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}) } }