-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrule.go
More file actions
317 lines (294 loc) · 11.1 KB
/
Copy pathrule.go
File metadata and controls
317 lines (294 loc) · 11.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
// 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. The Flavor identity, the
// Feature support model, the dual-parser configuration, and the
// public detection entry point all live in pkg/markdown/flavor; this
// package is the rule adapter that maps config and convention into a
// flavor.Detect call and then maps flavor.Finding into engine
// diagnostics and fix bytes.
package markdownflavor
import (
"bytes"
"fmt"
"sort"
"strings"
"github.com/jeduden/mdsmith/pkg/goldmark/ast"
"github.com/jeduden/mdsmith/internal/convention"
"github.com/jeduden/mdsmith/internal/lint"
"github.com/jeduden/mdsmith/internal/rule"
"github.com/jeduden/mdsmith/pkg/markdown"
"github.com/jeduden/mdsmith/pkg/markdown/flavor"
)
func init() {
rule.Register(&Rule{})
}
// Rule implements MDS034, validating Markdown against a declared
// target flavor and flagging syntax the renderer does not interpret
// as a feature. The rule reads only the flavor; project-level
// convention selection (which can preset this rule's flavor and
// other rules' settings) is handled at config load — see
// internal/config/convention.go.
type Rule struct {
Flavor convention.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 "structural" }
// EnabledByDefault implements rule.Defaultable. MDS034 is opt-in.
func (r *Rule) EnabledByDefault() bool { return false }
// ApplySettings implements rule.Configurable. Keys are processed in
// sorted order so the error reported for multiple unknown settings is
// deterministic across runs (Go's map iteration order is randomised,
// which would otherwise produce flaky fixture goldens).
func (r *Rule) ApplySettings(settings map[string]any) error {
keys := make([]string, 0, len(settings))
for k := range settings {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
v := settings[k]
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 = convention.Flavor(0)
continue
}
fl, ok := convention.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. It runs flavor.Detect with an accept
// predicate that admits only features the configured flavor rejects,
// then maps each resulting Finding into one engine diagnostic.
//
// On the parse-skipped path (f.AST nil) the two AST-walking detectors inside
// flavor.Detect — bare URLs and GitHub alerts — surface nothing, so the rule
// supplies their findings from the Layer 0 projections instead: bare URLs from
// the shared run-grouped inline parse (lint.InlineBlocks) and alert
// blockquotes from the Layer 0 BlockQuote spans. The dual-parser features
// detect from the source body and need no AST either way. The union is sorted
// by byte offset, matching flavor.Detect's own ordering, so the diagnostics
// are byte-identical to the parsed path.
func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
if !r.Flavor.IsValid() {
return nil
}
unsupported := func(feat flavor.Feature) bool {
return !flavor.Supports(r.Flavor, feat)
}
doc := &markdown.Document{Body: f.Source, AST: f.AST}
findings := flavor.Detect(doc, unsupported)
if f.AST == nil {
findings = r.appendLayerFindings(f, unsupported, findings)
}
if len(findings) == 0 {
return nil
}
diags := make([]lint.Diagnostic, 0, len(findings))
for _, found := range findings {
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 does not interpret %s as a feature",
r.Flavor, found.Feature.Name()),
})
}
return diags
}
// appendLayerFindings adds the bare-URL and GitHub-alert findings the AST-less
// flavor.Detect could not produce, then re-sorts the union by byte offset to
// match flavor.Detect's document-order output. base is the only path-specific
// input: bare URLs come from each inline run's re-parse (mapped back with the
// run base), alerts from the Layer 0 BlockQuote spans' first marker line. Each
// detector runs only when the configured flavor rejects its feature, mirroring
// flavor.Detect's accept gating.
func (r *Rule) appendLayerFindings(
f *lint.File, accept func(flavor.Feature) bool, findings []flavor.Finding,
) []flavor.Finding {
if accept(flavor.FeatureBareURLAutolinks) {
for _, blk := range lint.InlineBlocks(f) {
findings = append(findings,
flavor.BareURLFindingsInTree(f.Source, blk.Node, blk.Offset)...)
}
}
if accept(flavor.FeatureGitHubAlerts) {
findings = append(findings, alertFindingsFromSpans(f)...)
}
sort.SliceStable(findings, func(i, j int) bool {
return findings[i].Start < findings[j].Start
})
return findings
}
// alertFindingsFromSpans returns one GitHub-alert finding per Layer 0
// BlockQuote span whose first paragraph opens with a GFM alert marker
// (`> [!TOKEN]`). It is the parse-skipped counterpart to
// flavor.detectGitHubAlerts, which anchors on the blockquote's first paragraph
// first line — not its first physical line. A blockquote can open with one or
// more blank `>` lines before that paragraph, so the scan skips leading
// blank-content quote lines and tests the first non-blank one, matching the AST
// detector's anchor (the marker line, column 1). A blockquote always carries a
// `>`, so the parse-skip gate keeps such files on the parse path in production;
// the audit drives this branch directly.
func alertFindingsFromSpans(f *lint.File) []flavor.Finding {
var out []flavor.Finding
for _, span := range lint.Layer0(f).BlockSpans {
if span.Kind != lint.BlockQuote {
continue
}
ln, ok := firstQuoteParagraphLine(f, span)
if !ok {
continue
}
if flavor.IsAlertMarkerLine(f.Lines[ln-1]) {
out = append(out, flavor.AlertFinding(f.Source, f.LineStartOffset(ln-1)))
}
}
return out
}
// firstQuoteParagraphLine returns the 1-based line of the first paragraph line
// inside a Layer 0 BlockQuote span: the first line in the span whose content,
// after the `>` markers are stripped, is non-blank. Leading blank quote lines
// (`>` with nothing after it) do not open a paragraph, so they are skipped —
// mirroring where goldmark records the blockquote's first paragraph. Returns
// false when the span carries no non-blank quoted line.
func firstQuoteParagraphLine(f *lint.File, span lint.BlockSpan) (int, bool) {
for ln := span.Start; ln <= span.End; ln++ {
if len(bytes.TrimSpace(flavor.StripBlockquoteMarkers(f.Lines[ln-1]))) > 0 {
return ln, true
}
}
return 0, false
}
// Fix implements rule.FixableRule. It first removes the [!TOKEN]
// marker line from GitHub Alert blockquotes (line-level edit, with
// lazy-continuation handling), then runs the byte-range fix pipeline
// over the result for heading IDs, strikethrough, task lists,
// superscript, subscript, and bare-URL autolinks. Each feature is
// fixed only when the configured flavor does not support it. When
// alerts are stripped the byte-range pass re-parses the rewritten
// source so AST offsets match the new bytes.
func (r *Rule) Fix(f *lint.File) []byte {
if !r.Flavor.IsValid() {
return f.Source
}
current := f
if !flavor.Supports(r.Flavor, flavor.FeatureGitHubAlerts) {
stripped := r.fixGitHubAlerts(f)
if !bytes.Equal(stripped, f.Source) {
reparsed, err := lint.NewFile(f.Path, stripped)
if err != nil {
return stripped
}
current = reparsed
}
}
return r.fixByteRangeFeatures(current)
}
// buildAlertSkipMaps walks the AST of f and returns two zero-byte-value
// sets: skip holds the 1-based line numbers of GitHub Alert marker lines
// that should be dropped, and addPrefix holds the line numbers of lazy-
// continuation lines that need a "> " prefix re-added after the marker
// is removed. Using map[int]struct{} rather than map[int]bool follows the
// high-performance Go guideline "map[K]struct{} for sets — zero-byte value type."
func buildAlertSkipMaps(f *lint.File) (skip, addPrefix map[int]struct{}) {
skip = map[int]struct{}{}
addPrefix = map[int]struct{}{} // 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 !flavor.IsGitHubAlert(bq, f.Source) {
return ast.WalkContinue, nil
}
// flavor.IsGitHubAlert is the only authority on whether the
// (Paragraph, non-empty Lines) invariants hold; if it returns
// true the assertion + At(0) below cannot panic. A defensive
// local re-check would only mask a future contract break by
// silently skipping the fix while Check still flagged the
// alert — worse than a clear panic. The contract is locked by
// the rule's existing fix tests.
para := bq.FirstChild().(*ast.Paragraph)
lines := para.Lines()
seg := lines.At(0)
markerLine, _ := flavor.LineCol(f.Source, seg.Start)
skip[markerLine] = struct{}{}
// 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, _ := flavor.LineCol(f.Source, contSeg.Start)
raw := strings.TrimLeft(string(f.Lines[contLine-1]), " \t")
if !strings.HasPrefix(raw, ">") {
addPrefix[contLine] = struct{}{}
}
}
return ast.WalkContinue, nil
})
return
}
// fixGitHubAlerts strips [!TOKEN] alert markers from blockquotes,
// re-adding "> " on lazy-continuation lines so the blockquote stays
// well-formed after the marker line goes away. If the marker is the
// only line in the blockquote, the whole blockquote is removed.
func (r *Rule) fixGitHubAlerts(f *lint.File) []byte {
skip, addPrefix := buildAlertSkipMaps(f)
if len(skip) == 0 {
return f.Source
}
var out []string
for i, line := range f.Lines {
lineNum := i + 1
if _, ok := skip[lineNum]; ok {
continue
}
s := string(line)
if _, ok := addPrefix[lineNum]; ok {
trimmed := strings.TrimLeft(s, " \t")
s = s[:len(s)-len(trimmed)] + "> " + trimmed
}
out = append(out, s)
}
return []byte(strings.Join(out, "\n"))
}
var (
_ rule.Configurable = (*Rule)(nil)
_ rule.Defaultable = (*Rule)(nil)
_ rule.FixableRule = (*Rule)(nil)
)
// FixTitle implements rule.QuickFixTitler.
func (r *Rule) FixTitle() string { return "Replace syntax the flavor can't render" }