Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/mdsmith
eval/corpus/config.local.yml
coverage.out
cover.out
2 changes: 1 addition & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ footer: |
| 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) |
| 87 | 🔲 | [Flavor validation for GitHub Alerts](plan/87_markdown-flavor-github-alerts.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) |
<?/catalog?>
12 changes: 10 additions & 2 deletions internal/rules/MDS034-markdown-flavor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ flavor does not render.
- **Name**: `markdown-flavor`
- **Status**: ready
- **Default**: disabled
- **Fixable**: no (fix pipeline lands in a follow-up)
- **Fixable**: partially (GitHub Alerts only)
- **Implementation**:
[source](./)
- **Category**: meta
Expand Down Expand Up @@ -73,7 +73,7 @@ rules:

## Detected features

MDS034 tracks twelve syntax features whose
MDS034 tracks thirteen syntax features whose
support varies across Markdown flavors.

Eleven features are detected from the goldmark AST
Expand All @@ -89,6 +89,13 @@ detector scans text nodes from the main parse for
URL-shaped text. It skips links, autolinks, code
spans, and code blocks.

GitHub Alerts are detected from the CommonMark
AST. The detector matches the five GFM tokens
(`NOTE`, `TIP`, `IMPORTANT`, `WARNING`,
`CAUTION`) on the first line of a blockquote
paragraph. Matching is case-sensitive per the
GFM spec.

`flavor: any` accepts every feature and is omitted
from the table below.

Expand All @@ -106,6 +113,7 @@ from the table below.
| 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 |
| github alerts | no | yes | no | no | no | no | no |

## Examples

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
settings:
flavor: commonmark
diagnostics:
- line: 3
column: 1
message: "github alerts are not supported by commonmark"
---
# Alerts

> [!NOTE]
> Something to remember.
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
settings:
flavor: goldmark
diagnostics:
- line: 3
column: 1
message: "github alerts are not supported by goldmark"
---
# Alerts

> [!WARNING]
> Something to remember.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Alerts

> Something to remember.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Alerts

> Something to remember.
41 changes: 41 additions & 0 deletions internal/rules/markdownflavor/detect.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package markdownflavor

import (
"bytes"
"regexp"
"sort"

Expand Down Expand Up @@ -45,6 +46,10 @@ type HeadingIDExtra struct {
AttrEnd int // byte offset one past '}'
}

// alertTokenRe matches the exact content of a GitHub Alert marker line
// inside a blockquote (case-sensitive per GFM spec).
var alertTokenRe = regexp.MustCompile(`^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$`)

// 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
Expand Down Expand Up @@ -92,6 +97,10 @@ func DetectFiltered(f *lint.File, accept func(Feature) bool) []Finding {
out = append(out, detectBareURLs(f)...)
}

if keep(FeatureGitHubAlerts) {
out = append(out, detectGitHubAlerts(f)...)
}

sort.SliceStable(out, func(i, j int) bool {
return out[i].Start < out[j].Start
})
Expand Down Expand Up @@ -488,3 +497,35 @@ func insideNonBareContext(n ast.Node) bool {
}
return false
}

// detectGitHubAlerts walks f.AST for Blockquote nodes whose first paragraph
// child starts with a GFM alert token (e.g. [!NOTE]).
func detectGitHubAlerts(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
}
bq, ok := n.(*ast.Blockquote)
if !ok {
return ast.WalkContinue, nil
}
if isGitHubAlert(bq, f.Source) {
findings = append(findings, blockFinding(f, bq, FeatureGitHubAlerts))
}
return ast.WalkContinue, nil
})
return findings
}

// isGitHubAlert reports whether bq is a GitHub Alert blockquote: its first
// paragraph child's first line matches one of the five GFM alert tokens.
func isGitHubAlert(bq *ast.Blockquote, source []byte) bool {
para, ok := bq.FirstChild().(*ast.Paragraph)
if !ok {
return false
}
seg := para.Lines().At(0)
firstLine := bytes.TrimRight(source[seg.Start:seg.Stop], "\r\n")
return alertTokenRe.Match(firstLine)
}
30 changes: 30 additions & 0 deletions internal/rules/markdownflavor/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,3 +231,33 @@ func TestDetectFindingsAreSortedByStart(t *testing.T) {
i-1, fs[i-1], i, fs[i])
}
}

func TestDetectGitHubAlerts(t *testing.T) {
tokens := []string{"NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"}
for _, tok := range tokens {
t.Run(tok, func(t *testing.T) {
fs := findings(t, "> [!"+tok+"]\n> Something.\n")
assert.True(t, hasFeature(fs, FeatureGitHubAlerts))
})
}
}

func TestDetectGitHubAlertsLowercaseNoMatch(t *testing.T) {
for _, src := range []string{
"> [!note]\n> text.\n",
"> [!INFO]\n> text.\n",
} {
fs := findings(t, src)
assert.False(t, hasFeature(fs, FeatureGitHubAlerts), "should not match: %q", src)
}
}

func TestDetectGitHubAlertsMixedContent(t *testing.T) {
fs := findings(t, "> [!NOTE]\n> Line one.\n> Line two.\n")
assert.True(t, hasFeature(fs, FeatureGitHubAlerts))
}

func TestDetectGitHubAlertsOnlyLine(t *testing.T) {
fs := findings(t, "> [!WARNING]\n")
assert.True(t, hasFeature(fs, FeatureGitHubAlerts))
}
7 changes: 7 additions & 0 deletions internal/rules/markdownflavor/features.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const (
FeatureMathBlock
FeatureMathInline
FeatureAbbreviations
FeatureGitHubAlerts
)

// AllFeatures returns every tracked feature in declaration order.
Expand All @@ -125,6 +126,7 @@ func AllFeatures() []Feature {
FeatureMathBlock,
FeatureMathInline,
FeatureAbbreviations,
FeatureGitHubAlerts,
}
}

Expand All @@ -136,6 +138,8 @@ func (f Feature) Verb() string {
case FeatureStrikethrough, FeatureSuperscript, FeatureSubscript,
FeatureMathInline:
return "is"
case FeatureGitHubAlerts:
return "are"
}
return "are"
}
Expand Down Expand Up @@ -167,6 +171,8 @@ func (f Feature) Name() string {
return "inline math"
case FeatureAbbreviations:
return "abbreviations"
case FeatureGitHubAlerts:
return "github alerts"
}
return ""
}
Expand All @@ -183,6 +189,7 @@ var support = map[Flavor]map[Feature]bool{
FeatureTaskLists: true,
FeatureStrikethrough: true,
FeatureBareURLAutolinks: true,
FeatureGitHubAlerts: true,
},
FlavorGoldmark: {
FeatureTables: true,
Expand Down
7 changes: 4 additions & 3 deletions internal/rules/markdownflavor/features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestFeatureSupportCommonMark(t *testing.T) {
func TestFeatureSupportGFM(t *testing.T) {
assertSupports(t, FlavorGFM,
FeatureTables, FeatureTaskLists, FeatureStrikethrough,
FeatureBareURLAutolinks)
FeatureBareURLAutolinks, FeatureGitHubAlerts)
}

func TestFeatureSupportGoldmark(t *testing.T) {
Expand Down Expand Up @@ -122,8 +122,8 @@ func TestFeatureSupportMyST(t *testing.T) {
}

func TestAllFeaturesComplete(t *testing.T) {
// Ensure AllFeatures enumerates exactly the 12 features we track.
require.Len(t, AllFeatures(), 12)
// Ensure AllFeatures enumerates exactly the 13 features we track.
require.Len(t, AllFeatures(), 13)
}

func TestFeatureName(t *testing.T) {
Expand All @@ -139,4 +139,5 @@ func TestFeatureName(t *testing.T) {
assert.Equal(t, "math blocks", FeatureMathBlock.Name())
assert.Equal(t, "inline math", FeatureMathInline.Name())
assert.Equal(t, "abbreviations", FeatureAbbreviations.Name())
assert.Equal(t, "github alerts", FeatureGitHubAlerts.Name())
}
70 changes: 68 additions & 2 deletions internal/rules/markdownflavor/rule.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package markdownflavor

import (
"fmt"
"strings"

"github.com/yuin/goldmark/ast"

"github.com/jeduden/mdsmith/internal/lint"
"github.com/jeduden/mdsmith/internal/rule"
Expand Down Expand Up @@ -39,7 +42,7 @@ func (r *Rule) ApplySettings(settings map[string]any) error {
return fmt.Errorf("markdown-flavor: flavor must be a string, got %T", v)
}
if s == "" {
r.Flavor = 0
r.Flavor = flavorInvalid
continue
}
fl, ok := ParseFlavor(s)
Expand Down Expand Up @@ -67,7 +70,7 @@ func (r *Rule) DefaultSettings() map[string]any {

// Check implements rule.Rule.
func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
if r.Flavor == 0 {
if r.Flavor == flavorInvalid {
return nil
}
// Only ask detectors about features this flavor rejects. Detectors
Expand All @@ -92,7 +95,70 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
return diags
}

// Fix implements rule.FixableRule. It removes the [!TOKEN] marker line from
// GitHub Alert blockquotes when the configured flavor does not support them.
// If the marker is the only line in the blockquote, the whole blockquote is
// removed.
func (r *Rule) Fix(f *lint.File) []byte {
if r.Flavor == flavorInvalid || r.Flavor.Supports(FeatureGitHubAlerts) {
return f.Source
}

skip := map[int]bool{}
addPrefix := map[int]bool{} // lazy-continuation lines that lose blockquote context
_ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
bq, ok := n.(*ast.Blockquote)
if !ok {
return ast.WalkContinue, nil
}
if !isGitHubAlert(bq, f.Source) {
return ast.WalkContinue, nil
}
para := bq.FirstChild().(*ast.Paragraph)
lines := para.Lines()
seg := lines.At(0)
markerLine, _ := lineCol(f.Source, seg.Start)
skip[markerLine] = true

// Remaining lines of the first paragraph may use lazy continuation
// (no "> " prefix in the raw source). After removing the marker they
// would no longer be inside a blockquote, so re-add the prefix.
for i := 1; i < lines.Len(); i++ {
contSeg := lines.At(i)
contLine, _ := lineCol(f.Source, contSeg.Start)
raw := strings.TrimLeft(string(f.Lines[contLine-1]), " \t")
if !strings.HasPrefix(raw, ">") {
addPrefix[contLine] = true
}
}
return ast.WalkContinue, nil
})

if len(skip) == 0 {
return f.Source
}

var out []string
for i, line := range f.Lines {
lineNum := i + 1
if skip[lineNum] {
continue
Comment thread
jeduden marked this conversation as resolved.
}
s := string(line)
if addPrefix[lineNum] {
trimmed := strings.TrimLeft(s, " \t")
s = s[:len(s)-len(trimmed)] + "> " + trimmed
}
Comment thread
jeduden marked this conversation as resolved.
out = append(out, s)
}
return []byte(strings.Join(out, "\n"))
Comment thread
jeduden marked this conversation as resolved.
}

var (
_ rule.Configurable = (*Rule)(nil)
_ rule.Defaultable = (*Rule)(nil)
_ rule.FixableRule = (*Rule)(nil)
)
Loading
Loading