diff --git a/PLAN.md b/PLAN.md index 860996e94..8ef267ad5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -219,7 +219,7 @@ footer: | | 2606202100 | 🔳 | opus | [Parity perf: make InlineBlocks a light inline scan, not a goldmark re-parse](plan/2606202100_parity-light-inline-scan.md) | | 2606210840 | ✅ | sonnet | [Same-file anchor-resolution rule for true gomarklint parity](plan/2606210840_same-file-anchor-resolution-rule.md) | | 2606211907 | ✅ | | [arch-fix: split internal/engine/runner.go](plan/2606211907_arch-fix-runner-srp-split.md) | -| 2606211908 | 🔲 | | [arch-fix: split internal/lint/layer0.go](plan/2606211908_arch-fix-layer0-split.md) | +| 2606211908 | ✅ | | [arch-fix: split internal/lint/layer0.go](plan/2606211908_arch-fix-layer0-split.md) | | 2606211909 | 🔲 | | [arch-fix: split internal/lsp/server.go](plan/2606211909_arch-fix-lsp-server-split.md) | | 2606211910 | 🔲 | | [arch-fix: add trivial-accessor exemption comments in workspace.go](plan/2606211910_arch-fix-workspace-exemptions.md) | diff --git a/internal/lint/layer0.go b/internal/lint/layer0.go index 82b2ae779..8d76e046a 100644 --- a/internal/lint/layer0.go +++ b/internal/lint/layer0.go @@ -1,31 +1,6 @@ package lint -import ( - "bytes" - "regexp" -) - -// allowedBlockTags is the CommonMark type-6 HTML block tag set, mirroring -// parser.allowedBlockTags (unexported there). A type-6 HTML block opens -// only on one of these tag names; the list must stay in sync with the -// goldmark fork so the Layer 0 scan classifies HTML blocks identically. -var allowedBlockTags = map[string]bool{ - "address": true, "article": true, "aside": true, "base": true, - "basefont": true, "blockquote": true, "body": true, "caption": true, - "center": true, "col": true, "colgroup": true, "dd": true, - "details": true, "dialog": true, "dir": true, "div": true, - "dl": true, "dt": true, "fieldset": true, "figcaption": true, - "figure": true, "footer": true, "form": true, "frame": true, - "frameset": true, "h1": true, "h2": true, "h3": true, "h4": true, - "h5": true, "h6": true, "head": true, "header": true, "hr": true, - "html": true, "iframe": true, "legend": true, "li": true, - "link": true, "main": true, "menu": true, "menuitem": true, - "meta": true, "nav": true, "noframes": true, "ol": true, - "optgroup": true, "option": true, "p": true, "param": true, - "search": true, "section": true, "summary": true, "table": true, - "tbody": true, "td": true, "tfoot": true, "th": true, "thead": true, - "title": true, "tr": true, "track": true, "ul": true, -} +import "bytes" // BlockKind classifies a Layer 0 block span by its leading construct. type BlockKind uint8 @@ -258,8 +233,9 @@ func (s *scanner) tryBlockquote() bool { // (a fence or a >=4-column indent); the overwhelmingly common // prose-only block quote sets it false and skips the recursive scan and // its allocations entirely. - var body [][]byte - var parentLine []int + remaining := len(s.lines) - s.i + body := make([][]byte, 0, remaining) + parentLine := make([]int, 0, remaining) codeCapable := false // openFence tracks whether a fenced code block opened by a marker line // is still open. A fenced code block inside a quote must keep its `>` @@ -336,200 +312,6 @@ func (s *scanner) tryBlockquote() bool { return true } -// advanceFenceState advances the open-fence tracking for a block quote's -// stripped body line, using the fence-open result the caller already -// computed (opensFence / fi) so openingFence is not re-run. When no fence -// is open, a fence opener starts one; when a fence is open, a matching -// closing fence ends it. Used so the quote scan knows a fenced code block -// is still open and therefore cannot be lazily continued by a non-marker -// line. -func advanceFenceState(open *fenceInfo, line []byte, fi fenceInfo, opensFence bool) *fenceInfo { - if open == nil { - if opensFence { - f := fi - return &f - } - return nil - } - if closingFence(line, *open) { - return nil - } - return open -} - -// isLazyContinuation reports whether line can lazily continue an open -// block quote paragraph or code block: a non-blank line that does not begin -// a new top-level block. Per CommonMark, a line starting a fence, PI, HTML -// block, ATX heading, list, thematic break, or nested quote interrupts the -// quote instead of continuing it; everything else (plain text, including a -// 4-space-indented line, which cannot start indented code mid-paragraph) -// is a lazy continuation. The caller has already handled the quote-marker -// and blank-line cases, so this only classifies non-marker, non-blank -// lines. -func isLazyContinuation(line []byte) bool { - if _, ok := openingFence(line); ok { - return false - } - if opensPI(line) || openHTMLBlock(line, true) != htmlNone { - return false - } - return paragraphLeadKind(line) == BlockParagraph -} - -// lineHasNonFenceCode reports whether line could contribute a code block to -// a recursively-scanned block-quote body for a reason OTHER than opening a -// fence — the caller already tested the fence case via openingFence and -// folds it in separately. It is true when the line carries a >=4-column -// indent (potential indented code) or is itself a nested block quote -// (whose deeper levels may hold code only the recursive scan can reach). -// May over-report (an indented or quoted line that yields no code), which -// only costs a recursion that finds nothing; it must never under-report. -func lineHasNonFenceCode(line []byte) bool { - if indentWidth(line) >= 4 && !isBlankLine(line) { - return true - } - return paragraphLeadKind(line) == BlockQuote -} - -// stripQuoteMarker removes one block-quote level from line: up to 3 spaces -// of indent, a `>`, and one optional following space. A line with no -// marker (a lazy continuation) is returned unchanged. -func stripQuoteMarker(line []byte) []byte { - i := leadingSpaces(line) - if i >= len(line) || line[i] != '>' { - return line - } - i++ - if i < len(line) && line[i] == ' ' { - i++ - } - return line[i:] -} - -// fenceInfo describes an opening fenced-code fence line. -type fenceInfo struct { - char byte - indent int - length int - // hasInfo records whether the opening fence carries a non-empty info - // string after the fence run. goldmark exposes no source position for - // an info-less, content-less fence, so the projection emits no lines - // for it — hasInfo drives that quirk. - hasInfo bool -} - -// openingFence parses line as a fenced-code opening fence, returning its -// data and ok=true when it qualifies: indent < 4, a run of >= 3 identical -// fence characters, and (for backtick fences) no backtick in the info -// string. Mirrors fencedCodeBlockParser.Open. -func openingFence(line []byte) (fenceInfo, bool) { - indent := leadingSpaces(line) - if indent >= 4 { - return fenceInfo{}, false - } - if indent >= len(line) { - return fenceInfo{}, false - } - ch := line[indent] - if ch != '`' && ch != '~' { - return fenceInfo{}, false - } - j := indent - for j < len(line) && line[j] == ch { - j++ - } - length := j - indent - if length < 3 { - return fenceInfo{}, false - } - rest := line[j:] - if ch == '`' && bytes.IndexByte(rest, '`') >= 0 { - return fenceInfo{}, false - } - return fenceInfo{ - char: ch, - indent: indent, - length: length, - hasInfo: len(bytes.TrimSpace(rest)) > 0, - }, true -} - -// closingFence reports whether line closes a fence opened with fi: indent -// < 4, a run of >= fi.length identical fence characters, and only -// whitespace after the run. Mirrors fencedCodeBlockParser.Continue. -func closingFence(line []byte, fi fenceInfo) bool { - indent := leadingSpaces(line) - if indent >= 4 { - return false - } - j := indent - for j < len(line) && line[j] == fi.char { - j++ - } - if j-indent < fi.length { - return false - } - return isBlankLine(line[j:]) -} - -// tryFence recognises a fenced code block at the cursor. It marks every -// line from the opening fence through the closing fence (or end of -// document for an unclosed fence) as code, records the span, and advances -// the cursor past it. Returns false when the cursor line is not a fence. -func (s *scanner) tryFence() bool { - fi, ok := openingFence(s.lines[s.i]) - if !ok { - return false - } - openLine := s.i // 0-based opening fence index - // Scan content lines until a closing fence or EOF. The closing fence - // is never a content line (goldmark closes before appending it). - lastContent := 0 // 1-based; 0 means "no content lines" - closed := false - s.i++ - for s.i < len(s.lines) { - if s.trailingEmptyLine(s.i) { - break - } - if closingFence(s.lines[s.i], fi) { - closed = true - break - } - lastContent = s.i + 1 - s.i++ - } - // goldmark exposes no source position for an info-less, content-less - // fence, so addFencedCodeBlockLines emits nothing for it. Mirror that: - // skip marking entirely when the fence has neither info nor content. - if fi.hasInfo || lastContent > 0 { - s.markCode(openLine) - for ln := openLine + 2; ln <= lastContent; ln++ { - s.markCode(ln - 1) - } - // Mirror addFencedCodeBlockLines: the closing fence is the line - // after the last content line (or after the opening fence when - // there were no content lines). For a closed fence that is the - // matched line; for an unclosed fence it is a phantom line, marked - // only when within bounds. - closeLine := lastContent + 1 - if lastContent == 0 { - closeLine = openLine + 2 // 0-based open +1 to 1-based, +1 next - } - if closeLine <= len(s.lines) { - s.markCode(closeLine - 1) - } - } - if closed { - s.i++ // advance past the matched closing fence line - } - s.addSpan(BlockFencedCode, openLine, s.i-1, 0) - // Record fence closure for MDS031: closed is local to this scan, so - // stamp it on the span tryFence just appended. - s.l0.BlockSpans[len(s.l0.BlockSpans)-1].Closed = closed - s.prevNonBlankParagraph = false - return true -} - // tryPI recognises a processing-instruction block at the cursor. It // mirrors the piBlockParser: an opening line with up to 3 spaces of // indent, a `|/>|$)`) - htmlType1Close = regexp.MustCompile(`(?i)`) - htmlType2Open = regexp.MustCompile(`^[ ]{0,3}") - htmlClose3 = []byte("?>") - htmlClose4 = []byte(">") - htmlClose5 = []byte("]]>") -) - -// tryHTMLBlock recognises an HTML block at the cursor and consumes it, -// recording the span and advancing past it. Its interior is opaque to the -// code/PI/fence scanners, so an indented line inside an HTML comment is not -// mistaken for indented code. inParagraph suppresses type 7 (which cannot -// interrupt a paragraph). Returns false when the cursor line opens no HTML -// block. -func (s *scanner) tryHTMLBlock(inParagraph bool) bool { - t := openHTMLBlock(s.lines[s.i], inParagraph) - if t == htmlNone { - return false - } - start := s.i - closeOnTerminator := t >= htmlType1 && t <= htmlType5 - // Types 1–5 may close on their opening line. - if closeOnTerminator && htmlBlockCloses(s.lines[s.i], t) { - s.i++ - s.addSpan(BlockHTML, start, start, 0) - s.prevNonBlankParagraph = false - return true - } - s.i++ - for s.i < len(s.lines) { - if s.trailingEmptyLine(s.i) { - break - } - cur := s.lines[s.i] - if closeOnTerminator { - if htmlBlockCloses(cur, t) { - s.i++ - break - } - } else if isBlankLine(cur) { - // Types 6 and 7 close before the first blank line. - break - } - s.i++ - } - s.addSpan(BlockHTML, start, s.i-1, 0) - s.prevNonBlankParagraph = false - return true -} - // opensPI reports whether line opens a processing-instruction block: up // to 3 spaces of indent, a `= 4 || indent >= len(line) { - return BlockParagraph - } - switch line[indent] { - case '>': - return BlockQuote - case '*', '-', '+': - if isThematicBreak(line) { - return BlockThematicBreak - } - if isBulletMarker(line, indent) { - return BlockList - } - return BlockParagraph - case '_': - if isThematicBreak(line) { - return BlockThematicBreak - } - return BlockParagraph - } - if isOrderedMarker(line, indent) { - return BlockList - } - return BlockParagraph -} - -var ( - fenceBacktickRun = []byte("```") - fenceTildeRun = []byte("~~~") - fourSpaceRun = []byte(" ") -) - -// SourceMayHaveCodeBlock reports whether source could contain a fenced or -// indented code block: it holds a fenced-code marker run (``` or ~~~), a tab, -// or a run of four spaces. Every code block forces one of these bytes — -// fences need three backticks or tildes; an indented code block needs a -// four-column indent, which is four spaces or a tab — regardless of how -// deeply the block nests inside lists or block quotes. -// -// The Layer 0 parse-skip gate skips the goldmark parse only when this returns -// false. A source with none of these markers has no code block, so its -// CollectCodeBlockLines is empty under both the Layer 0 scan and the AST and -// the line-based rules behave identically. Any source that might hold code is -// parsed normally, which sidesteps every Layer 0/AST CodeBlockLines -// divergence — all of which require a code block to be present (the scanner -// does not descend into a list item's content, so a fence or indent inside a -// list item is the known divergence class; this guard makes the gate -// indifferent to it). The check is deliberately coarse — an inline `code` -// span or a column of alignment spaces also trips it — but provably sound, -// allocation-free, and far more robust than re-deriving goldmark's -// container-aware code-block detection in the gate. -func SourceMayHaveCodeBlock(source []byte) bool { - return bytes.IndexByte(source, '\t') >= 0 || - bytes.Contains(source, fenceBacktickRun) || - bytes.Contains(source, fenceTildeRun) || - bytes.Contains(source, fourSpaceRun) -} - -// SourceMayHaveBlockQuote reports whether source could contain a block -// quote: it holds at least one `>` byte. A block quote requires a `>` -// marker, so a source with no `>` has no quote. -// -// The Layer 0 parse-skip gate skips the goldmark parse only when this -// returns false. The scanner collapses a block quote into a single -// BlockQuote span and does not descend into its body to emit the -// heading and fenced-code spans block-kind rules (MDS002, MDS015) react -// to, so a quote-nested heading or fence is invisible to the block scan -// while the AST path still flags it. Disqualifying any source that might -// hold a quote sidesteps that divergence the same way the code-block -// guard handles a list-nested code block. The check is deliberately -// coarse — a `>` in an autolink, raw HTML, or prose also trips it — but -// provably sound and allocation-free. -func SourceMayHaveBlockQuote(source []byte) bool { - return bytes.IndexByte(source, '>') >= 0 -} - -// blockDepth returns the block-quote nesting depth of line: the number of -// leading `>` markers (each optionally followed by a space), after up to 3 -// spaces of indent. Non-quote lines are depth 0. -func blockDepth(line []byte) int { - depth := 0 - i := 0 - for { - j := i - for j < len(line) && j-i < 4 && line[j] == ' ' { - j++ - } - if j < len(line) && line[j] == '>' { - depth++ - j++ - if j < len(line) && line[j] == ' ' { - j++ - } - i = j - continue - } - break - } - return depth -} - -// isBulletMarker reports whether the marker at indent is a list bullet -// (`-`, `*`, `+` followed by a space, tab, or end of line). -func isBulletMarker(line []byte, indent int) bool { - j := indent + 1 - return j >= len(line) || line[j] == ' ' || line[j] == '\t' || line[j] == '\r' -} - -// isOrderedMarker reports whether line opens with an ordered-list marker: -// 1–9 digits, a `.` or `)`, then a space, tab, or end of line. -func isOrderedMarker(line []byte, indent int) bool { - j := indent - digits := 0 - for j < len(line) && line[j] >= '0' && line[j] <= '9' { - j++ - digits++ - } - if digits == 0 || digits > 9 { - return false - } - if j >= len(line) || (line[j] != '.' && line[j] != ')') { - return false - } - j++ - return j >= len(line) || line[j] == ' ' || line[j] == '\t' || line[j] == '\r' -} - -// isThematicBreak reports whether line is a thematic break: at most 3 -// spaces of indent, then 3 or more of a single `-`, `*`, or `_` character -// with only spaces interspersed. -func isThematicBreak(line []byte) bool { - indent := leadingSpaces(line) - if indent >= 4 || indent >= len(line) { - return false - } - ch := line[indent] - if ch != '-' && ch != '*' && ch != '_' { - return false - } - count := 0 - for j := indent; j < len(line); j++ { - switch c := line[j]; c { - case ch: - count++ - case ' ', '\t', '\r': - default: - return false - } - } - return count >= 3 -} - -// isSetextUnderline reports whether line is a setext heading underline: at -// most 3 spaces of indent, then a run of only `=` or only `-` characters -// (with optional trailing spaces). -func isSetextUnderline(line []byte) bool { - indent := leadingSpaces(line) - if indent >= 4 || indent >= len(line) { - return false - } - ch := line[indent] - if ch != '=' && ch != '-' { - return false - } - j := indent - for j < len(line) && line[j] == ch { - j++ - } - return isBlankLine(line[j:]) -} - // addSpan appends a block span [start, end] (1-based, inclusive) of the // given kind and nesting depth. func (s *scanner) addSpan(kind BlockKind, start, end, depth int) { diff --git a/internal/lint/layer0_fence.go b/internal/lint/layer0_fence.go new file mode 100644 index 000000000..ba59a0833 --- /dev/null +++ b/internal/lint/layer0_fence.go @@ -0,0 +1,148 @@ +package lint + +import "bytes" + +// fenceInfo describes an opening fenced-code fence line. +type fenceInfo struct { + char byte + indent int + length int + // hasInfo records whether the opening fence carries a non-empty info + // string after the fence run. goldmark exposes no source position for + // an info-less, content-less fence, so the projection emits no lines + // for it — hasInfo drives that quirk. + hasInfo bool +} + +// openingFence parses line as a fenced-code opening fence, returning its +// data and ok=true when it qualifies: indent < 4, a run of >= 3 identical +// fence characters, and (for backtick fences) no backtick in the info +// string. Mirrors fencedCodeBlockParser.Open. +func openingFence(line []byte) (fenceInfo, bool) { + indent := leadingSpaces(line) + if indent >= 4 { + return fenceInfo{}, false + } + if indent >= len(line) { + return fenceInfo{}, false + } + ch := line[indent] + if ch != '`' && ch != '~' { + return fenceInfo{}, false + } + j := indent + for j < len(line) && line[j] == ch { + j++ + } + length := j - indent + if length < 3 { + return fenceInfo{}, false + } + rest := line[j:] + if ch == '`' && bytes.IndexByte(rest, '`') >= 0 { + return fenceInfo{}, false + } + return fenceInfo{ + char: ch, + indent: indent, + length: length, + hasInfo: len(bytes.TrimSpace(rest)) > 0, + }, true +} + +// closingFence reports whether line closes a fence opened with fi: indent +// < 4, a run of >= fi.length identical fence characters, and only +// whitespace after the run. Mirrors fencedCodeBlockParser.Continue. +func closingFence(line []byte, fi fenceInfo) bool { + indent := leadingSpaces(line) + if indent >= 4 { + return false + } + j := indent + for j < len(line) && line[j] == fi.char { + j++ + } + if j-indent < fi.length { + return false + } + return isBlankLine(line[j:]) +} + +// advanceFenceState advances the open-fence tracking for a block quote's +// stripped body line, using the fence-open result the caller already +// computed (opensFence / fi) so openingFence is not re-run. When no fence +// is open, a fence opener starts one; when a fence is open, a matching +// closing fence ends it. Used so the quote scan knows a fenced code block +// is still open and therefore cannot be lazily continued by a non-marker +// line. +func advanceFenceState(open *fenceInfo, line []byte, fi fenceInfo, opensFence bool) *fenceInfo { + if open == nil { + if opensFence { + f := fi + return &f + } + return nil + } + if closingFence(line, *open) { + return nil + } + return open +} + +// tryFence recognises a fenced code block at the cursor. It marks every +// line from the opening fence through the closing fence (or end of +// document for an unclosed fence) as code, records the span, and advances +// the cursor past it. Returns false when the cursor line is not a fence. +func (s *scanner) tryFence() bool { + fi, ok := openingFence(s.lines[s.i]) + if !ok { + return false + } + openLine := s.i // 0-based opening fence index + // Scan content lines until a closing fence or EOF. The closing fence + // is never a content line (goldmark closes before appending it). + lastContent := 0 // 1-based; 0 means "no content lines" + closed := false + s.i++ + for s.i < len(s.lines) { + if s.trailingEmptyLine(s.i) { + break + } + if closingFence(s.lines[s.i], fi) { + closed = true + break + } + lastContent = s.i + 1 + s.i++ + } + // goldmark exposes no source position for an info-less, content-less + // fence, so addFencedCodeBlockLines emits nothing for it. Mirror that: + // skip marking entirely when the fence has neither info nor content. + if fi.hasInfo || lastContent > 0 { + s.markCode(openLine) + for ln := openLine + 2; ln <= lastContent; ln++ { + s.markCode(ln - 1) + } + // Mirror addFencedCodeBlockLines: the closing fence is the line + // after the last content line (or after the opening fence when + // there were no content lines). For a closed fence that is the + // matched line; for an unclosed fence it is a phantom line, marked + // only when within bounds. + closeLine := lastContent + 1 + if lastContent == 0 { + closeLine = openLine + 2 // 0-based open +1 to 1-based, +1 next + } + if closeLine <= len(s.lines) { + s.markCode(closeLine - 1) + } + } + if closed { + s.i++ // advance past the matched closing fence line + } + s.addSpan(BlockFencedCode, openLine, s.i-1, 0) + // Record fence closure for MDS031: closed is local to this scan, so + // stamp it on the span tryFence just appended. + s.l0.BlockSpans[len(s.l0.BlockSpans)-1].Closed = closed + s.prevNonBlankParagraph = false + return true +} diff --git a/internal/lint/layer0_html.go b/internal/lint/layer0_html.go new file mode 100644 index 000000000..44d76077e --- /dev/null +++ b/internal/lint/layer0_html.go @@ -0,0 +1,229 @@ +package lint + +import ( + "bytes" + "regexp" +) + +// allowedBlockTags is the CommonMark type-6 HTML block tag set, mirroring +// parser.allowedBlockTags (unexported there). A type-6 HTML block opens +// only on one of these tag names; the list must stay in sync with the +// goldmark fork so the Layer 0 scan classifies HTML blocks identically. +var allowedBlockTags = map[string]bool{ + "address": true, "article": true, "aside": true, "base": true, + "basefont": true, "blockquote": true, "body": true, "caption": true, + "center": true, "col": true, "colgroup": true, "dd": true, + "details": true, "dialog": true, "dir": true, "div": true, + "dl": true, "dt": true, "fieldset": true, "figcaption": true, + "figure": true, "footer": true, "form": true, "frame": true, + "frameset": true, "h1": true, "h2": true, "h3": true, "h4": true, + "h5": true, "h6": true, "head": true, "header": true, "hr": true, + "html": true, "iframe": true, "legend": true, "li": true, + "link": true, "main": true, "menu": true, "menuitem": true, + "meta": true, "nav": true, "noframes": true, "ol": true, + "optgroup": true, "option": true, "p": true, "param": true, + "search": true, "section": true, "summary": true, "table": true, + "tbody": true, "td": true, "tfoot": true, "th": true, "thead": true, + "title": true, "tr": true, "track": true, "ul": true, +} + +// htmlBlockType identifies which of CommonMark's seven HTML block kinds a +// line opens (0 = none). Each kind has a distinct closing condition, which +// htmlClose encodes. +type htmlBlockType int + +const ( + htmlNone htmlBlockType = iota + htmlType1 + htmlType2 + htmlType3 + htmlType4 + htmlType5 + htmlType6 + htmlType7 +) + +var ( + htmlType1Open = regexp.MustCompile(`(?i)^[ ]{0,3}<(script|pre|style|textarea)(\s|>|/>|$)`) + htmlType1Close = regexp.MustCompile(`(?i)`) + htmlType6Open = regexp.MustCompile(`^[ ]{0,3}|/>|$)`) + htmlType7Open = regexp.MustCompile(`^[ ]{0,3}<(/[ ]*)?[a-zA-Z][a-zA-Z0-9-]*(\s[^>]*)?[ ]*/?>[ \t\r]*$`) +) + +// openHTMLBlock classifies line as an HTML block opener, returning the +// type (htmlNone when none). It mirrors the precedence in +// htmlBlockParser.Open: types 1–5 first, then type 7 (gated on an allowed +// or generic tag and unable to interrupt a paragraph), then type 6. The +// inParagraph flag suppresses type 7, which cannot interrupt a paragraph. +func openHTMLBlock(line []byte, inParagraph bool) htmlBlockType { + // Every HTML-block opener is anchored `^[ ]{0,3}<`, so a line whose + // first non-space byte (within the first 4 columns) is not `<` can + // never open one. Gate the regexp battery on that cheap byte check so + // ordinary prose lines — the overwhelming common case in the Layer 0 + // hot path — skip the regexp battery entirely. + indent := leadingSpaces(line) + if indent > 3 || indent >= len(line) || line[indent] != '<' { + return htmlNone + } + rest := line[indent:] + switch { + case htmlType1Open.Match(line): + return htmlType1 + case bytes.HasPrefix(rest, []byte("") + htmlClose3 = []byte("?>") + htmlClose5 = []byte("]]>") +) + +// tryHTMLBlock recognises an HTML block at the cursor and consumes it, +// recording the span and advancing past it. Its interior is opaque to the +// code/PI/fence scanners, so an indented line inside an HTML comment is not +// mistaken for indented code. inParagraph suppresses type 7 (which cannot +// interrupt a paragraph). Returns false when the cursor line opens no HTML +// block. +func (s *scanner) tryHTMLBlock(inParagraph bool) bool { + t := openHTMLBlock(s.lines[s.i], inParagraph) + if t == htmlNone { + return false + } + start := s.i + closeOnTerminator := t >= htmlType1 && t <= htmlType5 + // Types 1–5 may close on their opening line. + if closeOnTerminator && htmlBlockCloses(s.lines[s.i], t) { + s.i++ + s.addSpan(BlockHTML, start, start, 0) + s.prevNonBlankParagraph = false + return true + } + s.i++ + for s.i < len(s.lines) { + if s.trailingEmptyLine(s.i) { + break + } + cur := s.lines[s.i] + if closeOnTerminator { + if htmlBlockCloses(cur, t) { + s.i++ + break + } + } else if isBlankLine(cur) { + // Types 6 and 7 close before the first blank line. + break + } + s.i++ + } + s.addSpan(BlockHTML, start, s.i-1, 0) + s.prevNonBlankParagraph = false + return true +} diff --git a/internal/lint/layer0_para.go b/internal/lint/layer0_para.go new file mode 100644 index 000000000..4f0ed7be1 --- /dev/null +++ b/internal/lint/layer0_para.go @@ -0,0 +1,294 @@ +package lint + +import "bytes" + +var ( + fenceBacktickRun = []byte("```") + fenceTildeRun = []byte("~~~") + fourSpaceRun = []byte(" ") +) + +// SourceMayHaveCodeBlock reports whether source could contain a fenced or +// indented code block: it holds a fenced-code marker run (``` or ~~~), a tab, +// or a run of four spaces. Every code block forces one of these bytes — +// fences need three backticks or tildes; an indented code block needs a +// four-column indent, which is four spaces or a tab — regardless of how +// deeply the block nests inside lists or block quotes. +// +// The Layer 0 parse-skip gate skips the goldmark parse only when this returns +// false. A source with none of these markers has no code block, so its +// CollectCodeBlockLines is empty under both the Layer 0 scan and the AST and +// the line-based rules behave identically. Any source that might hold code is +// parsed normally, which sidesteps every Layer 0/AST CodeBlockLines +// divergence — all of which require a code block to be present (the scanner +// does not descend into a list item's content, so a fence or indent inside a +// list item is the known divergence class; this guard makes the gate +// indifferent to it). The check is deliberately coarse — an inline `code` +// span or a column of alignment spaces also trips it — but provably sound, +// allocation-free, and far more robust than re-deriving goldmark's +// container-aware code-block detection in the gate. +func SourceMayHaveCodeBlock(source []byte) bool { + return bytes.IndexByte(source, '\t') >= 0 || + bytes.Contains(source, fenceBacktickRun) || + bytes.Contains(source, fenceTildeRun) || + bytes.Contains(source, fourSpaceRun) +} + +// SourceMayHaveBlockQuote reports whether source could contain a block +// quote: it holds at least one `>` byte. A block quote requires a `>` +// marker, so a source with no `>` has no quote. +// +// The Layer 0 parse-skip gate skips the goldmark parse only when this +// returns false. The scanner collapses a block quote into a single +// BlockQuote span and does not descend into its body to emit the +// heading and fenced-code spans block-kind rules (MDS002, MDS015) react +// to, so a quote-nested heading or fence is invisible to the block scan +// while the AST path still flags it. Disqualifying any source that might +// hold a quote sidesteps that divergence the same way the code-block +// guard handles a list-nested code block. The check is deliberately +// coarse — a `>` in an autolink, raw HTML, or prose also trips it — but +// provably sound and allocation-free. +func SourceMayHaveBlockQuote(source []byte) bool { + return bytes.IndexByte(source, '>') >= 0 +} + +// scanParagraph consumes a paragraph: the run of non-blank lines that no +// other block kind claimed, stopping at a blank line, a fence, a PI, or an +// ATX heading. A `---` / `===` underline directly under a paragraph line +// promotes the run to a setext heading. Block quotes and list markers are +// recorded by kind but otherwise scanned as a single line so their inner +// constructs (which the projections do not depend on) stay simple. +func (s *scanner) scanParagraph() { + start := s.i + line := s.lines[s.i] + kind := paragraphLeadKind(line) + if kind != BlockParagraph { + s.addSpan(kind, start, start, blockDepth(line)) + s.i++ + s.prevNonBlankParagraph = kind == BlockQuote || kind == BlockList + return + } + s.i++ + for s.i < len(s.lines) { + if s.trailingEmptyLine(s.i) { + break + } + cur := s.lines[s.i] + if isBlankLine(cur) { + break + } + if isSetextUnderline(cur) { + s.markSetextRun(start, s.i) + s.i++ + s.prevNonBlankParagraph = false + return + } + if _, ok := openingFence(cur); ok { + break + } + if opensPI(cur) { + break + } + // An ATX heading interrupts a paragraph (atxHeadingParser: + // CanInterruptParagraph is true), so the paragraph ends before it. + if isATXHeadingLine(cur) { + break + } + // HTML blocks of types 1–6 can interrupt a paragraph (type 7 + // cannot, so inParagraph is true here). + if openHTMLBlock(cur, true) != htmlNone { + break + } + if paragraphLeadKind(cur) != BlockParagraph { + break + } + s.i++ + } + s.addSpan(BlockParagraph, start, s.i-1, 0) + s.prevNonBlankParagraph = true +} + +// markSetextRun records the paragraph run [start, underline] as a setext +// heading span. +func (s *scanner) markSetextRun(start, underline int) { + s.addSpan(BlockSetextHeading, start, underline, 0) +} + +// paragraphLeadKind classifies a non-blank, non-code, non-PI, non-ATX line +// by its leading marker so the paragraph scan can break on block-quote and +// list boundaries and tag thematic breaks. Returns BlockParagraph for an +// ordinary text line. +func paragraphLeadKind(line []byte) BlockKind { + indent := leadingSpaces(line) + if indent >= 4 || indent >= len(line) { + return BlockParagraph + } + switch line[indent] { + case '>': + return BlockQuote + case '*', '-', '+': + if isThematicBreak(line) { + return BlockThematicBreak + } + if isBulletMarker(line, indent) { + return BlockList + } + return BlockParagraph + case '_': + if isThematicBreak(line) { + return BlockThematicBreak + } + return BlockParagraph + } + if isOrderedMarker(line, indent) { + return BlockList + } + return BlockParagraph +} + +// blockDepth returns the block-quote nesting depth of line: the number of +// leading `>` markers (each optionally followed by a space), after up to 3 +// spaces of indent. Non-quote lines are depth 0. +func blockDepth(line []byte) int { + depth := 0 + i := 0 + for { + j := i + for j < len(line) && j-i < 4 && line[j] == ' ' { + j++ + } + if j < len(line) && line[j] == '>' { + depth++ + j++ + if j < len(line) && line[j] == ' ' { + j++ + } + i = j + continue + } + break + } + return depth +} + +// isLazyContinuation reports whether line can lazily continue an open +// block quote paragraph or code block: a non-blank line that does not begin +// a new top-level block. Per CommonMark, a line starting a fence, PI, HTML +// block, ATX heading, list, thematic break, or nested quote interrupts the +// quote instead of continuing it; everything else (plain text, including a +// 4-space-indented line, which cannot start indented code mid-paragraph) +// is a lazy continuation. The caller has already handled the quote-marker +// and blank-line cases, so this only classifies non-marker, non-blank +// lines. +func isLazyContinuation(line []byte) bool { + if _, ok := openingFence(line); ok { + return false + } + if isATXHeadingLine(line) { + return false + } + if opensPI(line) || openHTMLBlock(line, true) != htmlNone { + return false + } + return paragraphLeadKind(line) == BlockParagraph +} + +// lineHasNonFenceCode reports whether line could contribute a code block to +// a recursively-scanned block-quote body for a reason OTHER than opening a +// fence — the caller already tested the fence case via openingFence and +// folds it in separately. It is true when the line carries a >=4-column +// indent (potential indented code) or is itself a nested block quote +// (whose deeper levels may hold code only the recursive scan can reach). +// May over-report (an indented or quoted line that yields no code), which +// only costs a recursion that finds nothing; it must never under-report. +func lineHasNonFenceCode(line []byte) bool { + if indentWidth(line) >= 4 && !isBlankLine(line) { + return true + } + return paragraphLeadKind(line) == BlockQuote +} + +// stripQuoteMarker removes one block-quote level from line: up to 3 spaces +// of indent, a `>`, and one optional following space. A line with no +// marker (a lazy continuation) is returned unchanged. +func stripQuoteMarker(line []byte) []byte { + i := leadingSpaces(line) + if i >= len(line) || line[i] != '>' { + return line + } + i++ + if i < len(line) && line[i] == ' ' { + i++ + } + return line[i:] +} + +// isBulletMarker reports whether the marker at indent is a list bullet +// (`-`, `*`, `+` followed by a space, tab, or end of line). +func isBulletMarker(line []byte, indent int) bool { + j := indent + 1 + return j >= len(line) || line[j] == ' ' || line[j] == '\t' || line[j] == '\r' +} + +// isOrderedMarker reports whether line opens with an ordered-list marker: +// 1–9 digits, a `.` or `)`, then a space, tab, or end of line. +func isOrderedMarker(line []byte, indent int) bool { + j := indent + digits := 0 + for j < len(line) && line[j] >= '0' && line[j] <= '9' { + j++ + digits++ + } + if digits == 0 || digits > 9 { + return false + } + if j >= len(line) || (line[j] != '.' && line[j] != ')') { + return false + } + j++ + return j >= len(line) || line[j] == ' ' || line[j] == '\t' || line[j] == '\r' +} + +// isThematicBreak reports whether line is a thematic break: at most 3 +// spaces of indent, then 3 or more of a single `-`, `*`, or `_` character +// with only spaces interspersed. +func isThematicBreak(line []byte) bool { + indent := leadingSpaces(line) + if indent >= 4 || indent >= len(line) { + return false + } + ch := line[indent] + if ch != '-' && ch != '*' && ch != '_' { + return false + } + count := 0 + for j := indent; j < len(line); j++ { + switch c := line[j]; c { + case ch: + count++ + case ' ', '\t', '\r': + default: + return false + } + } + return count >= 3 +} + +// isSetextUnderline reports whether line is a setext heading underline: at +// most 3 spaces of indent, then a run of only `=` or only `-` characters +// (with optional trailing spaces). +func isSetextUnderline(line []byte) bool { + indent := leadingSpaces(line) + if indent >= 4 || indent >= len(line) { + return false + } + ch := line[indent] + if ch != '=' && ch != '-' { + return false + } + j := indent + for j < len(line) && line[j] == ch { + j++ + } + return isBlankLine(line[j:]) +} diff --git a/internal/lint/layer0_test.go b/internal/lint/layer0_test.go index f2dc89064..88ab50285 100644 --- a/internal/lint/layer0_test.go +++ b/internal/lint/layer0_test.go @@ -406,6 +406,16 @@ func TestLayer0_LazyContinuationRejectsPIAndHTML(t *testing.T) { assert.Equal(t, []int{2}, keysOf(l0.PIBlockLines)) } +func TestLayer0_LazyContinuationRejectsATXHeading(t *testing.T) { + // An ATX heading without a > prefix interrupts a block-quote lazy + // continuation and is not absorbed into the quote span. + l0 := scan("> para\n# heading\n") + require.GreaterOrEqual(t, len(l0.BlockSpans), 2) + assert.Equal(t, BlockQuote, l0.BlockSpans[0].Kind) + assert.Equal(t, 1, l0.BlockSpans[0].End, "quote must end at line 1, before the heading") + assert.Equal(t, BlockATXHeading, l0.BlockSpans[1].Kind) +} + func TestLayer0_ATXHeadingLevels(t *testing.T) { // Each ATX level 1–6 is a single-line heading; a 7-hash run is not. l0 := scan("# h1\n## h2\n### h3\n#### h4\n##### h5\n###### h6\n####### not\n") diff --git a/plan/2606211908_arch-fix-layer0-split.md b/plan/2606211908_arch-fix-layer0-split.md index c06c54815..ba0dd71e3 100644 --- a/plan/2606211908_arch-fix-layer0-split.md +++ b/plan/2606211908_arch-fix-layer0-split.md @@ -1,7 +1,7 @@ --- id: 2606211908 title: 'arch-fix: split internal/lint/layer0.go' -status: '🔲' +status: "✅" summary: >- Split layer0.go (1 203 lines) into focused sibling files along block-type sub-parsers @@ -64,11 +64,11 @@ stays under 600 lines. ## Acceptance Criteria -- [ ] `internal/lint/layer0.go` is under +- [x] `internal/lint/layer0.go` is under 600 lines. -- [ ] `go build ./...` passes. -- [ ] `go test ./...` passes. -- [ ] `go tool golangci-lint run` reports +- [x] `go build ./...` passes. +- [x] `go test ./...` passes. +- [x] `go tool golangci-lint run` reports no new issues. -- [ ] No logic changed — pure file +- [x] No logic changed — pure file reorganisation within the `lint` package.