diff --git a/PLAN.md b/PLAN.md index 93b6d98a3..18bd725db 100644 --- a/PLAN.md +++ b/PLAN.md @@ -43,7 +43,7 @@ footer: | | 83 | 🔳 | [Security hardening batch](plan/83_security-hardening-batch.md) | | 84 | 🔲 | [Symlink default-deny for file discovery](plan/84_symlink-default-deny.md) | | 85 | 🔳 | [Increase test coverage to 95% by extracting shared rule helpers](plan/85_coverage-to-95-percent.md) | -| 86 | 🔲 | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) | +| 86 | 🔳 | [Markdown flavor validation](plan/86_markdown-flavor-validation.md) | | 87 | 🔲 | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.md) | | 88 | ✅ | [TOC directive migration aid](plan/88_toc-directive-migration.md) | | 89 | 🔲 | [TOC generator directive and MDS035 auto-fix](plan/89_toc-generator-directive.md) | diff --git a/cmd/mdsmith/main.go b/cmd/mdsmith/main.go index 9c56e1795..1d725ca39 100644 --- a/cmd/mdsmith/main.go +++ b/cmd/mdsmith/main.go @@ -39,6 +39,7 @@ import ( _ "github.com/jeduden/mdsmith/internal/rules/include" _ "github.com/jeduden/mdsmith/internal/rules/linelength" _ "github.com/jeduden/mdsmith/internal/rules/listindent" + _ "github.com/jeduden/mdsmith/internal/rules/markdownflavor" _ "github.com/jeduden/mdsmith/internal/rules/maxfilelength" _ "github.com/jeduden/mdsmith/internal/rules/maxsectionlength" _ "github.com/jeduden/mdsmith/internal/rules/nobareurls" diff --git a/internal/integration/rules_test.go b/internal/integration/rules_test.go index c27520b0c..5a7acde7d 100644 --- a/internal/integration/rules_test.go +++ b/internal/integration/rules_test.go @@ -22,7 +22,7 @@ import ( _ "github.com/jeduden/mdsmith/internal/rules/catalog" _ "github.com/jeduden/mdsmith/internal/rules/concisenessscoring" _ "github.com/jeduden/mdsmith/internal/rules/crossfilereferenceintegrity" - _ "github.com/jeduden/mdsmith/internal/rules/directorystructure" + "github.com/jeduden/mdsmith/internal/rules/directorystructure" _ "github.com/jeduden/mdsmith/internal/rules/emptysectionbody" _ "github.com/jeduden/mdsmith/internal/rules/fencedcodelanguage" _ "github.com/jeduden/mdsmith/internal/rules/fencedcodestyle" @@ -32,6 +32,7 @@ import ( _ "github.com/jeduden/mdsmith/internal/rules/include" _ "github.com/jeduden/mdsmith/internal/rules/linelength" _ "github.com/jeduden/mdsmith/internal/rules/listindent" + _ "github.com/jeduden/mdsmith/internal/rules/markdownflavor" _ "github.com/jeduden/mdsmith/internal/rules/maxfilelength" _ "github.com/jeduden/mdsmith/internal/rules/maxsectionlength" _ "github.com/jeduden/mdsmith/internal/rules/nobareurls" @@ -140,9 +141,15 @@ func applySettingsToRule( } func TestRuleFixtures(t *testing.T) { - primeDirectoryStructureWarnOnce(t) dirs := discoverFixtureDirs(t) + // MDS033's "no allowed patterns" warning is gated by a + // process-level sync.Once. After MDS033's own fixtures run, the + // rule is left in configured=true / allowed=[] state by the + // defaults-based cleanup. Silence the guard explicitly so later + // checkAllRules walks do not depend on warning emission order. + directorystructure.SilenceConfigWarningForTesting() + for _, dir := range dirs { base := filepath.Base(dir) m := ruleIDPattern.FindStringSubmatch(base) @@ -388,28 +395,6 @@ func runFixSingleFile( // --- shared helpers --- -// primeDirectoryStructureWarnOnce fires MDS033's "no allowed patterns" -// sync.Once warning up front. Without this, test cleanup in -// applySettingsToRule leaves MDS033 in a configured-but-empty state, -// so the warning would fire the first time any post-MDS033 fixture -// calls checkAllRules, producing a spurious diagnostic. -func primeDirectoryStructureWarnOnce(t *testing.T) { - t.Helper() - r := rule.ByID("MDS033") - if r == nil { - return - } - cr, ok := r.(rule.Configurable) - if !ok { - return - } - require.NoError(t, cr.ApplySettings(map[string]any{"allowed": []any{}})) - f, err := lint.NewFile("prime.md", []byte("# x\n")) - require.NoError(t, err) - _ = r.Check(f) - require.NoError(t, cr.ApplySettings(cr.DefaultSettings())) -} - func discoverFixtureDirs(t *testing.T) []string { t.Helper() dirs, err := filepath.Glob("../../internal/rules/MDS*-*") diff --git a/internal/rules/MDS034-markdown-flavor/README.md b/internal/rules/MDS034-markdown-flavor/README.md new file mode 100644 index 000000000..ddebb219c --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/README.md @@ -0,0 +1,145 @@ +--- +id: MDS034 +name: markdown-flavor +status: ready +description: >- + Flags Markdown syntax that the declared target + flavor does not render. +--- +# MDS034: markdown-flavor + +Flags Markdown syntax that the declared target +flavor does not render. + +- **ID**: MDS034 +- **Name**: `markdown-flavor` +- **Status**: ready +- **Default**: disabled +- **Fixable**: no (fix pipeline lands in a follow-up) +- **Implementation**: + [source](./) +- **Category**: meta + +## Settings + +| Key | Type | Description | +|--------|--------|---------------------------------| +| flavor | string | Target flavor; see table below. | + +The flavor name is case-sensitive. Supported +values: + +- `commonmark` — strict CommonMark; rejects every + tracked feature. +- `gfm` — GitHub Flavored Markdown; adds tables, + task lists, strikethrough, and bare-URL + autolinks. +- `goldmark` — mdsmith-defined profile; GFM plus + heading IDs. +- `pandoc` — Pandoc's default markdown; GFM plus + footnotes, definition lists, heading IDs, + superscript, subscript, math block, and inline + math. Rejects abbreviations (non-default + extension). +- `phpextra` — PHP Markdown Extra; tables, + footnotes, definition lists, heading IDs, and + abbreviations. Rejects GFM features and math. +- `multimarkdown` — MultiMarkdown; PHP Extra plus + math block and inline math. +- `myst` — MyST (Sphinx documentation flavor); + tables, strikethrough, footnotes, definition + lists, heading IDs, math block, and inline math. +- `any` — accepts every tracked feature. Use when + the target renderer is unknown or permissive and + you want to silence flavor diagnostics without + disabling the rule. + +## Config + +Enable with a target flavor: + +```yaml +rules: + markdown-flavor: + flavor: gfm +``` + +Disable (default): + +```yaml +rules: + markdown-flavor: false +``` + +## Detected features + +MDS034 tracks twelve syntax features whose +support varies across Markdown flavors. + +Eleven features are detected from the goldmark AST +of a dual parse. That parse enables five built-in +extensions: table, strikethrough, task list, +footnote, and definition list. It also enables the +heading-ID attribute parser. Five custom parsers +add superscript, subscript, math block, inline +math, and abbreviations. + +Bare-URL autolinks are detected separately. The +detector scans text nodes from the main parse for +URL-shaped text. It skips links, autolinks, code +spans, and code blocks. + +`flavor: any` accepts every feature and is omitted +from the table below. + +| Feature | commonmark | gfm | goldmark | pandoc | phpextra | multimarkdown | myst | +|--------------------|------------|-----|----------|--------|----------|---------------|------| +| tables | no | yes | yes | yes | yes | yes | yes | +| task lists | no | yes | yes | yes | no | no | no | +| strikethrough | no | yes | yes | yes | no | no | yes | +| bare-URL autolinks | no | yes | yes | yes | no | no | no | +| footnotes | no | no | no | yes | yes | yes | yes | +| definition lists | no | no | no | yes | yes | yes | yes | +| heading IDs | no | no | yes | yes | yes | yes | yes | +| superscript | no | no | no | yes | no | no | no | +| subscript | no | no | no | yes | no | no | no | +| math blocks | no | no | no | yes | no | yes | yes | +| inline math | no | no | no | yes | no | yes | yes | +| abbreviations | no | no | no | no | yes | yes | no | + +## Examples + +### Good + + + +```markdown +# Heading + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo +``` + + + +### Bad + + + +```markdown +# Heading + +| a | b | +| - | - | +| 1 | 2 | +``` + + diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-abbreviation.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-abbreviation.md new file mode 100644 index 000000000..03b1b4f06 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-abbreviation.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 1 + message: "abbreviations are not supported by commonmark" + - line: 5 + column: 5 + message: "abbreviations are not supported by commonmark" +--- +# Heading + +*[HTML]: Hyper Text Markup Language + +Use HTML here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-bare-url.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-bare-url.md new file mode 100644 index 000000000..cc3f9cc2f --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-bare-url.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 7 + message: "bare-URL autolinks are not supported by commonmark" +--- +# Heading + +Visit https://example.com for details. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-heading-id.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-heading-id.md new file mode 100644 index 000000000..a972cadb3 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-heading-id.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 1 + column: 11 + message: "heading IDs are not supported by commonmark" +--- +# Heading {#top} + +Body text. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-block.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-block.md new file mode 100644 index 000000000..88ad7d7de --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-block.md @@ -0,0 +1,13 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 1 + message: "math blocks are not supported by commonmark" +--- +# Heading + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-inline.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-inline.md new file mode 100644 index 000000000..dd12bc228 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-math-inline.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 5 + message: "inline math is not supported by commonmark" +--- +# Heading + +See $x+1$ above. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-strikethrough.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-strikethrough.md new file mode 100644 index 000000000..58c6b98d0 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-strikethrough.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 6 + message: "strikethrough is not supported by commonmark" +--- +# Heading + +Text ~~crossed out~~ here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-subscript.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-subscript.md new file mode 100644 index 000000000..ef7940105 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-subscript.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 2 + message: "subscript is not supported by commonmark" +--- +# Heading + +H~2~O is water. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-superscript.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-superscript.md new file mode 100644 index 000000000..673792ac5 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-superscript.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 7 + message: "superscript is not supported by commonmark" +--- +# Heading + +E = mc^2^ is famous. diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-table.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-table.md new file mode 100644 index 000000000..74b4238fc --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-table.md @@ -0,0 +1,13 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 1 + message: "tables are not supported by commonmark" +--- +# Heading + +| a | b | +| - | - | +| 1 | 2 | diff --git a/internal/rules/MDS034-markdown-flavor/bad/commonmark-task-list.md b/internal/rules/MDS034-markdown-flavor/bad/commonmark-task-list.md new file mode 100644 index 000000000..2e38457ea --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/commonmark-task-list.md @@ -0,0 +1,15 @@ +--- +settings: + flavor: commonmark +diagnostics: + - line: 3 + column: 3 + message: "task lists are not supported by commonmark" + - line: 4 + column: 3 + message: "task lists are not supported by commonmark" +--- +# Heading + +- [x] done +- [ ] todo diff --git a/internal/rules/MDS034-markdown-flavor/bad/gfm-definition-list.md b/internal/rules/MDS034-markdown-flavor/bad/gfm-definition-list.md new file mode 100644 index 000000000..91706f421 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/gfm-definition-list.md @@ -0,0 +1,12 @@ +--- +settings: + flavor: gfm +diagnostics: + - line: 3 + column: 1 + message: "definition lists are not supported by gfm" +--- +# Heading + +term +: definition diff --git a/internal/rules/MDS034-markdown-flavor/bad/gfm-footnote.md b/internal/rules/MDS034-markdown-flavor/bad/gfm-footnote.md new file mode 100644 index 000000000..8fbcf0cc5 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/gfm-footnote.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: gfm +diagnostics: + - line: 3 + column: 1 + message: "footnotes are not supported by gfm" + - line: 5 + column: 1 + message: "footnotes are not supported by gfm" +--- +# Heading + +A paragraph.[^1] + +[^1]: footnote body. diff --git a/internal/rules/MDS034-markdown-flavor/bad/gfm-heading-id.md b/internal/rules/MDS034-markdown-flavor/bad/gfm-heading-id.md new file mode 100644 index 000000000..5cab89b3a --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/gfm-heading-id.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: gfm +diagnostics: + - line: 1 + column: 11 + message: "heading IDs are not supported by gfm" +--- +# Heading {#top} + +Body text. diff --git a/internal/rules/MDS034-markdown-flavor/bad/multimarkdown-task-list.md b/internal/rules/MDS034-markdown-flavor/bad/multimarkdown-task-list.md new file mode 100644 index 000000000..65b8e5e1e --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/multimarkdown-task-list.md @@ -0,0 +1,15 @@ +--- +settings: + flavor: multimarkdown +diagnostics: + - line: 3 + column: 3 + message: "task lists are not supported by multimarkdown" + - line: 4 + column: 3 + message: "task lists are not supported by multimarkdown" +--- +# Heading + +- [x] done +- [ ] todo diff --git a/internal/rules/MDS034-markdown-flavor/bad/myst-abbreviation.md b/internal/rules/MDS034-markdown-flavor/bad/myst-abbreviation.md new file mode 100644 index 000000000..cef9ba0ce --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/myst-abbreviation.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: myst +diagnostics: + - line: 3 + column: 1 + message: "abbreviations are not supported by myst" + - line: 5 + column: 5 + message: "abbreviations are not supported by myst" +--- +# Heading + +*[HTML]: Hyper Text Markup Language + +Use HTML here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/pandoc-abbreviation.md b/internal/rules/MDS034-markdown-flavor/bad/pandoc-abbreviation.md new file mode 100644 index 000000000..395be36cb --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/pandoc-abbreviation.md @@ -0,0 +1,16 @@ +--- +settings: + flavor: pandoc +diagnostics: + - line: 3 + column: 1 + message: "abbreviations are not supported by pandoc" + - line: 5 + column: 5 + message: "abbreviations are not supported by pandoc" +--- +# Heading + +*[HTML]: Hyper Text Markup Language + +Use HTML here. diff --git a/internal/rules/MDS034-markdown-flavor/bad/phpextra-strikethrough.md b/internal/rules/MDS034-markdown-flavor/bad/phpextra-strikethrough.md new file mode 100644 index 000000000..310a55dc6 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/bad/phpextra-strikethrough.md @@ -0,0 +1,11 @@ +--- +settings: + flavor: phpextra +diagnostics: + - line: 3 + column: 6 + message: "strikethrough is not supported by phpextra" +--- +# Heading + +Text ~~crossed out~~ here. diff --git a/internal/rules/MDS034-markdown-flavor/good/any.md b/internal/rules/MDS034-markdown-flavor/good/any.md new file mode 100644 index 000000000..31cf1f91a --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/any.md @@ -0,0 +1,33 @@ +--- +settings: + flavor: any +--- +# Heading {#top} + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +E = mc^2^ and H~2~O. + +$x+1$ inline and + +$$ +a^2 + b^2 = c^2 +$$ + +*[API]: Application Programming Interface + +Use API here. diff --git a/internal/rules/MDS034-markdown-flavor/good/commonmark.md b/internal/rules/MDS034-markdown-flavor/good/commonmark.md new file mode 100644 index 000000000..60413382c --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/commonmark.md @@ -0,0 +1,7 @@ +--- +settings: + flavor: commonmark +--- +# Heading + +A plain CommonMark paragraph with nothing special. diff --git a/internal/rules/MDS034-markdown-flavor/good/gfm.md b/internal/rules/MDS034-markdown-flavor/good/gfm.md new file mode 100644 index 000000000..49a6e48b3 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/gfm.md @@ -0,0 +1,10 @@ +--- +settings: + flavor: gfm +--- +# Heading + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo diff --git a/internal/rules/MDS034-markdown-flavor/good/goldmark.md b/internal/rules/MDS034-markdown-flavor/good/goldmark.md new file mode 100644 index 000000000..ba6e32f07 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/goldmark.md @@ -0,0 +1,10 @@ +--- +settings: + flavor: goldmark +--- +# Heading {#top} + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo diff --git a/internal/rules/MDS034-markdown-flavor/good/multimarkdown.md b/internal/rules/MDS034-markdown-flavor/good/multimarkdown.md new file mode 100644 index 000000000..18868ca77 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/multimarkdown.md @@ -0,0 +1,26 @@ +--- +settings: + flavor: multimarkdown +--- +# Heading {#top} + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +*[API]: Application Programming Interface + +Use API here. + +See $x+1$ inline and + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/good/myst.md b/internal/rules/MDS034-markdown-flavor/good/myst.md new file mode 100644 index 000000000..4e4b7e62e --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/myst.md @@ -0,0 +1,24 @@ +--- +settings: + flavor: myst +--- +# Heading {#top} + +Text with ~~old~~ markup. + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +See $x+1$ inline and + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/good/pandoc.md b/internal/rules/MDS034-markdown-flavor/good/pandoc.md new file mode 100644 index 000000000..069b8de2d --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/pandoc.md @@ -0,0 +1,25 @@ +--- +settings: + flavor: pandoc +--- +# Heading {#top} + +Text with ~~old~~ markup and a task list: + +- [x] done +- [ ] todo + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +E = mc^2^ and H~2~O. + +See $x+1$ and + +$$ +a^2 + b^2 = c^2 +$$ diff --git a/internal/rules/MDS034-markdown-flavor/good/phpextra.md b/internal/rules/MDS034-markdown-flavor/good/phpextra.md new file mode 100644 index 000000000..c408338d3 --- /dev/null +++ b/internal/rules/MDS034-markdown-flavor/good/phpextra.md @@ -0,0 +1,20 @@ +--- +settings: + flavor: phpextra +--- +# Heading {#top} + +| a | b | +|-----|-----| +| 1 | 2 | + +Footnote reference.[^1] + +[^1]: footnote body. + +term +: definition + +*[API]: Application Programming Interface + +Use API here. diff --git a/internal/rules/directorystructure/rule.go b/internal/rules/directorystructure/rule.go index cfcc78a3f..33bf8d7a5 100644 --- a/internal/rules/directorystructure/rule.go +++ b/internal/rules/directorystructure/rule.go @@ -20,6 +20,17 @@ func init() { // clones the rule per file. var configWarned sync.Once +// SilenceConfigWarningForTesting consumes the package-level once- +// guard with a no-op, so later checks will not fire the "no allowed +// patterns" warning. Intended for tests that share a process and +// cannot tolerate a misconfigured-state leak from a previous rule's +// cleanup. Unlike resetting the sync.Once (which would race with a +// concurrent Rule.Check), Do is safe to call at any time: after the +// first call it is a no-op and never writes to the Once. +func SilenceConfigWarningForTesting() { + configWarned.Do(func() {}) +} + // Rule checks that markdown files exist only in explicitly allowed directories. type Rule struct { Allowed []string diff --git a/internal/rules/directorystructure/rule_test.go b/internal/rules/directorystructure/rule_test.go index d8a6a72f9..3c2386499 100644 --- a/internal/rules/directorystructure/rule_test.go +++ b/internal/rules/directorystructure/rule_test.go @@ -157,6 +157,29 @@ func TestName(t *testing.T) { assert.Equal(t, "directory-structure", r.Name(), "expected directory-structure") } +func TestCategory(t *testing.T) { + r := &Rule{} + assert.Equal(t, "meta", r.Category(), "directory-structure belongs to the meta category") +} + +// TestSilenceConfigWarningForTesting covers the helper used by the +// integration runner to pre-consume the warn-once guard. Calling it +// twice must be safe and the subsequent Check must not emit the +// config warning even when the rule is in a configured-but-empty +// state. +func TestSilenceConfigWarningForTesting(t *testing.T) { + resetConfigWarned() + SilenceConfigWarningForTesting() + // Second call is a no-op. + SilenceConfigWarningForTesting() + + r := newRule(t, []string{}) + f, err := lint.NewFile("docs/a.md", []byte("# x\n")) + require.NoError(t, err) + assert.Empty(t, r.Check(f), + "after silencing the guard, the configured-empty warning must not fire") +} + func TestApplySettings(t *testing.T) { r := &Rule{} err := r.ApplySettings(map[string]any{ diff --git a/internal/rules/index.md b/internal/rules/index.md index 91279ae11..3a2f673e6 100644 --- a/internal/rules/index.md +++ b/internal/rules/index.md @@ -52,6 +52,7 @@ row: "| [{id}]({filename}) | `{name}` | {status} | {description} |" | [MDS031](MDS031-unclosed-code-block/README.md) | `unclosed-code-block` | ready | Fenced code blocks must have a closing fence delimiter. | | [MDS032](MDS032-no-empty-alt-text/README.md) | `no-empty-alt-text` | ready | Images must have non-empty alt text for accessibility. | | [MDS033](MDS033-directory-structure/README.md) | `directory-structure` | ready | Markdown files must exist only in explicitly allowed directories. | +| [MDS034](MDS034-markdown-flavor/README.md) | `markdown-flavor` | ready | Flags Markdown syntax that the declared target flavor does not render. | | [MDS035](MDS035-toc-directive/README.md) | `toc-directive` | ready | Flag renderer-specific TOC directives that render as literal text on CommonMark and goldmark. | | [MDS036](MDS036-max-section-length/README.md) | `max-section-length` | ready | Section length must not exceed per-level or per-heading limits. | diff --git a/internal/rules/markdownflavor/detect.go b/internal/rules/markdownflavor/detect.go new file mode 100644 index 000000000..23acfcaf8 --- /dev/null +++ b/internal/rules/markdownflavor/detect.go @@ -0,0 +1,490 @@ +package markdownflavor + +import ( + "regexp" + "sort" + + "github.com/yuin/goldmark/ast" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" + + "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rules/markdownflavor/ext" +) + +// Finding records one detected feature use. +// +// Line and Column are 1-based positions within the parsed document +// body in f.Source. The engine's lint.File.AdjustDiagnostics applies +// any front-matter LineOffset later, so detectors and Rule.Check +// must report body-relative positions only. +// +// Start and End are best-effort byte anchors in f.Source. They cover +// the feature span precisely only for features whose Fix needs an +// exact range (currently heading IDs via Extra, and bare URLs). +// Other findings use convenience anchors: block features widen Start +// to the start of the containing line, and inline extension nodes +// without a source segment emit a zero-length anchor (End == Start). +// Any future Fix implementation that needs a precise span must +// recompute it from f.Source rather than trusting End - Start. +type Finding struct { + Feature Feature + Line int + Column int + Start int + End int + // Extra carries feature-specific metadata used by Fix (e.g. the + // {#id} span inside a heading). Nil when not needed. + Extra any +} + +// HeadingIDExtra describes the byte span of a heading-attribute block +// (e.g. "{#custom-id}") inside the original source. +type HeadingIDExtra struct { + AttrStart int // byte offset of '{' + AttrEnd int // byte offset one past '}' +} + +// bareURLPattern mirrors goldmark's linkify http/https/ftp URL regex +// closely enough to catch bare URLs in text. Anchors removed so it can +// match anywhere inside a Text segment. The TLD class accepts both +// upper- and lowercase ASCII so URLs like https://example.COM are +// flagged the same way as their lowercase form. +var bareURLPattern = regexp.MustCompile( + `(?:http|https|ftp)://[-a-zA-Z0-9@:%._+~#=]{1,256}` + + `\.[a-zA-Z]+(?::\d+)?(?:[/#?][-a-zA-Z0-9@:%_+.~#$!?&/=();,'">^{}\[\]` + + "`" + `]*)?`, +) + +// Detect runs every feature detector against f and returns findings +// in document order. Use DetectFiltered to skip detectors for +// features the caller is not interested in. +func Detect(f *lint.File) []Finding { + return DetectFiltered(f, nil) +} + +// DetectFiltered is Detect with an optional accept predicate. When +// accept is non-nil, only features for which accept(feat) returns +// true are detected; whole-file scans are skipped when none of their +// features are accepted. Passing nil accepts every feature. +// +// The dual-parser and bare-URL passes each emit in document order +// on their own, but the two streams must be merged: a bare URL on +// line 3 should sort before a footnote definition on line 5 even +// though detectFromDual runs first. +func DetectFiltered(f *lint.File, accept func(Feature) bool) []Finding { + keep := func(feat Feature) bool { + return accept == nil || accept(feat) + } + + var out []Finding + + if anyDualFeatureAccepted(keep) { + dualDoc := Parser().Parser().Parse(text.NewReader(f.Source)) + for _, fin := range detectFromDual(f, dualDoc) { + if keep(fin.Feature) { + out = append(out, fin) + } + } + } + + if keep(FeatureBareURLAutolinks) { + out = append(out, detectBareURLs(f)...) + } + + sort.SliceStable(out, func(i, j int) bool { + return out[i].Start < out[j].Start + }) + return out +} + +// anyDualFeatureAccepted reports whether any feature detected by the +// dual-parser pass is wanted. Lets DetectFiltered skip the goldmark +// re-parse when every feature it would detect is already supported +// by the target flavor. +func anyDualFeatureAccepted(keep func(Feature) bool) bool { + for _, feat := range []Feature{ + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureFootnotes, FeatureDefinitionLists, FeatureHeadingIDs, + FeatureSuperscript, FeatureSubscript, + FeatureMathBlock, FeatureMathInline, FeatureAbbreviations, + } { + if keep(feat) { + return true + } + } + return false +} + +// detectFromDual walks the dual-parser tree for every feature that +// has an AST representation: the six built-in extensions (tables, +// strikethrough, task lists, footnotes, definition lists, heading +// IDs) plus the five MDS034 custom extensions (superscript, +// subscript, math block, math inline, abbreviations). +func detectFromDual(f *lint.File, doc ast.Node) []Finding { + var findings []Finding + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + fin, status := featureFindingFor(f, n) + if fin != nil { + findings = append(findings, *fin) + } + return status, nil + }) + return dedupe(findings) +} + +// featureFindingFor maps an AST node to at most one Finding plus +// the walk-status to return for the rest of the walk. A nil pointer +// means "no finding for this node". +func featureFindingFor(f *lint.File, n ast.Node) (*Finding, ast.WalkStatus) { + if fin, status, ok := builtinFindingFor(f, n); ok { + return fin, status + } + if fin, status, ok := customFindingFor(f, n); ok { + return fin, status + } + return nil, ast.WalkContinue +} + +// builtinFindingFor handles the six features detected via goldmark's +// built-in extensions plus the heading-ID attribute parser. +func builtinFindingFor(f *lint.File, n ast.Node) (*Finding, ast.WalkStatus, bool) { + switch node := n.(type) { + case *extast.Table: + fin := blockFinding(f, n, FeatureTables) + return &fin, ast.WalkSkipChildren, true + case *extast.TaskCheckBox: + fin := taskCheckBoxFinding(f, n) + return &fin, ast.WalkContinue, true + case *extast.Strikethrough: + fin := strikethroughFinding(f, n) + return &fin, ast.WalkContinue, true + case *extast.FootnoteLink: + fin := inlineExtFinding(f, n, FeatureFootnotes) + return &fin, ast.WalkContinue, true + case *extast.Footnote: + fin := blockFinding(f, n, FeatureFootnotes) + return &fin, ast.WalkSkipChildren, true + case *extast.FootnoteList: + // Walk children so Footnote definitions report their own + // locations; skip emitting a wrapper finding. + return nil, ast.WalkContinue, true + case *extast.DefinitionList: + fin := blockFinding(f, n, FeatureDefinitionLists) + return &fin, ast.WalkSkipChildren, true + case *ast.Heading: + if hf, ok := findHeadingID(f, node); ok { + return &hf, ast.WalkContinue, true + } + return nil, ast.WalkContinue, true + } + return nil, ast.WalkContinue, false +} + +// customFindingFor handles the five features covered by MDS034 +// custom extensions: superscript, subscript, math block / inline, +// and abbreviations (both definition and reference). +func customFindingFor(f *lint.File, n ast.Node) (*Finding, ast.WalkStatus, bool) { + switch n.(type) { + case *ext.SuperscriptNode: + fin := markerInlineFinding(f, n, FeatureSuperscript, '^') + return &fin, ast.WalkContinue, true + case *ext.SubscriptNode: + fin := markerInlineFinding(f, n, FeatureSubscript, '~') + return &fin, ast.WalkContinue, true + case *ext.MathBlockNode: + fin := blockFinding(f, n, FeatureMathBlock) + return &fin, ast.WalkSkipChildren, true + case *ext.MathInlineNode: + fin := markerInlineFinding(f, n, FeatureMathInline, '$') + return &fin, ast.WalkContinue, true + case *ext.AbbreviationDefinition: + fin := blockFinding(f, n, FeatureAbbreviations) + return &fin, ast.WalkSkipChildren, true + case *ext.AbbreviationReference: + // The reference carries a child Text with the term's exact + // source segment, so inlineFinding pulls the real column + // rather than the enclosing paragraph start. + fin := inlineFinding(f, n, FeatureAbbreviations) + return &fin, ast.WalkContinue, true + } + return nil, ast.WalkContinue, false +} + +// strikethroughFinding backs up past the opening "~~" so the +// diagnostic points at the marker, not at the content character. +func strikethroughFinding(f *lint.File, n ast.Node) Finding { + fin := inlineFinding(f, n, FeatureStrikethrough) + if fin.Start >= 2 && f.Source[fin.Start-1] == '~' && f.Source[fin.Start-2] == '~' { + fin.Start -= 2 + fin.Column -= 2 + } + return fin +} + +// markerInlineFinding backs up a single opening marker byte before +// the first text descendant. Used for superscript / subscript / +// inline-math spans where the first child text starts after the +// single-byte marker. +func markerInlineFinding(f *lint.File, n ast.Node, feat Feature, marker byte) Finding { + fin := inlineFinding(f, n, feat) + if fin.Start >= 1 && f.Source[fin.Start-1] == marker { + fin.Start-- + fin.Column-- + } + return fin +} + +// blockFinding reports a block-level feature starting at column 1 of +// the line containing the node's first text descendant. +func blockFinding(f *lint.File, n ast.Node, feat Feature) Finding { + start, end := nodeByteRange(n) + lineStart := lineStartOf(f.Source, start) + line, _ := lineCol(f.Source, lineStart) + return Finding{Feature: feat, Line: line, Column: 1, Start: lineStart, End: end} +} + +// taskCheckBoxFinding synthesises a Finding for a TaskCheckBox by +// walking up to the nearest block ancestor with line info (TextBlock +// inside the containing ListItem). TaskCheckBox has no source segment +// of its own. +func taskCheckBoxFinding(f *lint.File, n ast.Node) Finding { + if p := nearestBlockAncestor(n); p != nil { + return findingFromBlock(f, p, FeatureTaskLists) + } + return Finding{Feature: FeatureTaskLists, Line: 1, Column: 1} +} + +// inlineExtFinding covers inline extension nodes that expose no +// segment (e.g. FootnoteLink). It uses the first ancestor block's +// first-line position instead of firstTextStart, which would return +// zero for a childless inline. +func inlineExtFinding(f *lint.File, n ast.Node, feat Feature) Finding { + if p := nearestBlockAncestor(n); p != nil { + return findingFromBlock(f, p, feat) + } + return Finding{Feature: feat, Line: 1, Column: 1} +} + +// nearestBlockAncestor walks up from n and returns the first block- +// typed ancestor with non-empty Lines(). +func nearestBlockAncestor(n ast.Node) ast.Node { + for p := n.Parent(); p != nil; p = p.Parent() { + if p.Type() != ast.TypeBlock { + continue + } + if lines := p.Lines(); lines != nil && lines.Len() > 0 { + return p + } + } + return nil +} + +// findingFromBlock builds an inline-style finding (exact line/col of +// the block's first line) for features emitted from a block ancestor. +func findingFromBlock(f *lint.File, block ast.Node, feat Feature) Finding { + lines := block.Lines() + if lines == nil || lines.Len() == 0 { + return Finding{Feature: feat, Line: 1, Column: 1} + } + start := lines.At(0).Start + line, col := lineCol(f.Source, start) + return Finding{Feature: feat, Line: line, Column: col, Start: start, End: start} +} + +// inlineFinding reports an inline feature at its exact source column. +func inlineFinding(f *lint.File, n ast.Node, feat Feature) Finding { + start, end := nodeByteRange(n) + line, col := lineCol(f.Source, start) + return Finding{Feature: feat, Line: line, Column: col, Start: start, End: end} +} + +func nodeByteRange(n ast.Node) (int, int) { + if n.Type() == ast.TypeBlock { + if lines := n.Lines(); lines != nil && lines.Len() > 0 { + first := lines.At(0) + last := lines.At(lines.Len() - 1) + return first.Start, last.Stop + } + } + start := firstTextStart(n) + if start < 0 { + start = 0 + } + return start, start +} + +func lineStartOf(source []byte, offset int) int { + if offset > len(source) { + offset = len(source) + } + for i := offset - 1; i >= 0; i-- { + if source[i] == '\n' { + return i + 1 + } + } + return 0 +} + +// firstTextStart returns the byte offset of the first descendant Text +// node, or -1 when none exists. The sentinel matters: returning 0 on +// "not found" would point at the start of the file and shift inline +// findings to line 1, column 1. +func firstTextStart(n ast.Node) int { + if t, ok := n.(*ast.Text); ok { + return t.Segment.Start + } + for c := n.FirstChild(); c != nil; c = c.NextSibling() { + if s := firstTextStart(c); s >= 0 { + return s + } + } + return -1 +} + +// makeFinding converts a byte range to a Finding with line and column +// derived from f.Source. +func makeFinding(f *lint.File, feat Feature, start, end int) Finding { + line, col := lineCol(f.Source, start) + return Finding{Feature: feat, Line: line, Column: col, Start: start, End: end} +} + +// isASCIISpace reports whether b is one of the ASCII whitespace +// bytes that can legitimately appear after a heading's attribute +// block before the line's newline. +func isASCIISpace(b byte) bool { + switch b { + case ' ', '\t', '\r', '\v', '\f': + return true + } + return false +} + +func lineCol(source []byte, offset int) (int, int) { + if offset < 0 { + offset = 0 + } + if offset > len(source) { + offset = len(source) + } + line := 1 + lineStart := 0 + for i := 0; i < offset; i++ { + if source[i] == '\n' { + line++ + lineStart = i + 1 + } + } + return line, offset - lineStart + 1 +} + +// dedupe collapses consecutive findings of the same feature at the +// same offset (goldmark's extension nodes sometimes nest, e.g. each +// footnote child also carries FootnoteLink). +func dedupe(in []Finding) []Finding { + if len(in) < 2 { + return in + } + out := in[:1] + for _, f := range in[1:] { + last := out[len(out)-1] + if f.Feature == last.Feature && f.Start == last.Start { + continue + } + out = append(out, f) + } + return out +} + +// findHeadingID locates the trailing "{#id}" attribute block that the +// goldmark attribute parser consumed. The Heading node's Lines segment +// only covers the inner text, so we scan the raw line in f.Source from +// the segment start forward to the next newline. +func findHeadingID(f *lint.File, h *ast.Heading) (Finding, bool) { + if h.Attributes() == nil { + return Finding{}, false + } + if _, ok := h.AttributeString("id"); !ok { + return Finding{}, false + } + lines := h.Lines() + if lines == nil || lines.Len() == 0 { + return Finding{}, false + } + segStart := lines.At(0).Start + lineEnd := segStart + for lineEnd < len(f.Source) && f.Source[lineEnd] != '\n' { + lineEnd++ + } + // Find the last '{' on the line that introduces the attribute block. + brace := -1 + for i := lineEnd - 1; i >= segStart; i-- { + if f.Source[i] == '{' { + brace = i + break + } + } + if brace < 0 { + return Finding{}, false + } + attrStart := brace + attrEnd := lineEnd + // Trim trailing ASCII whitespace so fixes keep tidy line endings + // even when the heading line ends with a tab or CRLF. + for attrEnd > attrStart && isASCIISpace(f.Source[attrEnd-1]) { + attrEnd-- + } + line, col := lineCol(f.Source, attrStart) + return Finding{ + Feature: FeatureHeadingIDs, + Line: line, + Column: col, + Start: attrStart, + End: attrEnd, + Extra: HeadingIDExtra{AttrStart: attrStart, AttrEnd: attrEnd}, + }, true +} + +// detectBareURLs scans f.AST (the main CommonMark parse, which has no +// extensions) for bare URL text. Bracketed autolinks are +// recognised by CommonMark and appear as ast.AutoLink, so only true +// bare URLs remain inside Text nodes. +func detectBareURLs(f *lint.File) []Finding { + var findings []Finding + _ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + t, ok := n.(*ast.Text) + if !ok { + return ast.WalkContinue, nil + } + if insideNonBareContext(n) { + return ast.WalkContinue, nil + } + seg := t.Segment + body := seg.Value(f.Source) + matches := bareURLPattern.FindAllIndex(body, -1) + for _, m := range matches { + start := seg.Start + m[0] + end := seg.Start + m[1] + findings = append(findings, makeFinding(f, FeatureBareURLAutolinks, start, end)) + } + return ast.WalkContinue, nil + }) + return findings +} + +func insideNonBareContext(n ast.Node) bool { + for p := n.Parent(); p != nil; p = p.Parent() { + switch p.(type) { + case *ast.Link, *ast.AutoLink, *ast.CodeSpan, *ast.FencedCodeBlock, + *ast.CodeBlock: + return true + } + } + return false +} diff --git a/internal/rules/markdownflavor/detect_edge_test.go b/internal/rules/markdownflavor/detect_edge_test.go new file mode 100644 index 000000000..635130ceb --- /dev/null +++ b/internal/rules/markdownflavor/detect_edge_test.go @@ -0,0 +1,179 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark/ast" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" + + "github.com/jeduden/mdsmith/internal/lint" +) + +// TestLineColClampsNegativeOffset exercises the guard that clamps a +// negative offset to 0 so callers that subtract past the start of +// f.Source still get a valid (1, 1) position. +func TestLineColClampsNegativeOffset(t *testing.T) { + line, col := lineCol([]byte("hello\nworld\n"), -5) + assert.Equal(t, 1, line) + assert.Equal(t, 1, col) +} + +// TestLineColClampsOversizedOffset exercises the guard that clamps +// an offset past len(source) back to len(source) so callers that +// look one byte past EOF still get a valid position. +func TestLineColClampsOversizedOffset(t *testing.T) { + src := []byte("hello\nworld\n") + line, col := lineCol(src, len(src)+10) + assert.Equal(t, 3, line) + assert.Equal(t, 1, col) +} + +// TestLineStartOfClampsOversizedOffset mirrors the same clamp for +// lineStartOf. An offset past EOF clamps to len(source); for a file +// ending in a newline that puts us one byte past the last newline, +// which is the start of the (empty) line after the document. +func TestLineStartOfClampsOversizedOffset(t *testing.T) { + src := []byte("hello\nworld\n") + assert.Equal(t, len(src), lineStartOf(src, len(src)+10)) +} + +// TestLineStartOfMidLine returns the first byte of the line +// containing the given offset. +func TestLineStartOfMidLine(t *testing.T) { + src := []byte("hello\nworld\n") + // Offset 8 sits inside "world" — line start is 6. + assert.Equal(t, 6, lineStartOf(src, 8)) +} + +// TestFirstTextStartReturnsNegativeForEmptySubtree covers the +// sentinel return path when no Text node can be found under n. +func TestFirstTextStartReturnsNegativeForEmptySubtree(t *testing.T) { + // An empty file has no children. + f := mkFile(t, "\n") + root := f.AST + // A *real* ast.Document has no Text descendants, so + // firstTextStart returns -1 for it. + assert.Equal(t, -1, firstTextStart(root)) +} + +// TestFindHeadingIDIgnoresHeadingWithoutAttribute confirms that a +// heading parsed without an `id` attribute short-circuits +// findHeadingID and produces no finding. +func TestFindHeadingIDIgnoresHeadingWithoutAttribute(t *testing.T) { + // "# Heading" alone: no attribute block, no finding. + fs := findings(t, "# Heading\n") + assert.False(t, hasFeature(fs, FeatureHeadingIDs)) +} + +// TestFindHeadingIDIgnoresAttributesWithoutID covers the second +// guard: the heading has an attribute block but no `id` key. +func TestFindHeadingIDIgnoresAttributesWithoutID(t *testing.T) { + // Goldmark's attribute parser accepts class-only attribute + // blocks like `{.highlight}`. Those set Attributes() != nil but + // no "id" key, so findHeadingID should return ok=false. + fs := findings(t, "# Heading {.highlight}\n") + assert.False(t, hasFeature(fs, FeatureHeadingIDs)) +} + +// TestTaskCheckBoxFindingOrphan exercises the defensive fallback in +// taskCheckBoxFinding when the node has no block ancestor — which +// only happens if the AST was hand-constructed rather than produced +// by goldmark. The fallback returns (1, 1). +func TestTaskCheckBoxFindingOrphan(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("body\n")) + require.NoError(t, err) + orphan := extast.NewTaskCheckBox(true) + got := taskCheckBoxFinding(f, orphan) + assert.Equal(t, FeatureTaskLists, got.Feature) + assert.Equal(t, 1, got.Line) + assert.Equal(t, 1, got.Column) +} + +// TestInlineExtFindingOrphan is the same test for inlineExtFinding. +func TestInlineExtFindingOrphan(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("body\n")) + require.NoError(t, err) + orphan := extast.NewFootnoteLink(7) + got := inlineExtFinding(f, orphan, FeatureFootnotes) + assert.Equal(t, FeatureFootnotes, got.Feature) + assert.Equal(t, 1, got.Line) + assert.Equal(t, 1, got.Column) +} + +// TestFindingFromBlockNoLines covers the `lines == nil || .Len()==0` +// short-circuit: a freshly-constructed block with no Lines appended +// falls back to (1, 1). +func TestFindingFromBlockNoLines(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("body\n")) + require.NoError(t, err) + block := ast.NewParagraph() // no Lines appended + got := findingFromBlock(f, block, FeatureTables) + assert.Equal(t, FeatureTables, got.Feature) + assert.Equal(t, 1, got.Line) + assert.Equal(t, 1, got.Column) +} + +// TestNodeByteRangeClampsNegativeStart covers the clamp in +// nodeByteRange that floors a negative firstTextStart result to 0. +// A FootnoteLink has no children and no source segment, so +// firstTextStart returns -1 and nodeByteRange must floor that. +func TestNodeByteRangeClampsNegativeStart(t *testing.T) { + n := extast.NewFootnoteLink(7) + start, end := nodeByteRange(n) + assert.Equal(t, 0, start) + assert.Equal(t, 0, end) +} + +// TestNearestBlockAncestorSkipsNonBlockAncestors exercises the +// "parent is not a block" branch in nearestBlockAncestor: when we +// walk through an inline ancestor on the way up, the helper skips +// it and keeps climbing. +func TestNearestBlockAncestorSkipsNonBlockAncestors(t *testing.T) { + // Build: Paragraph (block, has Lines) → Emphasis (inline) → + // FootnoteLink (inline). Walking up from the FootnoteLink must + // skip Emphasis and return the Paragraph. + p := ast.NewParagraph() + // Append a line so findingFromBlock can resolve a position + // later (not needed here, but keeps the block well-formed). + p.Lines().Append(text.NewSegment(0, 1)) + em := ast.NewEmphasis(1) + link := extast.NewFootnoteLink(1) + p.AppendChild(p, em) + em.AppendChild(em, link) + + got := nearestBlockAncestor(link) + assert.Same(t, ast.Node(p), got) +} + +// TestFindHeadingIDHandlesMissingLines exercises the +// "lines == nil || lines.Len() == 0" rejection branch in +// findHeadingID. Normal parsing always fills in Lines on a +// Heading, so we synthesise a Heading with the id attribute set +// but no Lines appended. +func TestFindHeadingIDHandlesMissingLines(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("# Heading {#top}\n")) + require.NoError(t, err) + h := ast.NewHeading(1) + h.SetAttributeString("id", []byte("top")) + _, ok := findHeadingID(f, h) + assert.False(t, ok, + "findHeadingID must return ok=false when Lines is empty") +} + +// TestFindHeadingIDHandlesNoOpeningBrace covers the "brace < 0" +// branch: a Heading whose id attribute was somehow set but whose +// source line contains no `{`. The parser ordinarily does not +// produce such a node; we construct one directly. +func TestFindHeadingIDHandlesNoOpeningBrace(t *testing.T) { + f, err := lint.NewFile("t.md", []byte("# plain heading\n")) + require.NoError(t, err) + h := ast.NewHeading(1) + h.SetAttributeString("id", []byte("top")) + h.Lines().Append(text.NewSegment(2, 15)) + _, ok := findHeadingID(f, h) + assert.False(t, ok, + "findHeadingID must return ok=false when source line contains no '{'") +} diff --git a/internal/rules/markdownflavor/detect_test.go b/internal/rules/markdownflavor/detect_test.go new file mode 100644 index 000000000..3ad4ae862 --- /dev/null +++ b/internal/rules/markdownflavor/detect_test.go @@ -0,0 +1,233 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jeduden/mdsmith/internal/lint" +) + +func mkFile(t *testing.T, src string) *lint.File { + t.Helper() + f, err := lint.NewFile("test.md", []byte(src)) + require.NoError(t, err) + return f +} + +func findings(t *testing.T, src string) []Finding { + t.Helper() + return Detect(mkFile(t, src)) +} + +func hasFeature(fs []Finding, feat Feature) bool { + for _, f := range fs { + if f.Feature == feat { + return true + } + } + return false +} + +func TestDetectTable(t *testing.T) { + fs := findings(t, "| a | b |\n| - | - |\n| 1 | 2 |\n") + require.True(t, hasFeature(fs, FeatureTables)) + for _, f := range fs { + if f.Feature == FeatureTables { + assert.Equal(t, 1, f.Line) + assert.Equal(t, 1, f.Column) + return + } + } +} + +func TestDetectStrikethrough(t *testing.T) { + fs := findings(t, "hello ~~world~~\n") + require.True(t, hasFeature(fs, FeatureStrikethrough)) +} + +func TestDetectTaskList(t *testing.T) { + fs := findings(t, "- [ ] todo\n- [x] done\n") + require.True(t, hasFeature(fs, FeatureTaskLists)) +} + +func TestDetectFootnote(t *testing.T) { + fs := findings(t, "A paragraph.[^1]\n\n[^1]: footnote body\n") + require.True(t, hasFeature(fs, FeatureFootnotes)) +} + +func TestDetectDefinitionList(t *testing.T) { + fs := findings(t, "term\n: definition\n") + require.True(t, hasFeature(fs, FeatureDefinitionLists)) +} + +func TestDetectBareURLAutolink(t *testing.T) { + fs := findings(t, "See https://example.com for details.\n") + require.True(t, hasFeature(fs, FeatureBareURLAutolinks)) +} + +// TestDetectBareURLAutolinkUppercaseTLD guards the regex character +// class for the TLD: matches must be case-insensitive so SHOUTY +// domains and mixed-case TLDs are still flagged. +func TestDetectBareURLAutolinkUppercaseTLD(t *testing.T) { + for _, src := range []string{ + "See https://example.COM for details.\n", + "See https://EXAMPLE.CoM for details.\n", + } { + fs := findings(t, src) + assert.True(t, hasFeature(fs, FeatureBareURLAutolinks), + "uppercase TLD should be flagged: %q", src) + } +} + +func TestDetectIgnoresBracketedAutolink(t *testing.T) { + fs := findings(t, "See for details.\n") + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + " bracketed autolinks are CommonMark; must not be flagged as bare-URL autolinks") +} + +func TestDetectIgnoresURLInsideLink(t *testing.T) { + fs := findings(t, "See [here](https://example.com).\n") + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + "URLs inside Markdown link destinations are not bare") +} + +func TestDetectIgnoresURLInCodeSpan(t *testing.T) { + fs := findings(t, "See `https://example.com` for details.\n") + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + "URLs inside inline code must not be flagged") +} + +func TestDetectIgnoresURLInFencedCode(t *testing.T) { + src := "```\nhttps://example.com\n```\n" + fs := findings(t, src) + assert.False(t, hasFeature(fs, FeatureBareURLAutolinks), + "URLs inside fenced code blocks must not be flagged") +} + +func TestDetectHeadingID(t *testing.T) { + fs := findings(t, "# Heading {#custom}\n") + require.True(t, hasFeature(fs, FeatureHeadingIDs)) +} + +// TestDetectHeadingIDTrimsAllASCIIWhitespace guards the trailing- +// whitespace trim in findHeadingID: a heading that ends with a tab +// or CRLF \r before the newline must produce an End offset that +// stops at '}' rather than swallowing the whitespace. +func TestDetectHeadingIDTrimsAllASCIIWhitespace(t *testing.T) { + for _, trailer := range []string{" \n", "\t\n", " \r\n", "\t \r\n"} { + src := "# Heading {#custom}" + trailer + fs := findings(t, src) + require.True(t, hasFeature(fs, FeatureHeadingIDs), "trailer=%q", trailer) + for _, f := range fs { + if f.Feature != FeatureHeadingIDs { + continue + } + assert.Equal(t, byte('}'), src[f.End-1], + "trailer=%q: End should stop at '}'", trailer) + } + } +} + +func TestDetectMultipleFeatures(t *testing.T) { + src := "# Title {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + fs := findings(t, src) + assert.True(t, hasFeature(fs, FeatureHeadingIDs)) + assert.True(t, hasFeature(fs, FeatureTaskLists)) + assert.True(t, hasFeature(fs, FeatureTables)) + assert.True(t, hasFeature(fs, FeatureStrikethrough)) + assert.True(t, hasFeature(fs, FeatureBareURLAutolinks)) +} + +func TestDetectEmptyDocument(t *testing.T) { + fs := findings(t, "\n") + assert.Empty(t, fs) +} + +func TestDetectSuperscript(t *testing.T) { + fs := findings(t, "E = mc^2^\n") + require.True(t, hasFeature(fs, FeatureSuperscript)) +} + +func TestDetectSubscript(t *testing.T) { + fs := findings(t, "H~2~O\n") + require.True(t, hasFeature(fs, FeatureSubscript)) +} + +func TestDetectMathBlock(t *testing.T) { + fs := findings(t, "$$\na^2 + b^2 = c^2\n$$\n") + require.True(t, hasFeature(fs, FeatureMathBlock)) +} + +func TestDetectMathInline(t *testing.T) { + fs := findings(t, "foo $x+1$ bar\n") + require.True(t, hasFeature(fs, FeatureMathInline)) +} + +func TestDetectAbbreviations(t *testing.T) { + fs := findings(t, "*[API]: Application Programming Interface\n\nUse API here.\n") + require.True(t, hasFeature(fs, FeatureAbbreviations)) +} + +func TestDetectPlainCommonMark(t *testing.T) { + src := "# Heading\n\nA paragraph.\n\n- bullet\n- another\n\n" + + "```go\nfmt.Println(\"hi\")\n```\n" + fs := findings(t, src) + assert.Empty(t, fs) +} + +// TestDetectFilteredSkipsBareURLs exercises the skip path for the +// bare-URL regex scan: when the caller rejects +// FeatureBareURLAutolinks, detectBareURLs must not run even though +// other features (here strikethrough) are still accepted. The scenario +// is narrower than any specific flavor; Rule.Check under flavor: gfm or +// goldmark passes a different predicate (!flavor.Supports) that would +// also reject the strikethrough branch. +func TestDetectFilteredSkipsBareURLs(t *testing.T) { + src := "See https://example.com for details.\n\n~~old~~\n" + // Reject bare-URL autolinks; keep every other feature so the + // strikethrough assertion can verify the non-bare-URL path + // still runs. + accept := func(feat Feature) bool { + return feat != FeatureBareURLAutolinks + } + fs := DetectFiltered(mkFile(t, src), accept) + for _, f := range fs { + assert.NotEqual(t, FeatureBareURLAutolinks, f.Feature, + "bare-URL findings must be suppressed when caller skips them") + } + assert.True(t, hasFeature(fs, FeatureStrikethrough), + "accepted features are still returned") +} + +// TestDetectFilteredSkipsDualParseWhenAllSupported verifies that +// DetectFiltered avoids the goldmark re-parse entirely when every +// feature the dual pass could emit is accepted by the caller. +func TestDetectFilteredSkipsDualParseWhenAllSupported(t *testing.T) { + src := "# Title {#id}\n\n| a |\n| - |\n| 1 |\n\n~~x~~ and [^1]\n\n[^1]: note\n" + // Accept every dual-parser feature; ask only for bare URLs. + accept := func(feat Feature) bool { + return feat == FeatureBareURLAutolinks + } + fs := DetectFiltered(mkFile(t, src), accept) + for _, f := range fs { + assert.Equal(t, FeatureBareURLAutolinks, f.Feature, + "dual-parser features must be suppressed when all are accepted") + } +} + +// TestDetectFindingsAreSortedByStart guards the merge ordering +// between detectFromDual and detectBareURLs: a bare URL in line 1 +// must sort before a footnote definition further down the file. +func TestDetectFindingsAreSortedByStart(t *testing.T) { + src := "https://example.com paragraph.[^1]\n\n[^1]: note body\n" + fs := findings(t, src) + require.GreaterOrEqual(t, len(fs), 2) + for i := 1; i < len(fs); i++ { + assert.LessOrEqual(t, fs[i-1].Start, fs[i].Start, + "finding %d (%v) precedes finding %d (%v) but has greater Start", + i-1, fs[i-1], i, fs[i]) + } +} diff --git a/internal/rules/markdownflavor/ext/abbreviation.go b/internal/rules/markdownflavor/ext/abbreviation.go new file mode 100644 index 000000000..e4e7690ee --- /dev/null +++ b/internal/rules/markdownflavor/ext/abbreviation.go @@ -0,0 +1,372 @@ +package ext + +import ( + "bytes" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// AbbreviationDefinition is the AST node produced by the +// abbreviation block parser for a `*[term]: expansion` line. The +// node is raw so its content is not re-parsed as inline markup. +type AbbreviationDefinition struct { + ast.BaseBlock + Term []byte + Expansion []byte +} + +// KindAbbreviationDefinition is the NodeKind of AbbreviationDefinition. +var KindAbbreviationDefinition = ast.NewNodeKind("AbbreviationDefinition") + +// Kind implements ast.Node. +func (n *AbbreviationDefinition) Kind() ast.NodeKind { return KindAbbreviationDefinition } + +// IsRaw implements ast.Node. +func (n *AbbreviationDefinition) IsRaw() bool { return true } + +// Dump implements ast.Node. +func (n *AbbreviationDefinition) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{ + "Term": string(n.Term), + "Expansion": string(n.Expansion), + }, nil) +} + +// AbbreviationReference marks an inline occurrence of a defined +// abbreviation term. The referenced term text lives in the child +// Text node. +type AbbreviationReference struct { + ast.BaseInline + Term []byte +} + +// KindAbbreviationReference is the NodeKind of AbbreviationReference. +var KindAbbreviationReference = ast.NewNodeKind("AbbreviationReference") + +// Kind implements ast.Node. +func (n *AbbreviationReference) Kind() ast.NodeKind { return KindAbbreviationReference } + +// Dump implements ast.Node. +func (n *AbbreviationReference) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, map[string]string{ + "Term": string(n.Term), + }, nil) +} + +// abbrTableKey is the parser-context key under which the +// abbreviation-definition table is stored during a single parse so +// the transformer can look it up after all blocks have been parsed. +var abbrTableKey = parser.NewContextKey() + +// abbrTable is the in-parse table of defined abbreviations. Keys are +// canonical term byte sequences; values are expansion bytes. +type abbrTable map[string][]byte + +// getAbbrTable returns the abbreviation table from the context, +// creating it on first access. +func getAbbrTable(pc parser.Context) abbrTable { + if v := pc.Get(abbrTableKey); v != nil { + return v.(abbrTable) + } + t := abbrTable{} + pc.Set(abbrTableKey, t) + return t +} + +// --- block parser ----------------------------------------------------- + +// abbrDefPrefix is the literal `*[` that starts every definition. +var abbrDefPrefix = []byte("*[") + +// abbreviationBlockParser parses `*[term]: expansion` lines as +// block-level AbbreviationDefinition nodes and records the term in +// the parse context so the transformer can mark references. +type abbreviationBlockParser struct{} + +// Trigger implements parser.BlockParser. +func (p *abbreviationBlockParser) Trigger() []byte { return []byte{'*'} } + +// Open implements parser.BlockParser. +// +// A definition line has the shape `*[TERM]: EXPANSION` with no +// leading indent beyond three spaces. TERM must be non-empty and +// may not contain a literal `]`. EXPANSION may be empty. +func (p *abbreviationBlockParser) Open( + parent ast.Node, reader text.Reader, pc parser.Context, +) (ast.Node, parser.State) { + line, _ := reader.PeekLine() + if line == nil { + return nil, parser.NoChildren + } + trimmed := bytes.TrimLeft(line, " ") + indent := len(line) - len(trimmed) + if indent >= 4 { + return nil, parser.NoChildren + } + if !bytes.HasPrefix(trimmed, abbrDefPrefix) { + return nil, parser.NoChildren + } + rest := trimmed[len(abbrDefPrefix):] + rbracket := bytes.IndexByte(rest, ']') + if rbracket <= 0 { + return nil, parser.NoChildren + } + term := rest[:rbracket] + afterBracket := rest[rbracket+1:] + if len(afterBracket) == 0 || afterBracket[0] != ':' { + return nil, parser.NoChildren + } + expansion := bytes.TrimSpace(afterBracket[1:]) + // Copy byte slices — the reader's line buffer is reused between + // calls, so holding references to it would be unsafe. + termCopy := append([]byte(nil), term...) + expCopy := append([]byte(nil), expansion...) + + node := &AbbreviationDefinition{Term: termCopy, Expansion: expCopy} + // Record the raw source span so detectors (and a future Fix) can + // locate this definition without re-scanning. + _, seg := reader.PeekLine() + node.Lines().Append(seg) + tbl := getAbbrTable(pc) + tbl[string(termCopy)] = expCopy + + reader.AdvanceToEOL() + return node, parser.NoChildren +} + +// Continue implements parser.BlockParser. +// +// Definitions are always single-line; the parser closes immediately +// after Open consumes the line. +func (p *abbreviationBlockParser) Continue( + n ast.Node, reader text.Reader, pc parser.Context, +) parser.State { + return parser.Close +} + +// Close implements parser.BlockParser. +func (p *abbreviationBlockParser) Close(n ast.Node, reader text.Reader, pc parser.Context) {} + +// CanInterruptParagraph implements parser.BlockParser. +func (p *abbreviationBlockParser) CanInterruptParagraph() bool { return false } + +// CanAcceptIndentedLine implements parser.BlockParser. +func (p *abbreviationBlockParser) CanAcceptIndentedLine() bool { return false } + +// --- AST transformer -------------------------------------------------- + +// abbreviationTransformer runs after block parsing and rewrites +// inline Text nodes to mark whole-word occurrences of defined terms +// as AbbreviationReference nodes. +type abbreviationTransformer struct{} + +// Transform implements parser.ASTTransformer. +func (t *abbreviationTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) { + raw := pc.Get(abbrTableKey) + if raw == nil { + return + } + table := raw.(abbrTable) + if len(table) == 0 { + return + } + source := reader.Source() + + // Walk every Text descendant in the document and rewrite + // whole-word term matches. Code spans, fenced and indented code + // blocks, and AbbreviationDefinition nodes are skipped so their + // contents stay literal; everything else (paragraphs, headings, + // list items, etc.) is eligible for marking. + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + switch node := n.(type) { + case *ast.CodeSpan, *ast.FencedCodeBlock, *ast.CodeBlock, + *AbbreviationDefinition: + return ast.WalkSkipChildren, nil + case *ast.Text: + rewriteText(node, table, source) + return ast.WalkSkipChildren, nil + } + return ast.WalkContinue, nil + }) +} + +// abbrMatch is an internal record of one abbreviation hit inside a +// Text node body. Offsets are relative to the body, not to f.Source. +type abbrMatch struct { + start, end int + term string +} + +// rewriteText splits a Text node around whole-word term matches and +// inserts AbbreviationReference nodes in their place. The original +// Text node's segment is shrunk to the prefix before the first +// match; subsequent content is appended as sibling nodes. +func rewriteText(t *ast.Text, table abbrTable, source []byte) { + seg := t.Segment + body := seg.Value(source) + if len(body) == 0 { + return + } + matches := findMatches(body, table) + if len(matches) == 0 { + return + } + parent := t.Parent() + if parent == nil { + return + } + applyMatches(parent, t, seg, body, matches) +} + +// findMatches scans body for the longest whole-word match of any +// defined term at each word-boundary position and advances past each +// hit so occurrences never overlap. +func findMatches(body []byte, table abbrTable) []abbrMatch { + var matches []abbrMatch + for i := 0; i < len(body); { + if i > 0 && isWordByte(body[i-1]) { + i++ + continue + } + m, ok := bestMatchAt(body, i, table) + if !ok { + i++ + continue + } + matches = append(matches, m) + i = m.end + } + return matches +} + +// bestMatchAt returns the longest term in table that matches body +// starting at i, requiring a word boundary after the term. +func bestMatchAt(body []byte, i int, table abbrTable) (abbrMatch, bool) { + best := abbrMatch{start: -1} + for term := range table { + tb := []byte(term) + if !bytes.HasPrefix(body[i:], tb) { + continue + } + endIdx := i + len(tb) + if endIdx < len(body) && isWordByte(body[endIdx]) { + continue + } + if len(tb) > best.end-best.start { + best = abbrMatch{start: i, end: endIdx, term: term} + } + } + if best.start < 0 { + return abbrMatch{}, false + } + return best, true +} + +// applyMatches rewrites the AST around t with the given matches. +// The original Text is either shrunk to the prefix (common case) +// or replaced entirely when the first match starts at offset 0. +func applyMatches(parent ast.Node, t *ast.Text, seg text.Segment, body []byte, matches []abbrMatch) { + first := matches[0] + var anchor ast.Node + if first.start == 0 { + ref := buildReference(seg, first) + parent.ReplaceChild(parent, t, ref) + anchor = ref + } else { + t.Segment = seg.WithStop(seg.Start + first.start) + ref := buildReference(seg, first) + parent.InsertAfter(parent, t, ref) + anchor = ref + } + anchor = appendRestAfter(parent, anchor, seg, matches[1:], first.end) + _ = appendTail(parent, anchor, seg, body, lastEnd(first, matches[1:])) +} + +// appendRestAfter inserts gap-text and reference nodes for each +// remaining match after anchor, returning the last inserted node. +func appendRestAfter(parent, anchor ast.Node, seg text.Segment, rest []abbrMatch, prev int) ast.Node { + for _, m := range rest { + if m.start > prev { + gap := ast.NewTextSegment(subSeg(seg, prev, m.start)) + parent.InsertAfter(parent, anchor, gap) + anchor = gap + } + ref := buildReference(seg, m) + parent.InsertAfter(parent, anchor, ref) + anchor = ref + prev = m.end + } + return anchor +} + +// appendTail inserts the final Text node for body content after the +// last match, returning the inserted node (or anchor when no tail). +func appendTail(parent, anchor ast.Node, seg text.Segment, body []byte, prev int) ast.Node { + if prev >= len(body) { + return anchor + } + tail := ast.NewTextSegment(seg.WithStart(seg.Start + prev)) + parent.InsertAfter(parent, anchor, tail) + return tail +} + +// lastEnd returns the end offset of the final match in first+rest. +func lastEnd(first abbrMatch, rest []abbrMatch) int { + if len(rest) == 0 { + return first.end + } + return rest[len(rest)-1].end +} + +// buildReference constructs an AbbreviationReference whose child +// Text covers the term's byte span. +func buildReference(parentSeg text.Segment, m abbrMatch) ast.Node { + ref := &AbbreviationReference{Term: []byte(m.term)} + ref.AppendChild(ref, ast.NewTextSegment(subSeg(parentSeg, m.start, m.end))) + return ref +} + +// isWordByte reports whether b is part of a word for abbreviation +// boundary purposes: ASCII alphanumeric or underscore. +func isWordByte(b byte) bool { + return (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') || + (b >= '0' && b <= '9') || b == '_' +} + +// subSeg returns a new Segment that starts at parent.Start + start +// and stops at parent.Start + stop. Avoids chained WithStart / +// WithStop calls, which cannot be inlined because both are pointer +// methods on text.Segment. +func subSeg(parent text.Segment, start, stop int) text.Segment { + s := parent.WithStart(parent.Start + start) + return s.WithStop(parent.Start + stop) +} + +// --- extender --------------------------------------------------------- + +type abbreviationExt struct{} + +// Abbreviation is the goldmark Extender that installs the block +// parser (priority 900 — later than most block parsers so it runs +// only when nothing else has claimed the line) and the AST +// transformer (priority 800 — runs after all blocks are finalized). +var Abbreviation goldmark.Extender = &abbreviationExt{} + +// Extend implements goldmark.Extender. +func (e *abbreviationExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions( + parser.WithBlockParsers( + util.Prioritized(&abbreviationBlockParser{}, 900), + ), + parser.WithASTTransformers( + util.Prioritized(&abbreviationTransformer{}, 800), + ), + ) +} diff --git a/internal/rules/markdownflavor/ext/abbreviation_edge_test.go b/internal/rules/markdownflavor/ext/abbreviation_edge_test.go new file mode 100644 index 000000000..bac81b272 --- /dev/null +++ b/internal/rules/markdownflavor/ext/abbreviation_edge_test.go @@ -0,0 +1,134 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// TestRewriteTextEmptyBody covers the early-return when the Text +// node's segment is empty. +func TestRewriteTextEmptyBody(t *testing.T) { + tbl := abbrTable{"HTML": []byte("Hyper Text Markup Language")} + src := []byte("") + tn := ast.NewTextSegment(text.NewSegment(0, 0)) + assert.NotPanics(t, func() { rewriteText(tn, tbl, src) }) +} + +// TestRewriteTextOrphanParent covers the early-return when the Text +// has no parent (the rewrite pass has nowhere to insert siblings). +func TestRewriteTextOrphanParent(t *testing.T) { + src := []byte("HTML") + tbl := abbrTable{"HTML": []byte("Hyper Text Markup Language")} + tn := ast.NewTextSegment(text.NewSegment(0, len(src))) + assert.NotPanics(t, func() { rewriteText(tn, tbl, src) }) +} + +// TestBestMatchAtWordBoundaryRejectsSuffix exercises the +// "endIdx < len(body) && isWordByte(...)" rejection in bestMatchAt: +// a defined term that is a prefix of a longer word must not match. +func TestBestMatchAtWordBoundaryRejectsSuffix(t *testing.T) { + tbl := abbrTable{"API": []byte("Application Programming Interface")} + body := []byte("APIserver does things") + _, ok := bestMatchAt(body, 0, tbl) + assert.False(t, ok, + "API followed by word byte 's' must not match") +} + +// TestAbbreviationOpenEdgeCases exercises every rejection path in +// the block parser's Open method that the happy-path tests don't +// already hit. +func TestAbbreviationOpenEdgeCases(t *testing.T) { + p := &abbreviationBlockParser{} + + cases := []struct { + name string + src string + }{ + {"empty", ""}, + {"four-space indent", " *[X]: y\n"}, + {"not a definition prefix", "paragraph text\n"}, + {"missing closing bracket", "*[HTML\n"}, + {"missing colon", "*[HTML] hyper\n"}, + {"empty term not accepted", "*[]: something\n"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := text.NewReader([]byte(tc.src)) + pc := parser.NewContext() + node, _ := p.Open(nil, r, pc) + assert.Nil(t, node, + "Open must reject %q (%s)", tc.src, tc.name) + }) + } +} + +// TestAbbreviationDefinitionWithEmptyExpansion verifies the parser +// accepts `*[TERM]:` (no expansion) and records an empty Expansion. +func TestAbbreviationDefinitionWithEmptyExpansion(t *testing.T) { + src := "*[HTML]:\n\nUse HTML here.\n" + doc := parseWith(t, src, Abbreviation) + def := walkFindKind(doc, KindAbbreviationDefinition) + if assert.NotNil(t, def, "accepts `*[TERM]:` with no expansion") { + n := def.(*AbbreviationDefinition) + assert.Equal(t, "HTML", string(n.Term)) + assert.Empty(t, n.Expansion) + } +} + +// TestAbbreviationTransformerNoTable skips the walk entirely when no +// definitions were found. +func TestAbbreviationTransformerNoTable(t *testing.T) { + // A paragraph with no *[term] definition: the transformer's + // `raw == nil` early return fires and nothing gets marked. + src := "Just a paragraph with HTML mentioned.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference)) + assert.Nil(t, walkFindKind(doc, KindAbbreviationDefinition)) +} + +// TestAbbreviationReferenceAtParagraphStart exercises rewriteText's +// `first.start == 0` branch: when a definition match is the first +// byte of a paragraph, the original Text node is replaced with the +// reference rather than being shrunk to a prefix. +func TestAbbreviationReferenceAtParagraphStart(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n\nHTML is great.\n" + doc := parseWith(t, src, Abbreviation) + ref := walkFindKind(doc, KindAbbreviationReference) + if assert.NotNil(t, ref) { + assert.Equal(t, "HTML", string(ref.(*AbbreviationReference).Term)) + } + // The paragraph should also carry the trailing " is great." as + // a sibling text node; ensure the number of refs is 1 (no + // runaway duplication). + assert.Equal(t, 1, countKind(doc, KindAbbreviationReference)) +} + +// TestAbbreviationMultipleTermsInOneParagraph covers the +// appendRestAfter branch that emits a gap text between two +// consecutive matches. +func TestAbbreviationMultipleTermsInOneParagraph(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n*[CSS]: Cascading Style Sheets\n\n" + + "HTML and CSS together.\n" + doc := parseWith(t, src, Abbreviation) + assert.Equal(t, 2, countKind(doc, KindAbbreviationReference)) +} + +// TestAbbreviationTransformerEmptyTable covers the branch where the +// context key is set to a zero-length table (e.g. an upstream +// package interacting with the same key) — transformer must still +// early-return. +func TestAbbreviationTransformerEmptyTable(t *testing.T) { + transformer := &abbreviationTransformer{} + doc := parseWith(t, "# Heading\n\nParagraph.\n", Abbreviation) + pc := parser.NewContext() + pc.Set(abbrTableKey, abbrTable{}) + reader := text.NewReader([]byte("# Heading\n\nParagraph.\n")) + assert.NotPanics(t, func() { + transformer.Transform(doc.(*ast.Document), reader, pc) + }) +} diff --git a/internal/rules/markdownflavor/ext/abbreviation_test.go b/internal/rules/markdownflavor/ext/abbreviation_test.go new file mode 100644 index 000000000..ebf2c021d --- /dev/null +++ b/internal/rules/markdownflavor/ext/abbreviation_test.go @@ -0,0 +1,53 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAbbreviationDefinitionRecognised(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n\nUse HTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.NotNil(t, walkFindKind(doc, KindAbbreviationDefinition), + "expected AbbreviationDefinition for `*[HTML]: ...`") +} + +func TestAbbreviationReferenceMarkedInParagraph(t *testing.T) { + src := "*[HTML]: Hyper Text Markup Language\n\nUse HTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.NotNil(t, walkFindKind(doc, KindAbbreviationReference), + "expected AbbreviationReference for the word 'HTML' inside the paragraph") +} + +func TestAbbreviationRequiresDefinedTerm(t *testing.T) { + // Without a `*[term]: ...` definition, an occurrence in text + // must not produce an AbbreviationReference node. + src := "Use HTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference)) + assert.Nil(t, walkFindKind(doc, KindAbbreviationDefinition)) +} + +func TestAbbreviationDoesNotMatchSubstring(t *testing.T) { + // "HTML" must not match as a suffix of "XHTML" — only whole- + // word tokens count. + src := "*[HTML]: Hyper Text Markup Language\n\nUse XHTML here.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference), + "XHTML must not be matched as HTML") +} + +func TestAbbreviationMultipleOccurrences(t *testing.T) { + src := "*[API]: Application Programming Interface\n\nAPI here; API there.\n" + doc := parseWith(t, src, Abbreviation) + assert.Equal(t, 2, countKind(doc, KindAbbreviationReference), + "both API occurrences should be marked") +} + +func TestAbbreviationInsideCodeIgnored(t *testing.T) { + // Occurrences inside inline code must not be marked. + src := "*[HTML]: Hyper Text Markup Language\n\nUse `HTML` here.\n" + doc := parseWith(t, src, Abbreviation) + assert.Nil(t, walkFindKind(doc, KindAbbreviationReference)) +} diff --git a/internal/rules/markdownflavor/ext/mathblock.go b/internal/rules/markdownflavor/ext/mathblock.go new file mode 100644 index 000000000..69db0b446 --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathblock.go @@ -0,0 +1,132 @@ +package ext + +import ( + "bytes" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// MathBlockNode is the AST node produced by the math-block parser +// for a `$$...$$` fenced display-math block. Detection-only; body +// bytes are recorded via BaseBlock.Lines so a future Fix could slice +// them out of the source, but MDS034 only inspects the node's kind. +type MathBlockNode struct { + ast.BaseBlock + // closed tracks whether a closing `$$` fence was observed. + closed bool +} + +// KindMathBlock is the NodeKind of MathBlockNode. +var KindMathBlock = ast.NewNodeKind("MathBlock") + +// Kind implements ast.Node. +func (n *MathBlockNode) Kind() ast.NodeKind { return KindMathBlock } + +// IsRaw implements ast.Node. Math-block content is raw — no inline +// children are parsed inside. +func (n *MathBlockNode) IsRaw() bool { return true } + +// HasClosure reports whether the block observed a closing fence. +func (n *MathBlockNode) HasClosure() bool { return n.closed } + +// Dump implements ast.Node for debug output. +func (n *MathBlockNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// mathBlockParser is the BlockParser registered with goldmark. +type mathBlockParser struct{} + +// Trigger implements parser.BlockParser. +func (p *mathBlockParser) Trigger() []byte { return []byte{'$'} } + +var mathFence = []byte("$$") + +// Open implements parser.BlockParser. A line that starts with `$$` +// after up to three spaces of indent opens a math-block node, +// regardless of its parent block. If the same line also contains a +// closing `$$`, the block is closed immediately. +func (p *mathBlockParser) Open( + parent ast.Node, reader text.Reader, pc parser.Context, +) (ast.Node, parser.State) { + line, seg := reader.PeekLine() + if line == nil { + return nil, parser.NoChildren + } + trimmed := bytes.TrimLeft(line, " ") + indent := len(line) - len(trimmed) + if indent >= 4 { + return nil, parser.NoChildren + } + if !bytes.HasPrefix(trimmed, mathFence) { + return nil, parser.NoChildren + } + node := &MathBlockNode{} + node.Lines().Append(seg) + + // Look for a closing `$$` on the same line. The rest of the line + // (after the opening `$$`) is searched for a standalone `$$`. + rest := bytes.TrimRight(trimmed[len(mathFence):], "\r\n") + if bytes.Contains(rest, mathFence) { + node.closed = true + } + reader.AdvanceToEOL() + return node, parser.NoChildren +} + +// Continue implements parser.BlockParser. Each subsequent line is +// appended to the block. A line whose trimmed content is exactly +// `$$` closes the block. +func (p *mathBlockParser) Continue( + n ast.Node, reader text.Reader, pc parser.Context, +) parser.State { + mb := n.(*MathBlockNode) + if mb.closed { + return parser.Close + } + line, seg := reader.PeekLine() + if line == nil { + return parser.Close + } + mb.Lines().Append(seg) + if bytes.Equal(bytes.TrimSpace(line), mathFence) { + mb.closed = true + reader.AdvanceToEOL() + return parser.Close + } + reader.AdvanceToEOL() + return parser.Continue | parser.NoChildren +} + +// Close implements parser.BlockParser. +func (p *mathBlockParser) Close(n ast.Node, reader text.Reader, pc parser.Context) {} + +// CanInterruptParagraph implements parser.BlockParser. Math fences +// behave like fenced code — they can start a block on a new line +// but do not interrupt an existing paragraph. +func (p *mathBlockParser) CanInterruptParagraph() bool { return false } + +// CanAcceptIndentedLine implements parser.BlockParser. +func (p *mathBlockParser) CanAcceptIndentedLine() bool { return false } + +// mathBlockExt wires the parser into goldmark. +type mathBlockExt struct{} + +// MathBlock is the goldmark Extender that installs the math-block +// block parser at priority 700. `$` is not a default block trigger, +// so no other parser competes; the value is chosen to match +// goldmark's own fenced-block precedent without claiming any +// ordering relationship to fenced-code (which triggers on backtick +// or tilde, not `$`). +var MathBlock goldmark.Extender = &mathBlockExt{} + +// Extend implements goldmark.Extender. +func (e *mathBlockExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithBlockParsers( + util.Prioritized(&mathBlockParser{}, 700), + )) +} diff --git a/internal/rules/markdownflavor/ext/mathblock_edge_test.go b/internal/rules/markdownflavor/ext/mathblock_edge_test.go new file mode 100644 index 000000000..302f566dd --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathblock_edge_test.go @@ -0,0 +1,67 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" +) + +// TestMathBlockOpenRejectsShortLine covers the `line == nil` early- +// return path in Open, and the three-space indent limit. +func TestMathBlockOpenEdgeCases(t *testing.T) { + p := &mathBlockParser{} + pc := parser.NewContext() + + // An empty document: PeekLine returns nil → Open rejects. + empty := text.NewReader([]byte("")) + node, _ := p.Open(nil, empty, pc) + assert.Nil(t, node, "Open on empty input must not open a block") + + // Four-space indent: Open rejects even though the line starts + // with `$$` further in. + indented := text.NewReader([]byte(" $$\n x\n $$\n")) + node, _ = p.Open(nil, indented, pc) + assert.Nil(t, node, "Open must not open on four-space-indented line") + + // Line that shares the `$` trigger but isn't a fence. + notFence := text.NewReader([]byte("$price\n")) + node, _ = p.Open(nil, notFence, pc) + assert.Nil(t, node, "Open must reject non-fence `$` lines") +} + +// TestMathBlockContinueEOF covers the `line == nil` path in Continue: +// the reader reaches EOF while the block is still open. +func TestMathBlockContinueEOF(t *testing.T) { + src := "$$\na^2 + b^2\n" + doc := parseWith(t, src, MathBlock) + n := walkFindKind(doc, KindMathBlock) + if assert.NotNil(t, n) { + mb := n.(*MathBlockNode) + assert.False(t, mb.HasClosure(), + "unclosed block must carry closed=false") + } +} + +// TestMathBlockContinueAlreadyClosed exercises the `mb.closed` early- +// return in Continue. A same-line `$$…$$` block is closed during +// Open; goldmark still calls Continue once, and it must return +// parser.Close without consuming another line. +func TestMathBlockContinueAlreadyClosed(t *testing.T) { + mb := &MathBlockNode{closed: true} + r := text.NewReader([]byte("some other line\n")) + got := (&mathBlockParser{}).Continue(mb, r, parser.NewContext()) + assert.Equal(t, parser.Close, got, + "Continue on an already-closed block must return parser.Close") +} + +// TestMathBlockContinueEOFDirect covers the `line == nil` branch in +// Continue by feeding the parser an empty reader. +func TestMathBlockContinueEOFDirect(t *testing.T) { + mb := &MathBlockNode{} + r := text.NewReader([]byte("")) + got := (&mathBlockParser{}).Continue(mb, r, parser.NewContext()) + assert.Equal(t, parser.Close, got, + "Continue on EOF must return parser.Close") +} diff --git a/internal/rules/markdownflavor/ext/mathblock_test.go b/internal/rules/markdownflavor/ext/mathblock_test.go new file mode 100644 index 000000000..e32106a94 --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathblock_test.go @@ -0,0 +1,55 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMathBlockParses(t *testing.T) { + src := "before\n\n$$\na^2 + b^2 = c^2\n$$\n\nafter\n" + doc := parseWith(t, src, MathBlock) + assert.NotNil(t, walkFindKind(doc, KindMathBlock), + "expected MathBlock node for $$...$$ fence") +} + +func TestMathBlockClosingOnSameLine(t *testing.T) { + // A single-line block like `$$...$$` is also valid. + src := "$$E=mc^2$$\n" + doc := parseWith(t, src, MathBlock) + assert.NotNil(t, walkFindKind(doc, KindMathBlock)) +} + +func TestMathBlockUnclosedIsNotMatched(t *testing.T) { + // If no closing `$$` appears, the block must not leak into the + // AST as a MathBlock — it stays as regular paragraph content. + src := "$$\nno close here\nparagraph\n" + doc := parseWith(t, src, MathBlock) + // Unclosed block may still create a node; verify it is flagged + // closed=false so detect can decide how to report it. The plan + // does not require matching unclosed blocks, so either "no node" + // or "node with HasClosure()==false" is acceptable. Assert the + // latter if a node was produced. + if n := walkFindKind(doc, KindMathBlock); n != nil { + mb, ok := n.(*MathBlockNode) + if assert.True(t, ok) { + assert.False(t, mb.HasClosure()) + } + } +} + +func TestMathBlockInsideParagraphIsIgnored(t *testing.T) { + // `$$` in the middle of paragraph text must not start a block. + src := "text $$inline$$ here\n" + doc := parseWith(t, src, MathBlock) + assert.Nil(t, walkFindKind(doc, KindMathBlock), + "mid-paragraph `$$` must not open a math block") +} + +func TestMathBlockIndentedDoesNotOpen(t *testing.T) { + // Four spaces of indent makes the line a code block, not a math + // fence. + src := " $$\n x + y\n $$\n" + doc := parseWith(t, src, MathBlock) + assert.Nil(t, walkFindKind(doc, KindMathBlock)) +} diff --git a/internal/rules/markdownflavor/ext/mathinline.go b/internal/rules/markdownflavor/ext/mathinline.go new file mode 100644 index 000000000..df8b17fd8 --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathinline.go @@ -0,0 +1,123 @@ +package ext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// MathInlineNode is the AST node produced by the inline-math parser +// for a `$...$` span using Pandoc's tex_math_dollars rules. +type MathInlineNode struct { + ast.BaseInline +} + +// KindMathInline is the NodeKind of MathInlineNode. +var KindMathInline = ast.NewNodeKind("MathInline") + +// Kind implements ast.Node. +func (n *MathInlineNode) Kind() ast.NodeKind { return KindMathInline } + +// Dump implements ast.Node for debug output. +func (n *MathInlineNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// mathInlineParser is the InlineParser registered with goldmark. +// It walks the current line byte-by-byte to apply Pandoc's +// tex_math_dollars rules, which are not expressible through the +// delimiter-pairing framework because the closing `$` depends on +// the character that *follows* it. +type mathInlineParser struct{} + +// Trigger implements parser.InlineParser. +func (p *mathInlineParser) Trigger() []byte { return []byte{'$'} } + +// Parse implements parser.InlineParser. +// +// Pandoc's tex_math_dollars: +// - The opening `$` must be immediately followed by a character +// that is not whitespace and not another `$` (the latter rule +// keeps `$$` from looking like a zero-length inline span). +// - A closing `$` is any `$` on the same line whose preceding +// character is not whitespace and whose following character is +// not a digit. +// +// This matches `$x$`, `($x$)`, and `foo $x+1$ bar` while rejecting +// `$ x $`, `$x $`, `$20`, and `$$`. +func (p *mathInlineParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + if before == '$' { + return nil + } + line, segment := block.PeekLine() + if len(line) < 2 || line[0] != '$' { + return nil + } + next := line[1] + if next == '$' || isSpaceByte(next) { + return nil + } + // Find a closing `$` on the line. + closeIdx := -1 + for i := 2; i < len(line); i++ { + if line[i] != '$' { + continue + } + prev := line[i-1] + if isSpaceByte(prev) { + continue + } + // If followed by another `$`, this is a `$$` fence marker, + // not a valid math-inline closer. + if i+1 < len(line) && line[i+1] == '$' { + continue + } + if i+1 < len(line) && isDigitByte(line[i+1]) { + continue + } + closeIdx = i + break + } + if closeIdx < 0 { + return nil + } + node := &MathInlineNode{} + contentSeg := segment.WithStart(segment.Start + 1) + contentSeg = contentSeg.WithStop(segment.Start + closeIdx) + node.AppendChild(node, ast.NewTextSegment(contentSeg)) + block.Advance(closeIdx + 1) + return node +} + +// CloseBlock implements parser.InlineParser. +func (p *mathInlineParser) CloseBlock(parent ast.Node, pc parser.Context) {} + +// isSpaceByte reports whether b is an ASCII whitespace byte the +// Pandoc rule treats as "space" (space, tab, newline, CR). +func isSpaceByte(b byte) bool { + return b == ' ' || b == '\t' || b == '\n' || b == '\r' +} + +// isDigitByte reports whether b is an ASCII decimal digit. +func isDigitByte(b byte) bool { return b >= '0' && b <= '9' } + +// mathInlineExt wires the parser into goldmark. +type mathInlineExt struct{} + +// MathInline is the goldmark Extender that installs the math-inline +// parser at priority 200. In goldmark a lower priority number runs +// earlier, so CommonMark emphasis (100) still wins on its own +// delimiters. Using 200 lets Pandoc-style math beat plain-text +// handling and leaves room for a future `$`-using inline parser to +// take precedence by registering at a lower number. +var MathInline goldmark.Extender = &mathInlineExt{} + +// Extend implements goldmark.Extender. +func (e *mathInlineExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&mathInlineParser{}, 200), + )) +} diff --git a/internal/rules/markdownflavor/ext/mathinline_test.go b/internal/rules/markdownflavor/ext/mathinline_test.go new file mode 100644 index 000000000..909ba2106 --- /dev/null +++ b/internal/rules/markdownflavor/ext/mathinline_test.go @@ -0,0 +1,69 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMathInlineParses(t *testing.T) { + doc := parseWith(t, "foo $x+1$ bar\n", MathInline) + assert.NotNil(t, walkFindKind(doc, KindMathInline), + "expected MathInline node for $x+1$") +} + +func TestMathInlineParensWrapped(t *testing.T) { + doc := parseWith(t, "area is ($x$)\n", MathInline) + assert.NotNil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineRejectsLeadingSpace(t *testing.T) { + // Opening `$` must be followed by a non-space character. + doc := parseWith(t, "$ x $\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline), + "'$ x $' has space after opening $ — no match") +} + +func TestMathInlineRejectsTrailingSpace(t *testing.T) { + // Closing `$` must be preceded by a non-space character. + doc := parseWith(t, "a $x $\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineRejectsDollarAmount(t *testing.T) { + // Closing `$` must not be followed by a digit. This rejects + // currency-style text like `$20$30`. + doc := parseWith(t, "pay $20$30 dollars\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline), + "digit after closing $ prevents the match") +} + +func TestMathInlineUnbalanced(t *testing.T) { + doc := parseWith(t, "this costs $5 maybe\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineInsideCodeIgnored(t *testing.T) { + doc := parseWith(t, "see `$x+1$` here.\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} + +func TestMathInlineDoesNotMatchDoubleDollar(t *testing.T) { + // `$$` is the start of a math block; the inline parser must not + // fire on consecutive dollars. + doc := parseWith(t, "before $$ not inline $$ after\n", MathInline) + assert.Nil(t, walkFindKind(doc, KindMathInline)) +} + +// TestMathInlineSkipsDoubleDollarAsCloser exercises the Parse branch +// that rejects a candidate closing `$` when it is immediately +// followed by another `$` (i.e. a `$$` fence). The parser must look +// past the first `$$` and pair the opening `$` with a later, valid +// closing `$`. +func TestMathInlineSkipsDoubleDollarAsCloser(t *testing.T) { + // The first candidate closer `$` at index after `x` is followed + // by another `$`, so it is skipped; the next `$` after `y` + // closes the span. + doc := parseWith(t, "see $x$$y$ here\n", MathInline) + assert.NotNil(t, walkFindKind(doc, KindMathInline)) +} diff --git a/internal/rules/markdownflavor/ext/stubs_test.go b/internal/rules/markdownflavor/ext/stubs_test.go new file mode 100644 index 000000000..9c83f2661 --- /dev/null +++ b/internal/rules/markdownflavor/ext/stubs_test.go @@ -0,0 +1,43 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +// TestExtensionStubsAreSafe exercises the no-op interface methods +// that goldmark requires (Close / CloseBlock / CanInterruptParagraph / +// CanAcceptIndentedLine / Dump). None do meaningful work; the tests +// just ensure they don't panic when invoked. +func TestExtensionStubsAreSafe(t *testing.T) { + assert.NotPanics(t, func() { + (&mathBlockParser{}).Close(nil, nil, nil) + _ = (&mathBlockParser{}).CanInterruptParagraph() + _ = (&mathBlockParser{}).CanAcceptIndentedLine() + }, "mathBlockParser stubs must not panic") + + assert.NotPanics(t, func() { + (&abbreviationBlockParser{}).Close(nil, nil, nil) + _ = (&abbreviationBlockParser{}).CanInterruptParagraph() + _ = (&abbreviationBlockParser{}).CanAcceptIndentedLine() + }, "abbreviationBlockParser stubs must not panic") + + assert.NotPanics(t, func() { + (&superscriptParser{}).CloseBlock(nil, nil) + (&subscriptParser{}).CloseBlock(nil, nil) + (&mathInlineParser{}).CloseBlock(nil, nil) + }, "inline parser CloseBlock stubs must not panic") +} + +// TestDumpDoesNotPanic calls Dump on each custom AST node type with a +// nil source buffer. Dump is a debug helper; we only care that it +// runs without crashing. +func TestDumpDoesNotPanic(t *testing.T) { + assert.NotPanics(t, func() { (&SuperscriptNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&SubscriptNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&MathBlockNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&MathInlineNode{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&AbbreviationDefinition{}).Dump(nil, 0) }) + assert.NotPanics(t, func() { (&AbbreviationReference{}).Dump(nil, 0) }) +} diff --git a/internal/rules/markdownflavor/ext/subscript.go b/internal/rules/markdownflavor/ext/subscript.go new file mode 100644 index 000000000..5c4e7d918 --- /dev/null +++ b/internal/rules/markdownflavor/ext/subscript.go @@ -0,0 +1,97 @@ +package ext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// SubscriptNode is the AST node produced by the subscript parser for +// a single-tilde `~text~` span. +type SubscriptNode struct { + ast.BaseInline +} + +// KindSubscript is the NodeKind of SubscriptNode. +var KindSubscript = ast.NewNodeKind("Subscript") + +// Kind implements ast.Node. +func (n *SubscriptNode) Kind() ast.NodeKind { return KindSubscript } + +// Dump implements ast.Node for debug output. +func (n *SubscriptNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// subscriptDelimiter drives the delimiter framework over a single- +// tilde span. Goldmark's built-in strikethrough uses the same char +// but matches `~~…~~`; see subscriptParser.Parse for the length +// partition that keeps the two extensions from stepping on each +// other. +type subscriptDelimiter struct{} + +func (p *subscriptDelimiter) IsDelimiter(b byte) bool { return b == '~' } + +func (p *subscriptDelimiter) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == '~' && closer.Char == '~' +} + +func (p *subscriptDelimiter) OnMatch(consumes int) ast.Node { return &SubscriptNode{} } + +var defaultSubscriptDelimiter = &subscriptDelimiter{} + +// subscriptParser is the InlineParser registered with goldmark. +type subscriptParser struct{} + +// Trigger implements parser.InlineParser. +func (p *subscriptParser) Trigger() []byte { return []byte{'~'} } + +// Parse implements parser.InlineParser. +// +// Subscript shares its trigger byte with goldmark's strikethrough +// extension. The two coexist through two complementary rules: +// +// - Subscript accepts only an exactly length-1 `~` run (`~text~`). +// `~~...~~` is rejected here so strikethrough — registered at +// a lower priority — gets to handle it next. +// - A Parse call that starts immediately after another `~` is +// rejected, so goldmark advancing one byte into the middle of +// `~~` cannot trigger a spurious subscript span. +func (p *subscriptParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + if before == '~' { + return nil + } + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 1, defaultSubscriptDelimiter) + if node == nil || node.OriginalLength != 1 { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +// CloseBlock implements parser.InlineParser. +func (p *subscriptParser) CloseBlock(parent ast.Node, pc parser.Context) {} + +// subscriptExt wires the parser into goldmark. +type subscriptExt struct{} + +// Subscript is the goldmark Extender that installs the subscript +// inline parser with a priority (400) higher — i.e. numerically +// smaller — than built-in strikethrough (500). Goldmark tries inline +// parsers for a shared trigger byte in priority order and stops at +// the first non-nil result, so subscript takes length-1 runs and +// strikethrough still handles length-2 runs. +var Subscript goldmark.Extender = &subscriptExt{} + +// Extend implements goldmark.Extender. +func (e *subscriptExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&subscriptParser{}, 400), + )) +} diff --git a/internal/rules/markdownflavor/ext/subscript_test.go b/internal/rules/markdownflavor/ext/subscript_test.go new file mode 100644 index 000000000..04af299f4 --- /dev/null +++ b/internal/rules/markdownflavor/ext/subscript_test.go @@ -0,0 +1,57 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/extension" + extast "github.com/yuin/goldmark/extension/ast" +) + +func TestSubscriptParsesSingleTilde(t *testing.T) { + doc := parseWith(t, "H~2~O is water.\n", Subscript) + assert.NotNil(t, walkFindKind(doc, KindSubscript), + "expected Subscript node for H~2~O") +} + +// When both the built-in strikethrough extension and our subscript +// extension are enabled, `~x~` must be subscript (not strikethrough) +// and `~~x~~` must remain strikethrough. The subscript parser is +// registered with a higher priority (smaller number) so it gets the +// first chance at each `~` run. +func TestSubscriptCoexistsWithStrikethrough(t *testing.T) { + doc := parseWith(t, "H~2~O and ~~old~~ text.\n", Subscript, extension.Strikethrough) + assert.NotNil(t, walkFindKind(doc, KindSubscript), + "single-tilde span must become Subscript") + assert.NotNil(t, walkFindKind(doc, extast.KindStrikethrough), + "double-tilde span must still become Strikethrough") +} + +func TestSubscriptDoubleTildeIsNotSubscript(t *testing.T) { + doc := parseWith(t, "a~~b~~c\n", Subscript) + assert.Nil(t, walkFindKind(doc, KindSubscript), + "`~~...~~` must not match subscript") +} + +func TestSubscriptUnbalancedTilde(t *testing.T) { + doc := parseWith(t, "a~b c\n", Subscript) + assert.Nil(t, walkFindKind(doc, KindSubscript)) +} + +func TestSubscriptContent(t *testing.T) { + src := []byte("H~2~O\n") + doc := parseWith(t, string(src), Subscript) + node := walkFindKind(doc, KindSubscript) + if assert.NotNil(t, node) { + child, ok := node.FirstChild().(*ast.Text) + if assert.True(t, ok, "subscript first child should be a Text node") { + assert.Equal(t, "2", string(child.Segment.Value(src))) + } + } +} + +func TestSubscriptInsideCodeIgnored(t *testing.T) { + doc := parseWith(t, "see `H~2~O` here.\n", Subscript) + assert.Nil(t, walkFindKind(doc, KindSubscript)) +} diff --git a/internal/rules/markdownflavor/ext/superscript.go b/internal/rules/markdownflavor/ext/superscript.go new file mode 100644 index 000000000..dbc97c644 --- /dev/null +++ b/internal/rules/markdownflavor/ext/superscript.go @@ -0,0 +1,103 @@ +// Package ext implements detection-only goldmark extensions used by +// MDS034 (markdown-flavor) to flag syntax that varies across Markdown +// flavors. Each extension parses its feature's syntax into a custom +// AST node; the rule walks the dual parser's tree and emits +// diagnostics. There is no HTML renderer — the nodes exist purely so +// the main rule can detect them by kind. +package ext + +import ( + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/parser" + "github.com/yuin/goldmark/text" + "github.com/yuin/goldmark/util" +) + +// SuperscriptNode is the AST node produced by the superscript parser +// for a `^text^` span. It carries no extra state; the surrounding +// content is stored as inline children. +type SuperscriptNode struct { + ast.BaseInline +} + +// KindSuperscript is the NodeKind of SuperscriptNode. +var KindSuperscript = ast.NewNodeKind("Superscript") + +// Kind implements ast.Node. +func (n *SuperscriptNode) Kind() ast.NodeKind { return KindSuperscript } + +// Dump implements ast.Node for debug output. +func (n *SuperscriptNode) Dump(source []byte, level int) { + ast.DumpHelper(n, source, level, nil, nil) +} + +// superscriptDelimiter drives goldmark's delimiter pairing over `^`. +// A `^` run of length 1 can open or close a superscript span; longer +// runs (e.g. `^^`) are rejected so they remain literal text. +type superscriptDelimiter struct{} + +func (p *superscriptDelimiter) IsDelimiter(b byte) bool { return b == '^' } + +func (p *superscriptDelimiter) CanOpenCloser(opener, closer *parser.Delimiter) bool { + return opener.Char == '^' && closer.Char == '^' +} + +func (p *superscriptDelimiter) OnMatch(consumes int) ast.Node { + return &SuperscriptNode{} +} + +var defaultSuperscriptDelimiter = &superscriptDelimiter{} + +// superscriptParser is the InlineParser registered with goldmark. +type superscriptParser struct{} + +// Trigger implements parser.InlineParser. +func (p *superscriptParser) Trigger() []byte { return []byte{'^'} } + +// Parse implements parser.InlineParser. It rejects `^^` and longer +// runs so they remain literal, and pushes a length-1 delimiter that +// goldmark's delimiter framework pairs with the next `^` in the same +// inline context. Spans containing whitespace (`^ x ^`) are rejected +// via CanOpen/CanClose, matching the emphasis-style left/right-flank +// rules that parser.ScanDelimiter computes. +func (p *superscriptParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { + before := block.PrecendingCharacter() + // `^^` / `^^^` and longer runs must stay literal. When goldmark + // advances into the middle of such a run, the `before` rune is + // `^` — reject those positions too so a stray single `^` inside + // a longer run does not start a bogus span. + if before == '^' { + return nil + } + line, segment := block.PeekLine() + node := parser.ScanDelimiter(line, before, 1, defaultSuperscriptDelimiter) + if node == nil || node.OriginalLength != 1 { + return nil + } + node.Segment = segment.WithStop(segment.Start + node.OriginalLength) + block.Advance(node.OriginalLength) + pc.PushDelimiter(node) + return node +} + +// CloseBlock implements parser.InlineParser. +func (p *superscriptParser) CloseBlock(parent ast.Node, pc parser.Context) {} + +// superscriptExt wires the parser into goldmark. It registers only +// the parser; MDS034 does not render, so no HTML renderer is added. +type superscriptExt struct{} + +// Superscript is the goldmark Extender that installs the superscript +// inline parser at priority 500. In goldmark a lower priority number +// runs earlier, so CommonMark emphasis (100) still wins on its own +// delimiters; `^` has no other default parser, so the ordering here +// does not introduce ambiguity. +var Superscript goldmark.Extender = &superscriptExt{} + +// Extend implements goldmark.Extender. +func (e *superscriptExt) Extend(m goldmark.Markdown) { + m.Parser().AddOptions(parser.WithInlineParsers( + util.Prioritized(&superscriptParser{}, 500), + )) +} diff --git a/internal/rules/markdownflavor/ext/superscript_test.go b/internal/rules/markdownflavor/ext/superscript_test.go new file mode 100644 index 000000000..a9879aa8e --- /dev/null +++ b/internal/rules/markdownflavor/ext/superscript_test.go @@ -0,0 +1,80 @@ +package ext + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/text" +) + +func walkFindKind(root ast.Node, kind ast.NodeKind) ast.Node { + var found ast.Node + _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && n.Kind() == kind { + found = n + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + return found +} + +func parseWith(t *testing.T, src string, exts ...goldmark.Extender) ast.Node { + t.Helper() + md := goldmark.New(goldmark.WithExtensions(exts...)) + doc := md.Parser().Parse(text.NewReader([]byte(src))) + require.NotNil(t, doc) + return doc +} + +// countKind returns the number of nodes of the given kind anywhere +// in the tree rooted at root. +func countKind(root ast.Node, kind ast.NodeKind) int { + n := 0 + _ = ast.Walk(root, func(node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering && node.Kind() == kind { + n++ + } + return ast.WalkContinue, nil + }) + return n +} + +func TestSuperscriptParses(t *testing.T) { + doc := parseWith(t, "x^2^ is fine.\n", Superscript) + assert.NotNil(t, walkFindKind(doc, KindSuperscript), + "expected Superscript node for x^2^") +} + +func TestSuperscriptSingleCharOnly(t *testing.T) { + // Double carets must not produce a Superscript node. + doc := parseWith(t, "x^^2^^\n", Superscript) + assert.Nil(t, walkFindKind(doc, KindSuperscript), + "^^...^^ must not match superscript") +} + +func TestSuperscriptUnbalancedCaret(t *testing.T) { + // A lone `^` with no closing pair must not produce a node. + doc := parseWith(t, "a^b c\n", Superscript) + assert.Nil(t, walkFindKind(doc, KindSuperscript)) +} + +func TestSuperscriptContainsContent(t *testing.T) { + src := []byte("E = mc^2^\n") + doc := parseWith(t, string(src), Superscript) + node := walkFindKind(doc, KindSuperscript) + require.NotNil(t, node) + // The child should carry the "2" text. + child, ok := node.FirstChild().(*ast.Text) + require.True(t, ok, "superscript first child should be a Text node") + assert.Equal(t, "2", string(child.Segment.Value(src))) +} + +func TestSuperscriptInsideCodeIsIgnored(t *testing.T) { + doc := parseWith(t, "see `x^2^` here.\n", Superscript) + assert.Nil(t, walkFindKind(doc, KindSuperscript), + "content inside a code span must not be parsed as superscript") +} diff --git a/internal/rules/markdownflavor/features.go b/internal/rules/markdownflavor/features.go new file mode 100644 index 000000000..c4d704a7d --- /dev/null +++ b/internal/rules/markdownflavor/features.go @@ -0,0 +1,242 @@ +// Package markdownflavor implements MDS034, which validates Markdown +// against a declared target flavor (commonmark, gfm, goldmark, +// pandoc, phpextra, multimarkdown, myst, or any) and flags syntax +// the target renderer will not understand. +package markdownflavor + +// Flavor identifies a target Markdown flavor. +type Flavor int + +// Flavor constants. The zero value is intentionally invalid so that +// unparsed settings are caught. +const ( + flavorInvalid Flavor = iota + FlavorCommonMark + FlavorGFM + FlavorGoldmark + // FlavorAny accepts every tracked feature. Useful when the + // document is destined for an unknown or permissive renderer and + // the user wants to disable flavor reporting without disabling + // the rule. + FlavorAny + // FlavorPandoc is Pandoc's default markdown dialect. Accepts + // GFM's four features plus footnotes, definition lists, heading + // IDs, superscript, subscript, math block, and inline math; + // rejects abbreviations (a non-default Pandoc extension). + FlavorPandoc + // FlavorPHPExtra is PHP Markdown Extra. Accepts tables, + // footnotes, definition lists, heading IDs, and abbreviations; + // rejects GFM's task lists, strikethrough, bare-URL autolinks, + // and every math / sub/superscript feature. + FlavorPHPExtra + // FlavorMultiMarkdown extends PHP Markdown Extra with math + // block and inline math. Like PHP Extra, rejects GFM task lists, + // strikethrough, bare-URL autolinks, and sub/superscript. + FlavorMultiMarkdown + // FlavorMyST is the MyST flavor used by the Sphinx documentation + // toolchain. Accepts tables, strikethrough, footnotes, + // definition lists, heading IDs, math block, and inline math; + // rejects GFM task lists, bare-URL autolinks, sub/superscript, + // and abbreviations. + FlavorMyST +) + +// String returns the canonical lowercase name of the flavor. +func (f Flavor) String() string { + switch f { + case FlavorCommonMark: + return "commonmark" + case FlavorGFM: + return "gfm" + case FlavorGoldmark: + return "goldmark" + case FlavorAny: + return "any" + case FlavorPandoc: + return "pandoc" + case FlavorPHPExtra: + return "phpextra" + case FlavorMultiMarkdown: + return "multimarkdown" + case FlavorMyST: + return "myst" + } + return "" +} + +// ParseFlavor converts a config string into a Flavor. The match is +// case-sensitive to reject typos like "GFM" that would otherwise +// silently validate against the wrong flavor. +func ParseFlavor(s string) (Flavor, bool) { + switch s { + case "commonmark": + return FlavorCommonMark, true + case "gfm": + return FlavorGFM, true + case "goldmark": + return FlavorGoldmark, true + case "any": + return FlavorAny, true + case "pandoc": + return FlavorPandoc, true + case "phpextra": + return FlavorPHPExtra, true + case "multimarkdown": + return FlavorMultiMarkdown, true + case "myst": + return FlavorMyST, true + } + return 0, false +} + +// Feature identifies one Markdown syntax feature whose support varies +// across flavors. +type Feature int + +// Feature constants. Keep in sync with AllFeatures, Feature.Name, +// and Feature.Verb. +const ( + FeatureTables Feature = iota + FeatureTaskLists + FeatureStrikethrough + FeatureBareURLAutolinks + FeatureFootnotes + FeatureDefinitionLists + FeatureHeadingIDs + FeatureSuperscript + FeatureSubscript + FeatureMathBlock + FeatureMathInline + FeatureAbbreviations +) + +// AllFeatures returns every tracked feature in declaration order. +func AllFeatures() []Feature { + return []Feature{ + FeatureTables, + FeatureTaskLists, + FeatureStrikethrough, + FeatureBareURLAutolinks, + FeatureFootnotes, + FeatureDefinitionLists, + FeatureHeadingIDs, + FeatureSuperscript, + FeatureSubscript, + FeatureMathBlock, + FeatureMathInline, + FeatureAbbreviations, + } +} + +// Verb returns "is" or "are" so diagnostic messages read naturally +// for both singular (strikethrough, inline math) and plural +// (tables, task lists) feature names. +func (f Feature) Verb() string { + switch f { + case FeatureStrikethrough, FeatureSuperscript, FeatureSubscript, + FeatureMathInline: + return "is" + } + return "are" +} + +// Name returns the human-readable feature name used in diagnostics. +func (f Feature) Name() string { + switch f { + case FeatureTables: + return "tables" + case FeatureTaskLists: + return "task lists" + case FeatureStrikethrough: + return "strikethrough" + case FeatureBareURLAutolinks: + return "bare-URL autolinks" + case FeatureFootnotes: + return "footnotes" + case FeatureDefinitionLists: + return "definition lists" + case FeatureHeadingIDs: + return "heading IDs" + case FeatureSuperscript: + return "superscript" + case FeatureSubscript: + return "subscript" + case FeatureMathBlock: + return "math blocks" + case FeatureMathInline: + return "inline math" + case FeatureAbbreviations: + return "abbreviations" + } + return "" +} + +// support maps (flavor, feature) to whether the flavor accepts it. +// CommonMark rejects every tracked feature. GFM adds tables, task +// lists, strikethrough, and bare-URL autolinks. The goldmark profile +// further adds heading IDs. Pandoc, PHP Markdown Extra, MultiMarkdown, +// and MyST each pick a different combination of the optional +// features; FlavorAny is handled specially in Supports. +var support = map[Flavor]map[Feature]bool{ + FlavorGFM: { + FeatureTables: true, + FeatureTaskLists: true, + FeatureStrikethrough: true, + FeatureBareURLAutolinks: true, + }, + FlavorGoldmark: { + FeatureTables: true, + FeatureTaskLists: true, + FeatureStrikethrough: true, + FeatureBareURLAutolinks: true, + FeatureHeadingIDs: true, + }, + FlavorPandoc: { + FeatureTables: true, + FeatureTaskLists: true, + FeatureStrikethrough: true, + FeatureBareURLAutolinks: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureSuperscript: true, + FeatureSubscript: true, + FeatureMathBlock: true, + FeatureMathInline: true, + }, + FlavorPHPExtra: { + FeatureTables: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureAbbreviations: true, + }, + FlavorMultiMarkdown: { + FeatureTables: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureAbbreviations: true, + FeatureMathBlock: true, + FeatureMathInline: true, + }, + FlavorMyST: { + FeatureTables: true, + FeatureStrikethrough: true, + FeatureFootnotes: true, + FeatureDefinitionLists: true, + FeatureHeadingIDs: true, + FeatureMathBlock: true, + FeatureMathInline: true, + }, +} + +// Supports reports whether the flavor accepts the given feature. +// FlavorAny accepts every feature; other flavors consult the +// support table. +func (f Flavor) Supports(feat Feature) bool { + if f == FlavorAny { + return true + } + return support[f][feat] +} diff --git a/internal/rules/markdownflavor/features_test.go b/internal/rules/markdownflavor/features_test.go new file mode 100644 index 000000000..daad7abf3 --- /dev/null +++ b/internal/rules/markdownflavor/features_test.go @@ -0,0 +1,142 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseFlavor(t *testing.T) { + tests := []struct { + in string + want Flavor + ok bool + }{ + {"commonmark", FlavorCommonMark, true}, + {"gfm", FlavorGFM, true}, + {"goldmark", FlavorGoldmark, true}, + {"any", FlavorAny, true}, + {"pandoc", FlavorPandoc, true}, + {"phpextra", FlavorPHPExtra, true}, + {"multimarkdown", FlavorMultiMarkdown, true}, + {"myst", FlavorMyST, true}, + {"GFM", 0, false}, + {"", 0, false}, + {"markdown", 0, false}, + } + for _, tc := range tests { + t.Run(tc.in, func(t *testing.T) { + got, ok := ParseFlavor(tc.in) + assert.Equal(t, tc.ok, ok) + if tc.ok { + assert.Equal(t, tc.want, got) + } + }) + } +} + +func TestFlavorString(t *testing.T) { + assert.Equal(t, "commonmark", FlavorCommonMark.String()) + assert.Equal(t, "gfm", FlavorGFM.String()) + assert.Equal(t, "goldmark", FlavorGoldmark.String()) + assert.Equal(t, "any", FlavorAny.String()) + assert.Equal(t, "pandoc", FlavorPandoc.String()) + assert.Equal(t, "phpextra", FlavorPHPExtra.String()) + assert.Equal(t, "multimarkdown", FlavorMultiMarkdown.String()) + assert.Equal(t, "myst", FlavorMyST.String()) +} + +// assertSupports checks every feature in the supported set is +// accepted by flavor and every feature not in that set is rejected. +func assertSupports(t *testing.T, f Flavor, supported ...Feature) { + t.Helper() + want := map[Feature]bool{} + for _, feat := range supported { + want[feat] = true + } + for _, feat := range AllFeatures() { + got := f.Supports(feat) + assert.Equal(t, want[feat], got, + "flavor %s feature %s: want=%v got=%v", + f.String(), feat.Name(), want[feat], got) + } +} + +func TestFlavorStringUnknownIsEmpty(t *testing.T) { + var zero Flavor + assert.Equal(t, "", zero.String()) + assert.Equal(t, "", Flavor(999).String()) +} + +func TestFeatureNameUnknownIsEmpty(t *testing.T) { + assert.Equal(t, "", Feature(999).Name()) +} + +func TestFeatureSupportCommonMark(t *testing.T) { + assertSupports(t, FlavorCommonMark) +} + +func TestFeatureSupportGFM(t *testing.T) { + assertSupports(t, FlavorGFM, + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureBareURLAutolinks) +} + +func TestFeatureSupportGoldmark(t *testing.T) { + assertSupports(t, FlavorGoldmark, + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureBareURLAutolinks, FeatureHeadingIDs) +} + +func TestFeatureSupportAny(t *testing.T) { + assertSupports(t, FlavorAny, AllFeatures()...) +} + +func TestFeatureSupportPandoc(t *testing.T) { + assertSupports(t, FlavorPandoc, + FeatureTables, FeatureTaskLists, FeatureStrikethrough, + FeatureBareURLAutolinks, FeatureFootnotes, FeatureDefinitionLists, + FeatureHeadingIDs, FeatureSuperscript, FeatureSubscript, + FeatureMathBlock, FeatureMathInline) +} + +func TestFeatureSupportPHPExtra(t *testing.T) { + assertSupports(t, FlavorPHPExtra, + FeatureTables, FeatureFootnotes, FeatureDefinitionLists, + FeatureHeadingIDs, FeatureAbbreviations) +} + +func TestFeatureSupportMultiMarkdown(t *testing.T) { + assertSupports(t, FlavorMultiMarkdown, + FeatureTables, FeatureFootnotes, FeatureDefinitionLists, + FeatureHeadingIDs, FeatureAbbreviations, + FeatureMathBlock, FeatureMathInline) +} + +func TestFeatureSupportMyST(t *testing.T) { + assertSupports(t, FlavorMyST, + FeatureTables, FeatureStrikethrough, FeatureFootnotes, + FeatureDefinitionLists, FeatureHeadingIDs, + FeatureMathBlock, FeatureMathInline) +} + +func TestAllFeaturesComplete(t *testing.T) { + // Ensure AllFeatures enumerates exactly the 12 features we track. + require.Len(t, AllFeatures(), 12) +} + +func TestFeatureName(t *testing.T) { + assert.Equal(t, "tables", FeatureTables.Name()) + assert.Equal(t, "task lists", FeatureTaskLists.Name()) + assert.Equal(t, "strikethrough", FeatureStrikethrough.Name()) + assert.Equal(t, "bare-URL autolinks", FeatureBareURLAutolinks.Name()) + assert.Equal(t, "footnotes", FeatureFootnotes.Name()) + assert.Equal(t, "definition lists", FeatureDefinitionLists.Name()) + assert.Equal(t, "heading IDs", FeatureHeadingIDs.Name()) + assert.Equal(t, "superscript", FeatureSuperscript.Name()) + assert.Equal(t, "subscript", FeatureSubscript.Name()) + assert.Equal(t, "math blocks", FeatureMathBlock.Name()) + assert.Equal(t, "inline math", FeatureMathInline.Name()) + assert.Equal(t, "abbreviations", FeatureAbbreviations.Name()) +} diff --git a/internal/rules/markdownflavor/parser.go b/internal/rules/markdownflavor/parser.go new file mode 100644 index 000000000..603ec4394 --- /dev/null +++ b/internal/rules/markdownflavor/parser.go @@ -0,0 +1,67 @@ +package markdownflavor + +import ( + "sync" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/parser" + + "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rules/markdownflavor/ext" +) + +var ( + parserOnce sync.Once + parserMD goldmark.Markdown +) + +// Parser returns the shared goldmark parser used for dual parsing. +// It enables five built-in goldmark extensions (table, strikethrough, +// task list, footnote, and definition list) plus the heading-ID +// attribute parser — together covering six AST-detected features — +// and the five custom MDS034 extensions that cover superscript, +// subscript, math block, inline math, and abbreviations. The seventh +// feature that the rule tracks from the AST, bare-URL autolinks, is +// detected on the main CommonMark parse (see detectBareURLs). +// +// To keep MDS034 aligned with the rest of mdsmith, the dual parser +// also registers lint.PIBlockParserPrioritized so a +// block is treated as a processing-instruction +// node here — just as lint.NewFile does — rather than as an HTML +// block. Without this, a table fixture embedded inside a PI block +// would be flagged by MDS034 but invisible to every other rule. +// +// Linkify is intentionally not enabled: bare-URL autolinks are +// detected separately in detectBareURLs by scanning Text nodes from +// the main CommonMark parse, so adding Linkify would only duplicate +// work without changing the result. +// +// The parser is detection-only: we never render its output. Storing +// it as a package-level singleton avoids rebuilding the parser on +// every rule clone. +func Parser() goldmark.Markdown { + parserOnce.Do(func() { + parserMD = goldmark.New( + goldmark.WithExtensions( + extension.Table, + extension.Strikethrough, + extension.TaskList, + extension.Footnote, + extension.DefinitionList, + ext.Superscript, + ext.Subscript, + ext.MathBlock, + ext.MathInline, + ext.Abbreviation, + ), + goldmark.WithParserOptions( + parser.WithAttribute(), + parser.WithBlockParsers( + lint.PIBlockParserPrioritized(), + ), + ), + ) + }) + return parserMD +} diff --git a/internal/rules/markdownflavor/parser_test.go b/internal/rules/markdownflavor/parser_test.go new file mode 100644 index 000000000..ff77fd055 --- /dev/null +++ b/internal/rules/markdownflavor/parser_test.go @@ -0,0 +1,124 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/yuin/goldmark/ast" + extast "github.com/yuin/goldmark/extension/ast" + "github.com/yuin/goldmark/text" + + "github.com/jeduden/mdsmith/internal/lint" +) + +func TestParserCachesSingleInstance(t *testing.T) { + p1 := Parser() + p2 := Parser() + assert.Same(t, p1, p2, "Parser() must return the cached instance") +} + +func TestParserDetectsTables(t *testing.T) { + src := []byte("| a | b |\n| - | - |\n| 1 | 2 |\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindTable), + "expected table node in dual-parser AST") +} + +func TestParserDetectsStrikethrough(t *testing.T) { + src := []byte("hello ~~world~~\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindStrikethrough), + "expected strikethrough node in dual-parser AST") +} + +func TestParserDetectsTaskList(t *testing.T) { + src := []byte("- [ ] todo\n- [x] done\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindTaskCheckBox), + "expected task-list checkbox node in dual-parser AST") +} + +func TestParserDetectsFootnote(t *testing.T) { + src := []byte("A paragraph.[^1]\n\n[^1]: footnote body\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindFootnoteLink), + "expected footnote link node in dual-parser AST") +} + +func TestParserDetectsDefinitionList(t *testing.T) { + src := []byte("term\n: definition\n") + doc := parseSource(t, src) + assert.True(t, containsKind(doc, extast.KindDefinitionList), + "expected definition-list node in dual-parser AST") +} + +// TestParserRecognisesPIBlocks guards that the dual parser uses the +// same processing-instruction block parser as lint.NewFile so table +// / list markup embedded inside a block is not +// detected as real document markup by MDS034. +func TestParserRecognisesPIBlocks(t *testing.T) { + src := []byte("\n") + doc := parseSource(t, src) + found := false + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if n.Kind() == lint.KindProcessingInstruction { + found = true + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + assert.True(t, found, + "expected ProcessingInstruction node in dual-parser AST") +} + +func TestParserDetectsHeadingAttribute(t *testing.T) { + src := []byte("# Heading {#custom-id}\n") + doc := parseSource(t, src) + // The heading attribute parser stores {#id} as an attribute on the + // Heading node, not as a separate child. + found := false + _ = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if h, ok := n.(*ast.Heading); ok && h.Attributes() != nil { + if _, ok := h.AttributeString("id"); ok { + found = true + return ast.WalkStop, nil + } + } + return ast.WalkContinue, nil + }) + assert.True(t, found, "expected heading id attribute in dual-parser AST") +} + +// parseSource invokes Parser().Parse on the given source and returns +// the resulting document node. Helper shared by parser detection tests. +func parseSource(t *testing.T, src []byte) ast.Node { + t.Helper() + p := Parser() + doc := p.Parser().Parse(text.NewReader(src)) + require.NotNil(t, doc) + return doc +} + +// containsKind walks the tree rooted at root and reports whether any +// node has the given kind. +func containsKind(root ast.Node, kind ast.NodeKind) bool { + found := false + _ = ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { + if !entering { + return ast.WalkContinue, nil + } + if n.Kind() == kind { + found = true + return ast.WalkStop, nil + } + return ast.WalkContinue, nil + }) + return found +} diff --git a/internal/rules/markdownflavor/rule.go b/internal/rules/markdownflavor/rule.go new file mode 100644 index 000000000..7b0bb343f --- /dev/null +++ b/internal/rules/markdownflavor/rule.go @@ -0,0 +1,98 @@ +package markdownflavor + +import ( + "fmt" + + "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rule" +) + +func init() { + rule.Register(&Rule{}) +} + +// Rule implements MDS034, validating Markdown against a declared +// target flavor and flagging syntax the renderer will reject. +type Rule struct { + Flavor Flavor +} + +// ID implements rule.Rule. +func (r *Rule) ID() string { return "MDS034" } + +// Name implements rule.Rule. +func (r *Rule) Name() string { return "markdown-flavor" } + +// Category implements rule.Rule. +func (r *Rule) Category() string { return "meta" } + +// EnabledByDefault implements rule.Defaultable. MDS034 is opt-in. +func (r *Rule) EnabledByDefault() bool { return false } + +// ApplySettings implements rule.Configurable. +func (r *Rule) ApplySettings(settings map[string]any) error { + for k, v := range settings { + switch k { + case "flavor": + s, ok := v.(string) + if !ok { + return fmt.Errorf("markdown-flavor: flavor must be a string, got %T", v) + } + if s == "" { + r.Flavor = 0 + continue + } + fl, ok := ParseFlavor(s) + if !ok { + return fmt.Errorf( + "markdown-flavor: unknown flavor %q (expected one of: "+ + "any, commonmark, gfm, goldmark, multimarkdown, myst, pandoc, phpextra)", + s, + ) + } + r.Flavor = fl + default: + return fmt.Errorf("markdown-flavor: unknown setting %q", k) + } + } + return nil +} + +// DefaultSettings implements rule.Configurable. +func (r *Rule) DefaultSettings() map[string]any { + return map[string]any{ + "flavor": "", + } +} + +// Check implements rule.Rule. +func (r *Rule) Check(f *lint.File) []lint.Diagnostic { + if r.Flavor == 0 { + return nil + } + // Only ask detectors about features this flavor rejects. Detectors + // like the bare-URL regex scan then skip large files entirely when + // the flavor (gfm, goldmark) accepts them. + unsupported := func(feat Feature) bool { + return !r.Flavor.Supports(feat) + } + var diags []lint.Diagnostic + for _, found := range DetectFiltered(f, unsupported) { + diags = append(diags, lint.Diagnostic{ + File: f.Path, + Line: found.Line, + Column: found.Column, + RuleID: r.ID(), + RuleName: r.Name(), + Severity: lint.Warning, + Message: fmt.Sprintf("%s %s not supported by %s", + found.Feature.Name(), found.Feature.Verb(), r.Flavor), + }) + } + return diags +} + +var ( + _ rule.Configurable = (*Rule)(nil) + _ rule.Defaultable = (*Rule)(nil) +) diff --git a/internal/rules/markdownflavor/rule_test.go b/internal/rules/markdownflavor/rule_test.go new file mode 100644 index 000000000..f83df18d6 --- /dev/null +++ b/internal/rules/markdownflavor/rule_test.go @@ -0,0 +1,220 @@ +package markdownflavor + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/jeduden/mdsmith/internal/lint" + "github.com/jeduden/mdsmith/internal/rule" +) + +func TestRuleIdentity(t *testing.T) { + r := &Rule{} + assert.Equal(t, "MDS034", r.ID()) + assert.Equal(t, "markdown-flavor", r.Name()) + assert.Equal(t, "meta", r.Category()) +} + +func TestRuleIsConfigurableAndDefaultable(t *testing.T) { + var r rule.Rule = &Rule{} + _, ok := r.(rule.Configurable) + assert.True(t, ok, "Rule must implement rule.Configurable") + _, ok = r.(rule.Defaultable) + assert.True(t, ok, "Rule must implement rule.Defaultable") +} + +func TestRuleDisabledByDefault(t *testing.T) { + r := &Rule{} + assert.False(t, r.EnabledByDefault()) +} + +func TestRuleDefaultSettings(t *testing.T) { + r := &Rule{} + ds := r.DefaultSettings() + assert.Equal(t, "", ds["flavor"]) +} + +func TestRuleApplySettingsValid(t *testing.T) { + valid := []string{ + "commonmark", "gfm", "goldmark", + "any", "pandoc", "phpextra", "multimarkdown", "myst", + } + for _, name := range valid { + t.Run(name, func(t *testing.T) { + r := &Rule{} + err := r.ApplySettings(map[string]any{"flavor": name}) + require.NoError(t, err) + assert.Equal(t, name, r.Flavor.String()) + }) + } +} + +// TestRuleFlavorAnySilencesAllDiagnostics verifies that `flavor: any` +// never emits a diagnostic, regardless of what features the document +// uses. That matches the explicit "disable flavor reporting" contract +// promised in the doc. +func TestRuleFlavorAnySilencesAllDiagnostics(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "any"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n\nE = mc^2^ and H~2~O.\n\n" + + "$x+1$ inline and\n\n$$\nx\n$$\n\n" + + "*[API]: Application Programming Interface\n\nUse API here.\n" + diags := r.Check(mkFile(t, src)) + assert.Empty(t, diags, + "flavor: any must not flag any tracked feature") +} + +// TestRuleFlavorPHPExtra exercises the PHP Markdown Extra support +// set: footnotes and abbreviations are accepted, GFM features and +// math are not. +func TestRuleFlavorPHPExtra(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "phpextra"})) + + src := "*[API]: Application Programming Interface\n\n" + + "Use API here.[^1]\n\n[^1]: note\n\n~~strike~~ and $x+1$.\n" + diags := r.Check(mkFile(t, src)) + + byMsg := map[string]bool{} + for _, d := range diags { + byMsg[d.Message] = true + } + assert.False(t, byMsg["footnotes are not supported by phpextra"], + "phpextra accepts footnotes") + assert.False(t, byMsg["abbreviations are not supported by phpextra"], + "phpextra accepts abbreviations") + assert.True(t, byMsg["strikethrough is not supported by phpextra"], + "phpextra rejects strikethrough") + assert.True(t, byMsg["inline math is not supported by phpextra"], + "phpextra rejects inline math") +} + +func TestRuleApplySettingsInvalid(t *testing.T) { + tests := []struct { + name string + settings map[string]any + }{ + {"unknown key", map[string]any{"unknown": "x"}}, + {"bad flavor", map[string]any{"flavor": "markdown"}}, + {"non-string flavor", map[string]any{"flavor": 42}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + r := &Rule{} + err := r.ApplySettings(tc.settings) + assert.Error(t, err) + }) + } +} + +func TestRuleCheckNoFlavorConfigured(t *testing.T) { + // When the rule is enabled but no flavor is set (empty default), + // Check must be a no-op rather than flagging everything. + r := &Rule{} + require.NoError(t, r.ApplySettings(r.DefaultSettings())) + f := mkFile(t, "# Hi {#id}\n\n| a |\n| - |\n") + assert.Empty(t, r.Check(f)) +} + +func TestRuleCheckCommonMark(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + diags := r.Check(mkFile(t, src)) + + got := make(map[string]bool) + for _, d := range diags { + got[d.Message] = true + } + assert.True(t, got["heading IDs are not supported by commonmark"]) + assert.True(t, got["task lists are not supported by commonmark"]) + assert.True(t, got["tables are not supported by commonmark"]) + assert.True(t, got["strikethrough is not supported by commonmark"]) + assert.True(t, got["bare-URL autolinks are not supported by commonmark"]) +} + +func TestRuleCheckGFM(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + diags := r.Check(mkFile(t, src)) + + // GFM accepts tables, task lists, strikethrough, bare-URL autolinks. + for _, d := range diags { + assert.NotContains(t, d.Message, "tables") + assert.NotContains(t, d.Message, "task lists") + assert.NotContains(t, d.Message, "strikethrough") + assert.NotContains(t, d.Message, "bare-URL autolinks") + } + + // GFM rejects heading IDs. + found := false + for _, d := range diags { + if d.Message == "heading IDs are not supported by gfm" { + found = true + } + } + assert.True(t, found, "GFM should flag heading IDs") +} + +func TestRuleCheckGoldmark(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "goldmark"})) + + src := "# Head {#top}\n\n- [ ] task\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n" + + "~~old~~ https://example.com\n" + diags := r.Check(mkFile(t, src)) + + // goldmark accepts tables, task lists, strikethrough, bare URLs, + // AND heading IDs. The sample should produce no diagnostics. + for _, d := range diags { + t.Errorf("unexpected diagnostic for goldmark flavor: %s", d.Message) + } +} + +func TestRuleDiagnosticFields(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "commonmark"})) + f := mkFile(t, "| a | b |\n| - | - |\n| 1 | 2 |\n") + diags := r.Check(f) + require.Len(t, diags, 1) + d := diags[0] + assert.Equal(t, "MDS034", d.RuleID) + assert.Equal(t, "markdown-flavor", d.RuleName) + assert.Equal(t, lint.Warning, d.Severity) + assert.Equal(t, 1, d.Line) + assert.Equal(t, 1, d.Column) +} + +func TestRuleFootnotesDiagnostic(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + f := mkFile(t, "Text.[^1]\n\n[^1]: note body\n") + diags := r.Check(f) + require.NotEmpty(t, diags) + // First footnote-related diagnostic must name the feature. + found := false + for _, d := range diags { + if d.Message == "footnotes are not supported by gfm" { + found = true + } + } + assert.True(t, found) +} + +func TestRuleDefinitionListsDiagnostic(t *testing.T) { + r := &Rule{} + require.NoError(t, r.ApplySettings(map[string]any{"flavor": "gfm"})) + f := mkFile(t, "term\n: definition\n") + diags := r.Check(f) + require.Len(t, diags, 1) + assert.Equal(t, "definition lists are not supported by gfm", diags[0].Message) +} diff --git a/plan/86_markdown-flavor-validation.md b/plan/86_markdown-flavor-validation.md index 7acae9c01..de1c64e41 100644 --- a/plan/86_markdown-flavor-validation.md +++ b/plan/86_markdown-flavor-validation.md @@ -1,7 +1,7 @@ --- id: 86 title: Markdown flavor validation -status: "🔲" +status: "🔳" summary: >- New rule MDS034 that validates Markdown files against a declared flavor (CommonMark, GFM, Goldmark, etc.) @@ -164,63 +164,77 @@ math, abbreviations. ## Tasks -1. Add feature enum and flavor registry in - `internal/rules/markdownflavor/features.go` -2. Write `SuperscriptExt` inline parser in - `internal/rules/markdownflavor/ext/superscript.go` -3. Write `SubscriptExt` inline parser in - `internal/rules/markdownflavor/ext/subscript.go` -4. Write `MathBlockExt` block parser in - `internal/rules/markdownflavor/ext/mathblock.go` -5. Write `MathInlineExt` inline parser in - `internal/rules/markdownflavor/ext/mathinline.go` -6. Write `AbbreviationExt` block parser + paragraph - transformer in - `internal/rules/markdownflavor/ext/abbreviation.go` -7. Add tests for all five custom extensions -8. Build dual parser with built-in + custom extensions -9. Add AST-based detectors for all 12 features -10. Implement `rule.go` with `Check()` and `Fix()` -11. Implement `rule.Configurable` for MDS034: add - `ApplySettings` and `DefaultSettings` for `flavor` -12. Implement `rule.Defaultable` (`EnabledByDefault` - returns `false`) so the rule is opt-in -13. Register as MDS034 in category `meta` -14. Add test fixtures in - `internal/rules/MDS034-markdown-flavor/` -15. Add rule README and update docs +- [x] Add feature enum and flavor registry in + `internal/rules/markdownflavor/features.go` +- [x] Write `SuperscriptExt` inline parser in + `internal/rules/markdownflavor/ext/superscript.go` +- [x] Write `SubscriptExt` inline parser in + `internal/rules/markdownflavor/ext/subscript.go` +- [x] Write `MathBlockExt` block parser in + `internal/rules/markdownflavor/ext/mathblock.go` +- [x] Write `MathInlineExt` inline parser in + `internal/rules/markdownflavor/ext/mathinline.go` +- [x] Write `AbbreviationExt` block parser + paragraph + transformer in + `internal/rules/markdownflavor/ext/abbreviation.go` + +- [x] Add tests for all five custom extensions +- [x] Build dual parser with built-in + custom + extensions (superscript, subscript, math block, + math inline, abbreviations) +- [x] Add AST-based detectors for all 12 features. + Covered: tables, task lists, strikethrough, + bare-URL autolinks, footnotes, definition lists, + heading IDs, superscript, subscript, math blocks, + inline math, and abbreviations. +- [x] Implement `rule.go` with `Check()`; `Fix()` is + pending +- [x] Implement `rule.Configurable` for MDS034: add + `ApplySettings` and `DefaultSettings` for `flavor` +- [x] Implement `rule.Defaultable` (`EnabledByDefault` + returns `false`) so the rule is opt-in +- [x] Register as MDS034 in category `meta` +- [x] Add test fixtures in + `internal/rules/MDS034-markdown-flavor/` for the seven + built-in features +- [x] Add rule README and update docs ## Acceptance Criteria -- [ ] `flavor: commonmark` flags tables, task lists, +- [x] `flavor: commonmark` flags tables, task lists, strikethrough, bare-URL autolinks, footnotes, definition lists, heading IDs, superscript, subscript, math blocks, math inline, and abbreviations -- [ ] `flavor: gfm` accepts tables, task lists, +- [x] `flavor: gfm` accepts tables, task lists, strikethrough, and bare-URL autolinks; flags footnotes, definition lists, heading IDs, superscript, subscript, math blocks, math inline, and abbreviations -- [ ] `flavor: goldmark` accepts tables, task lists, +- [x] `flavor: goldmark` accepts tables, task lists, strikethrough, bare-URL autolinks, and heading IDs; flags footnotes, definition lists, superscript, subscript, math blocks, math inline, and abbreviations -- [ ] Error messages name the unsupported feature and +- [x] Error messages name the unsupported feature and the configured flavor - [ ] `mdsmith fix` auto-fixes fixable features -- [ ] Non-fixable features produce diagnostics only -- [ ] Invalid flavor name produces a config error -- [ ] Rule is disabled by default (opt-in) -- [ ] With `flavor: commonmark`, MDS034 reports bare + +- [x] Non-fixable features produce diagnostics only +- [x] Invalid flavor name produces a config error +- [x] Rule is disabled by default (opt-in) +- [x] With `flavor: commonmark`, MDS034 reports bare URLs as unsupported autolinks -- [ ] With `flavor: gfm` or `flavor: goldmark`, +- [x] With `flavor: gfm` or `flavor: goldmark`, MDS034 treats bare URLs as supported syntax and does not emit a flavor diagnostic for them -- [ ] MDS034 does not emit a duplicate bare-URL +- [x] MDS034 does not emit a duplicate bare-URL diagnostic when the configured flavor supports bare URLs, even if MDS012 still enforces its own bare-URL style rule -- [ ] All tests pass: `go test ./...` -- [ ] `go tool golangci-lint run` reports no issues +- [x] All tests pass: `go test ./...` +- [x] `go tool golangci-lint run` reports no issues