template-jj/cmd/planctl/scan.go
Sid aa73e2f727 feat(planctl): scanner — goldmark-backed InCode + LineMask classification
* cmd/planctl/scan.go: Scan() / scanBytes() construct a goldmark parser with
  the TaskList extension; AST walk populates InCode[] for CodeBlock /
  FencedCodeBlock / HTMLBlock and LineMask[] for inline CodeSpan nodes with
  +/-1 byte extension to cover the surrounding backticks.
* offsetToLineCol folds '\n' bytes into the preceding line so block segment
  Stop-1 maps correctly — a subtle off-by-one that tripped the closing fence
  and trailing blank-line tests before the fix landed.
* cmd/planctl/scan_test.go: 7 test functions covering fenced / indented /
  HTML blocks, inline code spans (substring + exact-range), nested emphasis
  around code, mixed features in one document, EOF-in-fenced-block, and
  trailing-newline splitLines variations.
* go.mod: goldmark promoted from indirect to direct (scan.go imports it).

Parent task 2.0 from dev/plans/26172-planctl/tasks.md.
Codex code-review session 019db23c-6c44-7d00-b3a4-6653882f0964 (2 rounds).
2026-04-21 17:02:59 -06:00

235 lines
7.3 KiB
Go

package main
import (
"bytes"
"os"
"sort"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/text"
)
// spec:planctl/R1.5+D§3.2
// MaskRun is a [Start, End) column range in one line that should be masked
// out before tag-matching. Columns are 0-indexed byte offsets within the line.
type MaskRun struct {
Start int
End int
}
// spec:planctl/R1.5+D§3.2
// ScanResult captures one markdown file parsed to a goldmark AST plus
// per-line classification derived from the AST.
//
// Callers (the indexer, lint rules) use InCode + LineMask to skip tag-like
// tokens that live inside code blocks or inline code spans — per R1.5 those
// are documentation about tags, not tags.
type ScanResult struct {
Path string
Source []byte
Lines []string // newline-stripped, 0-indexed (exposed as 1-indexed to callers)
Root ast.Node // goldmark AST root
InCode []bool // parallel to Lines; true if line is inside a CodeBlock / FencedCodeBlock / HTMLBlock
LineMask [][]MaskRun // parallel to Lines; column ranges occupied by inline CodeSpan content
}
// planParser is the shared goldmark parser. We enable the TaskList extension
// so `[ ]` / `[x]` list items surface as east.TaskCheckBox nodes — the indexer
// (§3.3) relies on that to extract task lines. The extension ships inside the
// goldmark module, so no additional require line is needed.
//
// spec:planctl/D§3.2
var planParser = goldmark.New(goldmark.WithExtensions(extension.TaskList)).Parser()
// spec:planctl/R1.5+D§3.2
// Scan reads the file at path and produces a ScanResult.
func Scan(path string) (ScanResult, error) {
source, err := os.ReadFile(path)
if err != nil {
return ScanResult{}, err
}
return scanBytes(path, source), nil
}
// spec:planctl/R1.5+D§3.2
// scanBytes is the testable core of Scan that operates on in-memory source
// bytes without touching the filesystem.
func scanBytes(path string, source []byte) ScanResult {
reader := text.NewReader(source)
root := planParser.Parse(reader)
lines := splitLines(source)
newlineOffsets := buildNewlineIndex(source)
r := ScanResult{
Path: path,
Source: source,
Lines: lines,
Root: root,
InCode: make([]bool, len(lines)),
LineMask: make([][]MaskRun, len(lines)),
}
_ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch n.Kind() {
case ast.KindCodeBlock, ast.KindFencedCodeBlock, ast.KindHTMLBlock:
markBlockLines(n, newlineOffsets, r.InCode)
case ast.KindCodeSpan:
appendInlineMask(n, source, newlineOffsets, r.Lines, r.LineMask)
}
return ast.WalkContinue, nil
})
// Normalise LineMask entries: sort by Start for stable consumption.
for i := range r.LineMask {
if len(r.LineMask[i]) > 1 {
sort.Slice(r.LineMask[i], func(a, b int) bool {
return r.LineMask[i][a].Start < r.LineMask[i][b].Start
})
}
}
return r
}
// spec:planctl/D§3.2
// splitLines splits source on '\n' and drops a trailing empty element so the
// result is a natural "lines" slice. An input ending in '\n' produces len(lines)
// elements where the last element is the content before the final '\n'.
func splitLines(source []byte) []string {
if len(source) == 0 {
return nil
}
parts := bytes.Split(source, []byte{'\n'})
if n := len(parts); n > 0 && len(parts[n-1]) == 0 {
parts = parts[:n-1]
}
out := make([]string, len(parts))
for i, p := range parts {
out[i] = string(p)
}
return out
}
// spec:planctl/D§3.2
// buildNewlineIndex returns byte offsets of every '\n' in source, in order.
// Consumers binary-search it to map a byte offset to (line, column).
func buildNewlineIndex(source []byte) []int {
var offsets []int
for i, b := range source {
if b == '\n' {
offsets = append(offsets, i)
}
}
return offsets
}
// spec:planctl/D§3.2
// offsetToLineCol converts a byte offset into (1-indexed line, 0-indexed col).
// A '\n' byte is treated as the END of the line it terminates, not the start
// of the following line — that way a block segment whose Stop-1 lands exactly
// on the terminating '\n' of its last content line maps back to that content
// line, not to the line after.
func offsetToLineCol(offset int, newlineOffsets []int) (line, col int) {
idx := sort.SearchInts(newlineOffsets, offset+1)
// If offset IS a newline position, fold it into the preceding line.
if idx > 0 && newlineOffsets[idx-1] == offset {
line = idx
var lineStart int
if idx > 1 {
lineStart = newlineOffsets[idx-2] + 1
}
col = offset - lineStart
return
}
line = idx + 1
var lineStart int
if idx > 0 {
lineStart = newlineOffsets[idx-1] + 1
}
col = offset - lineStart
return
}
// spec:planctl/R1.5+D§3.2
// markBlockLines sets InCode[i] = true for every line covered by a block
// whose content lies in the given node's Lines() segments.
func markBlockLines(n ast.Node, newlineOffsets []int, inCode []bool) {
segs := n.Lines()
if segs == nil {
return
}
for i := 0; i < segs.Len(); i++ {
seg := segs.At(i)
if seg.Stop <= seg.Start {
continue
}
startLine, _ := offsetToLineCol(seg.Start, newlineOffsets)
// Stop is exclusive; step back one byte to land inside the segment.
stopLine, _ := offsetToLineCol(seg.Stop-1, newlineOffsets)
for L := startLine; L <= stopLine; L++ {
if L >= 1 && L <= len(inCode) {
inCode[L-1] = true
}
}
}
}
// spec:planctl/R1.5+D§3.2
// appendInlineMask appends MaskRun entries covering an inline CodeSpan's
// byte range. The mask extends one byte past the children's segment bounds
// on each side so the surrounding backticks are included — a tag like
// `` `_Requirements: R9.9_` `` must not extract R9.9. If the span crosses a
// line boundary (rare), the mask is expanded across all affected lines.
func appendInlineMask(n ast.Node, source []byte, newlineOffsets []int, lines []string, lineMask [][]MaskRun) {
minStart, maxStop := -1, -1
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
if tc, ok := c.(*ast.Text); ok {
if minStart == -1 || tc.Segment.Start < minStart {
minStart = tc.Segment.Start
}
if tc.Segment.Stop > maxStop {
maxStop = tc.Segment.Stop
}
}
}
if minStart == -1 {
// Empty code span or a variant we don't recognise; skip safely.
return
}
spanStart := minStart - 1
if spanStart < 0 {
spanStart = 0
}
spanStop := maxStop + 1
if spanStop > len(source) {
spanStop = len(source)
}
startLine, startCol := offsetToLineCol(spanStart, newlineOffsets)
stopLine, stopCol := offsetToLineCol(spanStop, newlineOffsets)
if startLine == stopLine {
if idx := startLine - 1; idx >= 0 && idx < len(lineMask) {
lineMask[idx] = append(lineMask[idx], MaskRun{Start: startCol, End: stopCol})
}
return
}
// Multi-line code span: mask the trailing portion of the first line,
// full intermediate lines, and the leading portion of the final line.
if idx := startLine - 1; idx >= 0 && idx < len(lineMask) {
lineMask[idx] = append(lineMask[idx], MaskRun{Start: startCol, End: len(lines[idx])})
}
for L := startLine; L < stopLine-1; L++ {
if L >= 0 && L < len(lineMask) {
lineMask[L] = append(lineMask[L], MaskRun{Start: 0, End: len(lines[L])})
}
}
if idx := stopLine - 1; idx >= 0 && idx < len(lineMask) {
lineMask[idx] = append(lineMask[idx], MaskRun{Start: 0, End: stopCol})
}
}