* 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).
235 lines
7.3 KiB
Go
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})
|
|
}
|
|
}
|