Skip to content

Commit a7e8d73

Browse files
committed
Fix MDS068 reference-style image misclassification (MD054)
checkLinkImageStyle routed every *ast.Image through the inline-image toggle, ignoring node.Reference. Reference-style images (![alt][label], ![alt][], ![alt]) were therefore flagged by inline-image=false (wrong: they are not inline images) and ignored by full/collapsed/shortcut=false (wrong: those are their actual sub-forms) — contradicting the README and struct comment, which scope inline-image to ![alt](src) only. The fork's ast.Image embeds the same baseLink.Reference as ast.Link, so images now route through the shared reference classifier: inline images use inline-image; reference images use full/collapsed/shortcut, matching MD054. Also in autolinkPosition, match the literal `<url>` including the closing `>` so a short autolink whose URL prefixes a neighbour's (`<a.com>` beside `<a.com/x>`) resolves to its own column instead of the longer one, and correct a comment that wrongly claimed email autolinks are mailto:-prefixed (this fork returns the bare address). Tests: reference-style image routing per sub-form, the regression guard that inline-image=false leaves reference images alone, the autolink prefix-collision column, and a deep-merge guard proving the link-image-style sub-map merges across config layers. https://claude.ai/code/session_015t5KNNUG7YjqTVr9ERXVyZ
1 parent c310ea6 commit a7e8d73

3 files changed

Lines changed: 166 additions & 46 deletions

File tree

internal/rules/MDS068-link-style/README.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ and `validate-reference-style` flow to MDS027, while `style` and
4242
| `links.style.link-image-style` | map | absent | MD054 per-style toggles; absent means the axis is inactive (see below) |
4343
| `links.style.link-image-style.autolink` | bool | `true` | Allow or forbid `<https://x>` autolinks |
4444
| `links.style.link-image-style.inline` | bool | `true` | Allow or forbid `[t](u)` inline links |
45-
| `links.style.link-image-style.full` | bool | `true` | Allow or forbid `[t][label]` full reference links |
46-
| `links.style.link-image-style.collapsed` | bool | `true` | Allow or forbid `[t][]` collapsed reference links |
47-
| `links.style.link-image-style.shortcut` | bool | `true` | Allow or forbid `[t]` shortcut reference links |
45+
| `links.style.link-image-style.full` | bool | `true` | Allow or forbid `[t][label]` / `![alt][label]` full reference links and images |
46+
| `links.style.link-image-style.collapsed` | bool | `true` | Allow or forbid `[t][]` / `![alt][]` collapsed reference links and images |
47+
| `links.style.link-image-style.shortcut` | bool | `true` | Allow or forbid `[t]` / `![alt]` shortcut reference links and images |
4848
| `links.style.link-image-style.inline-image` | bool | `true` | Allow or forbid `![alt](src)` inline images |
4949
| `links.external-skip` | list | `[]` | Regex patterns reserved for the future external-link-check rule (issue #47); parsed here so users can declare it once per kind |
5050

@@ -63,8 +63,11 @@ forced to migrate to `link-image-style`.
6363

6464
External URLs (`http:`, `https:`, `mailto:`), local-anchor-only
6565
references are excluded from the `path`, `extension`, and `form`
66-
axes. The `link-image-style` axis checks `<url>` autolinks and inline
67-
images in addition to text links. The `path` and `form` axes apply to
66+
axes. The `link-image-style` axis checks `<url>` autolinks, inline and
67+
reference-style links, and inline and reference-style images. The
68+
reference sub-form toggles (`full`, `collapsed`, `shortcut`) apply to
69+
both links and images; `inline-image` governs only `![alt](src)`. The
70+
`path` and `form` axes apply to
6871
every local text link, including non-Markdown targets like `theme.css`.
6972
The `extension` axis is the only Markdown-shaped axis, described below.
7073

internal/rules/linkstyle/rule.go

Lines changed: 45 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,17 @@ func (r *Rule) checkLinkImageStyle(f *lint.File) []lint.Diagnostic {
160160
}
161161
lis := r.Links.Style.LinkImageStyle
162162
var diags []lint.Diagnostic
163+
add := func(line, col int, msg string) {
164+
diags = append(diags, lint.Diagnostic{
165+
File: f.Path,
166+
Line: line,
167+
Column: col,
168+
RuleID: r.ID(),
169+
RuleName: r.Name(),
170+
Severity: lint.Warning,
171+
Message: msg,
172+
})
173+
}
163174
_ = ast.Walk(f.AST, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
164175
if !entering {
165176
return ast.WalkContinue, nil
@@ -168,60 +179,49 @@ func (r *Rule) checkLinkImageStyle(f *lint.File) []lint.Diagnostic {
168179
case *ast.AutoLink:
169180
if !lis.Autolink {
170181
line, col := autolinkPosition(f, node)
171-
diags = append(diags, lint.Diagnostic{
172-
File: f.Path,
173-
Line: line,
174-
Column: col,
175-
RuleID: r.ID(),
176-
RuleName: r.Name(),
177-
Severity: lint.Warning,
178-
Message: msgLISAutolink,
179-
})
182+
add(line, col, msgLISAutolink)
180183
}
181184
case *ast.Link:
182-
msg := linkImageStyleMsg(lis, node)
183-
if msg != "" {
185+
if msg := linkImageStyleMsg(lis, node.Reference, false); msg != "" {
184186
line, col := linkNodePosition(f, node)
185-
diags = append(diags, lint.Diagnostic{
186-
File: f.Path,
187-
Line: line,
188-
Column: col,
189-
RuleID: r.ID(),
190-
RuleName: r.Name(),
191-
Severity: lint.Warning,
192-
Message: msg,
193-
})
187+
add(line, col, msg)
194188
}
195189
case *ast.Image:
196-
if !lis.InlineImage {
190+
// Images carry the same Reference sub-form as links, so a
191+
// reference-style image (![alt][label], ![alt][], ![alt])
192+
// is checked against full/collapsed/shortcut; only an
193+
// inline image (![alt](src)) uses the inline-image toggle.
194+
if msg := linkImageStyleMsg(lis, node.Reference, true); msg != "" {
197195
line, col := linkNodePosition(f, node)
198-
diags = append(diags, lint.Diagnostic{
199-
File: f.Path,
200-
Line: line,
201-
Column: col,
202-
RuleID: r.ID(),
203-
RuleName: r.Name(),
204-
Severity: lint.Warning,
205-
Message: msgLISInlineImage,
206-
})
196+
add(line, col, msg)
207197
}
208198
}
209199
return ast.WalkContinue, nil
210200
})
211201
return diags
212202
}
213203

214-
// linkImageStyleMsg returns the forbidden-style message for a Link
215-
// node, or "" if the node's form is allowed.
216-
func linkImageStyleMsg(lis LinkImageStyleConfig, l *ast.Link) string {
217-
if l.Reference == nil {
218-
// Inline link: [text](url)
204+
// linkImageStyleMsg returns the forbidden-style message for a link or
205+
// image node given its reference (nil for the inline form), or "" if
206+
// the form is allowed. isImage selects the inline-image vs inline
207+
// message for the non-reference case; the three reference sub-forms
208+
// (full/collapsed/shortcut) share their messages across links and
209+
// images, matching MD054.
210+
func linkImageStyleMsg(lis LinkImageStyleConfig, ref *ast.ReferenceLink, isImage bool) string {
211+
if ref == nil {
212+
// Inline form: [text](url) or ![alt](src).
213+
if isImage {
214+
if !lis.InlineImage {
215+
return msgLISInlineImage
216+
}
217+
return ""
218+
}
219219
if !lis.Inline {
220220
return msgLISInline
221221
}
222222
return ""
223223
}
224-
switch l.Reference.Type {
224+
switch ref.Type {
225225
case ast.ReferenceLinkFull:
226226
if !lis.Full {
227227
return msgLISFull
@@ -250,18 +250,22 @@ func autolinkPosition(f *lint.File, n *ast.AutoLink) (int, int) {
250250
if len(url) == 0 {
251251
return 1, 1
252252
}
253-
pat := append([]byte{'<'}, url...)
253+
// Match the literal `<url>` including the closing `>`, so a short
254+
// autolink whose URL is a prefix of a neighbour's (e.g. `<a.com>`
255+
// beside `<a.com/x>` on one line) does not match the longer one.
256+
pat := make([]byte, 0, len(url)+2)
257+
pat = append(pat, '<')
258+
pat = append(pat, url...)
259+
pat = append(pat, '>')
254260
// Walk up to the nearest block ancestor for a source range to
255261
// search. Lines() panics on inline nodes, so skip any ancestor
256262
// that is not a block (emphasis, link text, the document root).
257263
for p := n.Parent(); p != nil; p = p.Parent() {
258264
if p.Type() != ast.TypeBlock {
259265
continue
260266
}
261-
// Search each source line for `<` followed by the URL. A block
262-
// with no lines, or a URL that does not appear verbatim (e.g.
263-
// an email autolink whose URL() is mailto:-prefixed), falls
264-
// through to the (1,1) fallback below.
267+
// Search each source line for the literal `<url>`. If no block
268+
// line contains it, fall through to the (1,1) fallback below.
265269
lines := p.Lines()
266270
for i := range lines.Len() {
267271
seg := lines.At(i)

internal/rules/linkstyle/rule_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,119 @@ func TestAutolinkPosition_NotFoundFallsBack(t *testing.T) {
795795
assert.Equal(t, 1, col)
796796
}
797797

798+
// TestCheck_LinkImageStyle_ReferenceImagesUseReferenceToggles verifies
799+
// that reference-style images route to the full/collapsed/shortcut
800+
// toggles (shared with links), not the inline-image toggle.
801+
func TestCheck_LinkImageStyle_ReferenceImagesUseReferenceToggles(t *testing.T) {
802+
cases := []struct {
803+
name string
804+
src string
805+
lis LinkImageStyleConfig
806+
wantMsg string
807+
}{
808+
{
809+
"full reference image forbidden by full",
810+
"# Doc\n\n![alt][label]\n\n[label]: img.png\n",
811+
LinkImageStyleConfig{Active: true, Autolink: true, Inline: true,
812+
Full: false, Collapsed: true, Shortcut: true, InlineImage: true},
813+
"full",
814+
},
815+
{
816+
"collapsed reference image forbidden by collapsed",
817+
"# Doc\n\n![label][]\n\n[label]: img.png\n",
818+
LinkImageStyleConfig{Active: true, Autolink: true, Inline: true,
819+
Full: true, Collapsed: false, Shortcut: true, InlineImage: true},
820+
"collapsed",
821+
},
822+
{
823+
"shortcut reference image forbidden by shortcut",
824+
"# Doc\n\n![label]\n\n[label]: img.png\n",
825+
LinkImageStyleConfig{Active: true, Autolink: true, Inline: true,
826+
Full: true, Collapsed: true, Shortcut: false, InlineImage: true},
827+
"shortcut",
828+
},
829+
}
830+
for _, tc := range cases {
831+
t.Run(tc.name, func(t *testing.T) {
832+
f := newFile(t, tc.src)
833+
r := &Rule{Links: LinksConfig{Style: StyleConfig{LinkImageStyle: tc.lis}}}
834+
diags := r.Check(f)
835+
require.Len(t, diags, 1)
836+
assert.Contains(t, diags[0].Message, tc.wantMsg)
837+
})
838+
}
839+
}
840+
841+
// TestCheck_LinkImageStyle_ReferenceImageIgnoresInlineImageToggle is the
842+
// regression guard for the bug where every *ast.Image used the
843+
// inline-image toggle: a reference-style image must NOT be flagged by
844+
// inline-image:false, which governs only inline ![alt](src) images.
845+
func TestCheck_LinkImageStyle_ReferenceImageIgnoresInlineImageToggle(t *testing.T) {
846+
src := "# Doc\n\n![alt][label]\n\n[label]: img.png\n"
847+
f := newFile(t, src)
848+
r := &Rule{Links: LinksConfig{Style: StyleConfig{
849+
LinkImageStyle: LinkImageStyleConfig{
850+
Active: true, Autolink: true, Inline: true, Full: true, Collapsed: true, Shortcut: true,
851+
InlineImage: false,
852+
},
853+
}}}
854+
assert.Empty(t, r.Check(f), "reference-style image must not be flagged by inline-image:false")
855+
}
856+
857+
// TestCheck_LinkImageStyle_AutolinkPositionDistinguishesPrefixURLs
858+
// verifies the search pattern includes the closing `>` so a short
859+
// autolink whose URL prefixes a neighbour's resolves to its own column.
860+
func TestCheck_LinkImageStyle_AutolinkPositionDistinguishesPrefixURLs(t *testing.T) {
861+
src := "# Doc\n\n<https://a.com/x> and <https://a.com>\n"
862+
f := newFile(t, src)
863+
r := &Rule{Links: LinksConfig{Style: StyleConfig{
864+
LinkImageStyle: LinkImageStyleConfig{Active: true, Autolink: false,
865+
Inline: true, Full: true, Collapsed: true, Shortcut: true, InlineImage: true},
866+
}}}
867+
diags := r.Check(f)
868+
require.Len(t, diags, 2)
869+
// Document order: the longer URL at column 1, then the short URL
870+
// after "> and " at column 23 — not column 1 (the prefix match).
871+
assert.Equal(t, 1, diags[0].Column)
872+
assert.Equal(t, 23, diags[1].Column, "short autolink resolves to its own column, not the prefix match")
873+
}
874+
875+
// TestApplySettings_LinkImageStyle_DeepMergeAcrossLayers verifies the
876+
// link-image-style sub-map deep-merges across config layers: a base
877+
// rules: layer and a kind layer each set a different toggle, and both
878+
// survive in the effective config.
879+
func TestApplySettings_LinkImageStyle_DeepMergeAcrossLayers(t *testing.T) {
880+
cfg := &config.Config{
881+
Rules: map[string]config.RuleCfg{
882+
"link-style": {Enabled: true, Settings: map[string]any{
883+
"links": map[string]any{"style": map[string]any{
884+
"link-image-style": map[string]any{"inline": false},
885+
}},
886+
}},
887+
},
888+
Kinds: map[string]config.KindBody{
889+
"docs": {Rules: map[string]config.RuleCfg{
890+
"link-style": {Enabled: true, Settings: map[string]any{
891+
"links": map[string]any{"style": map[string]any{
892+
"link-image-style": map[string]any{"full": false},
893+
}},
894+
}},
895+
}},
896+
},
897+
KindAssignment: []config.KindAssignmentEntry{
898+
{Files: []string{"docs/**/*.md"}, Kinds: []string{"docs"}},
899+
},
900+
}
901+
rules := config.Effective(cfg, "docs/guides/foo.md", nil, nil)
902+
r := &Rule{}
903+
require.NoError(t, r.ApplySettings(rules["link-style"].Settings))
904+
lis := r.Links.Style.LinkImageStyle
905+
assert.True(t, lis.Active)
906+
assert.False(t, lis.Inline, "base layer inline:false must survive the kind's full:false override")
907+
assert.False(t, lis.Full, "kind layer full:false must apply")
908+
assert.True(t, lis.Collapsed, "an untouched toggle stays at its default allow")
909+
}
910+
798911
func newFile(t *testing.T, src string) *lint.File {
799912
t.Helper()
800913
f, err := lint.NewFile("doc.md", []byte(src))

0 commit comments

Comments
 (0)