diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 6cab83fb..291291eb 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -49,6 +49,8 @@ jobs: cache: true - name: Install benchstat + env: + GOTOOLCHAIN: auto run: go install golang.org/x/perf/cmd/benchstat@latest - name: Resolve base diff --git a/internal/core/file.go b/internal/core/file.go index ec5d6a18..77b16f69 100755 --- a/internal/core/file.go +++ b/internal/core/file.go @@ -26,8 +26,17 @@ var commentControlMatchesRE = regexp.MustCompile(`^vale (.+\..+)(\[.+\]) = (YES| // A File represents a linted text file. type File struct { - NLP nlp.Info // - - Summary bytes.Buffer // holds content to be included in summarization checks + NLP nlp.Info // - + Summary bytes.Buffer // holds content to be included in summarization checks + + // SummaryRuns place the summary's text in Content: each piece of prose + // written to Summary records where it came from, so that a match found in + // the summary can be reported at the text that was counted. Without them + // the match is located by searching Content for its text, and a copy in + // front matter, a code block or a heading -- none of which the summary + // holds -- takes the alert. See #1191. + SummaryRuns []nlp.Run + Alerts []Alert // all alerts associated with this file BaseStyles []string // base style assigned in .vale Lines []string // the File's Content split into lines diff --git a/internal/lint/ast.go b/internal/lint/ast.go index d44bb1e0..87cae674 100644 --- a/internal/lint/ast.go +++ b/internal/lint/ast.go @@ -355,9 +355,7 @@ func (l *Linter) lintScope(f *core.File, state *walker, txt string) error { for _, tag := range state.tagHistory { scope, match := tagToScope[tag] if (match && !core.StringInSlice(tag, inlineTags)) || heading.MatchString(tag) { - if scope == "text.blockquote" || scope == "text.list" { - f.Summary.WriteString(txt + "\n\n") - } + summarized := scope == "text.blockquote" || scope == "text.list" if !match { scope = "text.heading." + tag @@ -370,6 +368,9 @@ func (l *Linter) lintScope(f *core.File, state *walker, txt string) error { shift -= len(txt) b := state.block(txt, withClasses(scope, state)+f.MetaScope+f.RealExt, shift) + if summarized { + summarize(f, strings.Repeat(" ", shift)+txt, b, shift) + } b.Inline = inlineRuns(state.inline, b.Text, shift) state.gather(txt, b.Line, metric) @@ -398,14 +399,13 @@ func (l *Linter) lintScope(f *core.File, state *walker, txt string) error { } } - f.Summary.WriteString(txt + "\n\n") - // Counted here, and not from the summary: quotes and list items share the // summary so that readability metrics see all of a document's prose, but // `paragraphs` means what the `paragraph` scope reaches -- this branch. f.Metrics["paragraphs"]++ b := state.block(txt, withClasses("text", state)+f.MetaScope+f.RealExt, 0) + summarize(f, txt, b, 0) b.Inline = inlineRuns(state.inline, b.Text, 0) state.gather(txt, b.Line, "paragraphs") if err := l.lintProse(f, b, state.lines, true); err != nil { @@ -414,6 +414,24 @@ func (l *Linter) lintScope(f *core.File, state *walker, txt string) error { return l.lintInline(f, state, b, state.lines, 0) } +// summarize adds a block's text to the file's summary, recording where in +// the source each part of it sits. txt is what the summary receives and blk +// the block made from it, whose text begins shift bytes in. +func summarize(f *core.File, txt string, blk nlp.Block, shift int) { + at := f.Summary.Len() + shift + f.Summary.WriteString(txt + "\n\n") + + if blk.Offset >= 0 { + f.SummaryRuns = append(f.SummaryRuns, + nlp.Run{At: at, Src: blk.Offset, N: len(blk.Text)}) + return + } + for _, r := range blk.Runs { + f.SummaryRuns = append(f.SummaryRuns, + nlp.Run{At: at + r.At, Src: r.Src, N: r.N}) + } +} + // lintInline lints the inline elements captured inside blk -- link text, bold // and so on -- as scopes of their own. // @@ -523,6 +541,7 @@ func (l *Linter) lintSizedScopes(f *core.File) error { // TODO: is this the most efficient place to assign tagging? summary := nlp.NewLinedBlock(f.Content, f.Summary.String(), "summary"+f.RealExt, 0) + summary.Runs = f.SummaryRuns summary.Metrics = f.Metrics for _, blk := range []nlp.Block{summary} { diff --git a/testdata/e2e/checks.yaml b/testdata/e2e/checks.yaml index dd0d4cf2..8daa0711 100644 --- a/testdata/e2e/checks.yaml +++ b/testdata/e2e/checks.yaml @@ -263,6 +263,58 @@ cases: want: | test.md:2:30:T.SentenceLength:Try to keep sentences short (< 30 words). + - name: occurrence-summary-ancho + about: "#1191 -- a summary-scoped `occurrence` alert lands on the prose it + counted. The summary holds neither front matter, code, nor headings, so a + copy of the match in any of them is not what was counted; searching the + file for the match text handed it the alert" + files: + .vale.ini: | + StylesPath = styles + MinAlertLevel = suggestion + + [*.md] + BasedOnStyles = T + styles/T/Density.yml: | + extends: occurrence + message: "'finally' appears %d times" + level: suggestion + scope: summary + max: 1 + token: 'finally' + front.md: | + --- + summary: finally a value + --- + + # Title + + It works finally. It stops finally. + code.md: | + # Title + + ```sh + echo finally + ``` + + It works finally. It stops finally. + heading.md: | + # Finally, finally + + It works finally. It stops finally. + list.md: | + # Finally + + - It works finally. + - It stops finally. + args: --output=line front.md code.md heading.md list.md + exit: 0 + want: | + code.md:7:10:T.Density:'finally' appears 2 times + front.md:7:10:T.Density:'finally' appears 2 times + heading.md:3:10:T.Density:'finally' appears 2 times + list.md:3:12:T.Density:'finally' appears 2 times + - name: existence-eol-anchor about: "#892" dir: EndOfLine