template-jj/cmd/planctl/scan_test.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

365 lines
11 KiB
Go

package main
import (
"strings"
"testing"
)
// spec:planctl/R1.5+D§3.2
func TestScanBytes_InCode(t *testing.T) {
tests := []struct {
name string
source string
wantIn []bool // parallel to Lines
wantLen int // expected line count; 0 = skip length assertion
}{
{
name: "plain prose has no code lines",
source: joinLines(
"This is a paragraph.",
"Another paragraph line.",
"",
"Third one.",
),
wantIn: []bool{false, false, false, false},
},
{
name: "fenced code block marks only the content lines",
source: joinLines(
"Before fence.",
"```",
"code line 1",
"code line 2",
"```",
"After fence.",
),
// Goldmark's FencedCodeBlock.Lines() covers the content between fences
// (lines 3 and 4 here), not the fence delimiter lines themselves.
wantIn: []bool{false, false, true, true, false, false},
},
{
name: "fenced code block with language info string",
source: joinLines(
"Intro",
"```go",
"func foo() {}",
"```",
),
wantIn: []bool{false, false, true, false},
},
{
name: "indented code block (4-space)",
source: joinLines(
"Paragraph.",
"",
" indented code",
" more indented code",
"",
"After.",
),
wantIn: []bool{false, false, true, true, false, false},
},
{
name: "HTML block",
source: joinLines(
"Paragraph.",
"",
"<div>",
" raw html",
"</div>",
"",
"After.",
),
wantIn: []bool{false, false, true, true, true, false, false},
},
{
name: "empty source produces empty result",
source: "",
wantIn: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := scanBytes("<test>", []byte(tc.source))
if len(r.InCode) != len(tc.wantIn) {
t.Fatalf("len(InCode) = %d, want %d\nsource:\n%s\nInCode: %v", len(r.InCode), len(tc.wantIn), tc.source, r.InCode)
}
for i := range tc.wantIn {
if r.InCode[i] != tc.wantIn[i] {
t.Errorf("InCode[%d] = %v, want %v (line: %q)", i, r.InCode[i], tc.wantIn[i], safeIndex(r.Lines, i))
}
}
})
}
}
// spec:planctl/R1.5+D§3.2
func TestScanBytes_InlineCodeMask(t *testing.T) {
tests := []struct {
name string
// source: a single-line snippet.
source string
// wantMaskedSubstrings: substrings that must land inside a masked range
// on the line they appear. Each substring is expected to appear exactly
// once; the test finds its columns via strings.Index + len.
wantMaskedSubstrings []string
// wantUnmaskedSubstrings: substrings that must NOT be covered by any
// MaskRun (i.e. they're in plain prose).
wantUnmaskedSubstrings []string
}{
{
name: "inline code span masks backticked content",
source: "Prose with `code here` and more prose.",
wantMaskedSubstrings: []string{"code here"},
wantUnmaskedSubstrings: []string{"Prose with", "more prose"},
},
{
name: "tag-like token inside backticks is masked",
source: "The literal `_Requirements: R9.9_` appears in docs.",
wantMaskedSubstrings: []string{"_Requirements: R9.9_"},
wantUnmaskedSubstrings: []string{"appears in docs"},
},
{
name: "two inline code spans on one line",
source: "First `alpha` then `beta` end.",
wantMaskedSubstrings: []string{"alpha", "beta"},
wantUnmaskedSubstrings: []string{"First", "then", "end"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
r := scanBytes("<test>", []byte(tc.source))
if len(r.Lines) == 0 {
t.Fatal("expected at least one line")
}
line := r.Lines[0]
mask := r.LineMask[0]
for _, needle := range tc.wantMaskedSubstrings {
col := strings.Index(line, needle)
if col < 0 {
t.Fatalf("substring %q not found in line %q", needle, line)
}
end := col + len(needle)
if !rangeCovered(mask, col, end) {
t.Errorf("substring %q [%d,%d) is not covered by mask %v", needle, col, end, mask)
}
}
for _, needle := range tc.wantUnmaskedSubstrings {
col := strings.Index(line, needle)
if col < 0 {
t.Fatalf("substring %q not found in line %q", needle, line)
}
end := col + len(needle)
if rangeOverlapsAny(mask, col, end) {
t.Errorf("substring %q [%d,%d) should NOT overlap mask %v", needle, col, end, mask)
}
}
})
}
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_NestedEmphasisAroundCode covers task 2.7's "nested emphasis"
// case — an inline code span inside an emphasis span should still produce a
// LineMask entry covering the code. CommonMark allows `_*emph with `code`*_`
// style; goldmark parses it and the scanner should mask "code" regardless.
func TestScanBytes_NestedEmphasisAroundCode(t *testing.T) {
cases := []string{
"Prose *italic with `tag _Requirements: R9.9_` inside* end.",
"Prose **bold with `tag _Design: D§9.9_` inside** end.",
}
for _, source := range cases {
t.Run(source[:20], func(t *testing.T) {
r := scanBytes("<t>", []byte(source))
if len(r.Lines) == 0 {
t.Fatal("expected one line")
}
line := r.Lines[0]
mask := r.LineMask[0]
// Find the tag inside the backticks and assert it's masked.
needle := "tag _"
col := strings.Index(line, needle)
if col < 0 {
t.Fatalf("needle %q not found in %q", needle, line)
}
if !rangeOverlapsAny(mask, col, col+len(needle)) {
t.Errorf("expected %q [%d,%d) to overlap mask %v — emphasis-nested code span must still mask",
needle, col, col+len(needle), mask)
}
})
}
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_MixedFeatures exercises a single source containing a fenced
// block, an indented block, an inline code span, and a plain paragraph
// together — the pattern a real plan file looks like.
func TestScanBytes_MixedFeatures(t *testing.T) {
source := joinLines(
"# Heading",
"",
"Paragraph with `inline code` and more text.",
"",
"```go",
"func fenced() {}",
"```",
"",
"Another paragraph.",
"",
" indented code block",
" second indented line",
"",
"Final paragraph.",
)
r := scanBytes("<t>", []byte(source))
// Expected line-level classification.
type lineCheck struct {
line int // 1-indexed
inCode bool
}
checks := []lineCheck{
{1, false}, // "# Heading"
{3, false}, // paragraph with inline code
{5, false}, // "```go"
{6, true}, // "func fenced() {}"
{7, false}, // closing "```"
{9, false}, // "Another paragraph."
{11, true}, // " indented code block"
{12, true}, // " second indented line"
{14, false}, // "Final paragraph."
}
for _, c := range checks {
if r.InCode[c.line-1] != c.inCode {
t.Errorf("InCode[line %d] = %v, want %v (line content: %q)",
c.line, r.InCode[c.line-1], c.inCode, r.Lines[c.line-1])
}
}
// Inline-code mask on line 3 covers "inline code".
line3 := r.Lines[2]
col := strings.Index(line3, "inline code")
if col < 0 {
t.Fatalf("substring not found in line 3: %q", line3)
}
if !rangeCovered(r.LineMask[2], col, col+len("inline code")) {
t.Errorf("line 3 mask %v does not cover inline code span [%d,%d)",
r.LineMask[2], col, col+len("inline code"))
}
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_FencedBlockWithoutTrailingNewline pins the EOF edge — a source
// that ends mid-fenced-block without a final '\n'. Goldmark's segment Stop
// positions can point past EOF here; the scanner must not panic and must still
// classify the content line as InCode.
func TestScanBytes_FencedBlockWithoutTrailingNewline(t *testing.T) {
// Note: no trailing "\n" — source ends at "content".
source := "prose\n```\ncontent"
r := scanBytes("<t>", []byte(source))
if len(r.Lines) != 3 {
t.Fatalf("len(Lines) = %d, want 3; got %v", len(r.Lines), r.Lines)
}
// Line 3 is "content" inside the (unclosed) fenced block. Depending on
// goldmark's recovery, it may or may not be InCode — both outcomes are
// acceptable, the test's purpose is just to assert the scanner doesn't
// panic on the unterminated input.
_ = r.InCode
}
// spec:planctl/R1.5+D§3.2
// TestScanBytes_InlineCodeMask_ExactRanges pins down the boundary columns
// for single- and double-backtick code spans so future regressions in the
// mask-extension-by-1 logic are caught immediately.
func TestScanBytes_InlineCodeMask_ExactRanges(t *testing.T) {
source := "aa `bb` cc\n"
// Column layout: a=0 a=1 ' '=2 `=3 b=4 b=5 `=6 ' '=7 c=8 c=9
// CodeSpan children cover "bb" at [4, 6); mask extends by 1 on each side → [3, 7).
r := scanBytes("<t>", []byte(source))
if len(r.LineMask) != 1 || len(r.LineMask[0]) != 1 {
t.Fatalf("expected one mask on one line, got %v", r.LineMask)
}
got := r.LineMask[0][0]
if got.Start != 3 || got.End != 7 {
t.Errorf("MaskRun = %+v, want {Start:3, End:7}", got)
}
}
// spec:planctl/R1.5+D§3.2
func TestScanBytes_TagInsideFenceIsInCode(t *testing.T) {
// Regression guard for R1.5: a tag-like token inside a fenced block must
// land on a line with InCode==true so the indexer skips it.
source := joinLines(
"Some intro prose.",
"",
"```",
"- [ ] 1.1 do stuff _Requirements: R9.9_",
"```",
"",
"End.",
)
r := scanBytes("<test>", []byte(source))
// Line indices (1-based): 1=intro, 2=blank, 3=```, 4=tag-in-fence, 5=```, 6=blank, 7=End
if !r.InCode[3] { // 0-indexed: line 4 → index 3
t.Errorf("expected InCode[3] (tag-in-fence line) = true; got false.\nInCode=%v\nLines=%v", r.InCode, r.Lines)
}
}
// spec:planctl/D§3.2
func TestScanBytes_NewlineOffsets(t *testing.T) {
// Sanity: splitLines + newline index consistency across trailing-newline
// variations.
cases := []struct {
in string
wantLen int
wantLast string
}{
{"a\nb\nc", 3, "c"},
{"a\nb\nc\n", 3, "c"},
{"single", 1, "single"},
{"", 0, ""},
}
for _, tc := range cases {
r := scanBytes("<t>", []byte(tc.in))
if len(r.Lines) != tc.wantLen {
t.Errorf("splitLines(%q): len = %d, want %d", tc.in, len(r.Lines), tc.wantLen)
continue
}
if tc.wantLen > 0 && r.Lines[tc.wantLen-1] != tc.wantLast {
t.Errorf("splitLines(%q): last line = %q, want %q", tc.in, r.Lines[tc.wantLen-1], tc.wantLast)
}
}
}
// --- helpers ---
func joinLines(lines ...string) string {
return strings.Join(lines, "\n") + "\n"
}
func safeIndex(s []string, i int) string {
if i < 0 || i >= len(s) {
return "<out-of-range>"
}
return s[i]
}
// rangeCovered reports whether every byte in [start, end) lies inside some
// MaskRun in mask.
func rangeCovered(mask []MaskRun, start, end int) bool {
for _, m := range mask {
if m.Start <= start && end <= m.End {
return true
}
}
return false
}
// rangeOverlapsAny reports whether [start, end) intersects any MaskRun.
func rangeOverlapsAny(mask []MaskRun, start, end int) bool {
for _, m := range mask {
if start < m.End && m.Start < end {
return true
}
}
return false
}