* 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).
365 lines
11 KiB
Go
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
|
|
}
|