Skip to content

Commit 331f2c8

Browse files
authored
Merge pull request #82 from jeduden/claude/plan-issue-80-bYJmI
fix(MDS004): distinguish blank-line-before-heading from missing heading
2 parents 7d9b106 + 132bc6c commit 331f2c8

4 files changed

Lines changed: 142 additions & 6 deletions

File tree

internal/rules/MDS004-first-line-heading/README.md

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ rules:
5050
5151
## Examples
5252
53-
### Bad
53+
### Bad — content before heading
5454
5555
<?include
5656
file: bad/default.md
@@ -65,6 +65,23 @@ Some content here.
6565

6666
<?/include?>
6767

68+
### Bad — blank line before heading
69+
70+
The file below has a blank line between the frontmatter closing
71+
`---` and `# Title`. The blank line is not visible in the code
72+
block but triggers the diagnostic.
73+
74+
<?include
75+
file: bad/blank-line.md
76+
wrap: markdown
77+
?>
78+
79+
```markdown
80+
# Title
81+
```
82+
83+
<?/include?>
84+
6885
### Good
6986

7087
<?include
@@ -82,7 +99,8 @@ Some content here.
8299

83100
## Diagnostics
84101

85-
| Message | Condition |
86-
|------------------------------------------------|---------------------------------------------|
87-
| `first line should be a level {level} heading` | Line 1 is missing or not a heading |
88-
| `first heading should be level {level}, got {n}` | First heading on line 1 has the wrong level |
102+
| Message | Condition |
103+
|----------------------------------------------------------------|-----------------------------------------------------|
104+
| `first line should be a level {level} heading` | Line 1 is missing or not a heading |
105+
| `first line should be a level {level} heading, found blank line` | First child is a heading but preceded by blank line |
106+
| `first heading should be level {level}, got {n}` | First heading on line 1 has the wrong level |
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
diagnostics:
3+
- line: 1
4+
column: 1
5+
message: "first line should be a level 1 heading, found blank line"
6+
---
7+
8+
# Title

internal/rules/firstlineheading/rule.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ func (r *Rule) Check(f *lint.File) []lint.Diagnostic {
5050
}
5151

5252
if headingLine(heading, f) != 1 {
53-
return r.diag(f, missingMsg)
53+
return r.diag(f, fmt.Sprintf("first line should be a level %d heading, found blank line", level))
5454
}
5555

5656
if heading.Level != level {
@@ -125,5 +125,19 @@ func headingLine(heading *ast.Heading, f *lint.File) int {
125125
return f.LineOfOffset(t.Segment.Start)
126126
}
127127
}
128+
// Empty headings (e.g. "# \n") have no text segments.
129+
// Detect whether the first line is blank (only spaces/tabs
130+
// before a newline). Markdown treats such lines as blank,
131+
// so a heading on the following line starts on line 2.
132+
for i := 0; i < len(f.Source); i++ {
133+
b := f.Source[i]
134+
if b == ' ' || b == '\t' {
135+
continue
136+
}
137+
if b == '\n' || b == '\r' {
138+
return 2
139+
}
140+
return 1
141+
}
128142
return 1
129143
}

internal/rules/firstlineheading/rule_test.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,43 @@ func TestCheck_FirstLineH1_NoViolation(t *testing.T) {
1717
require.Len(t, diags, 0, "expected 0 diagnostics, got %d: %+v", len(diags), diags)
1818
}
1919

20+
func TestCheck_SetextHeading_NoViolation(t *testing.T) {
21+
src := []byte("Title\n=====\n\nSome text\n")
22+
f, err := lint.NewFile("test.md", src)
23+
require.NoError(t, err)
24+
r := &Rule{Level: 1}
25+
diags := r.Check(f)
26+
require.Len(t, diags, 0, "setext heading on line 1 should pass, got %d: %+v", len(diags), diags)
27+
}
28+
29+
func TestCheck_EmphasisHeading_NoViolation(t *testing.T) {
30+
src := []byte("# *Title*\n\nSome text\n")
31+
f, err := lint.NewFile("test.md", src)
32+
require.NoError(t, err)
33+
r := &Rule{Level: 1}
34+
diags := r.Check(f)
35+
require.Len(t, diags, 0, "heading with emphasis on line 1 should pass, got %d: %+v", len(diags), diags)
36+
}
37+
38+
func TestCheck_LinkHeading_NoViolation(t *testing.T) {
39+
src := []byte("# [link](url)\n\nSome text\n")
40+
f, err := lint.NewFile("test.md", src)
41+
require.NoError(t, err)
42+
r := &Rule{Level: 1}
43+
diags := r.Check(f)
44+
require.Len(t, diags, 0, "heading with link on line 1 should pass, got %d: %+v", len(diags), diags)
45+
}
46+
47+
func TestCheck_BlankLineSetextHeading(t *testing.T) {
48+
src := []byte("\nTitle\n=====\n")
49+
f, err := lint.NewFile("test.md", src)
50+
require.NoError(t, err)
51+
r := &Rule{Level: 1}
52+
diags := r.Check(f)
53+
require.Len(t, diags, 1, "setext heading after blank line should fail, got %d: %+v", len(diags), diags)
54+
require.Equal(t, "first line should be a level 1 heading, found blank line", diags[0].Message)
55+
}
56+
2057
func TestCheck_EmptyFile(t *testing.T) {
2158
src := []byte("")
2259
f, err := lint.NewFile("test.md", src)
@@ -36,6 +73,7 @@ func TestCheck_StartsWithParagraph(t *testing.T) {
3673
r := &Rule{Level: 1}
3774
diags := r.Check(f)
3875
require.Len(t, diags, 1, "expected 1 diagnostic, got %d: %+v", len(diags), diags)
76+
require.Equal(t, "first line should be a level 1 heading", diags[0].Message)
3977
}
4078

4179
func TestCheck_BlankLineThenHeading(t *testing.T) {
@@ -45,6 +83,57 @@ func TestCheck_BlankLineThenHeading(t *testing.T) {
4583
r := &Rule{Level: 1}
4684
diags := r.Check(f)
4785
require.Len(t, diags, 1, "expected 1 diagnostic for heading not on line 1, got %d: %+v", len(diags), diags)
86+
require.Equal(t, "first line should be a level 1 heading, found blank line", diags[0].Message)
87+
}
88+
89+
func TestCheck_MultipleBlankLinesThenHeading(t *testing.T) {
90+
src := []byte("\n\n\n# Title\n")
91+
f, err := lint.NewFile("test.md", src)
92+
require.NoError(t, err)
93+
r := &Rule{Level: 1}
94+
diags := r.Check(f)
95+
require.Len(t, diags, 1, "expected 1 diagnostic, got %d: %+v", len(diags), diags)
96+
require.Equal(t, "first line should be a level 1 heading, found blank line", diags[0].Message)
97+
}
98+
99+
func TestCheck_WhitespaceBlankLineThenEmptyHeading(t *testing.T) {
100+
src := []byte(" \n# \n")
101+
f, err := lint.NewFile("test.md", src)
102+
require.NoError(t, err)
103+
r := &Rule{Level: 1}
104+
diags := r.Check(f)
105+
require.Len(t, diags, 1,
106+
"whitespace-only blank line before empty heading should trigger, got %d: %+v",
107+
len(diags), diags)
108+
require.Equal(t, "first line should be a level 1 heading, found blank line", diags[0].Message)
109+
}
110+
111+
func TestCheck_BlankLineThenEmptyHeading(t *testing.T) {
112+
src := []byte("\n# \n")
113+
f, err := lint.NewFile("test.md", src)
114+
require.NoError(t, err)
115+
r := &Rule{Level: 1}
116+
diags := r.Check(f)
117+
require.Len(t, diags, 1, "expected 1 diagnostic for empty heading after blank line, got %d: %+v", len(diags), diags)
118+
require.Equal(t, "first line should be a level 1 heading, found blank line", diags[0].Message)
119+
}
120+
121+
func TestCheck_EmptyHeadingOnLine1(t *testing.T) {
122+
src := []byte("# \n")
123+
f, err := lint.NewFile("test.md", src)
124+
require.NoError(t, err)
125+
r := &Rule{Level: 1}
126+
diags := r.Check(f)
127+
require.Len(t, diags, 0, "empty heading on line 1 should not trigger diagnostic, got %d: %+v", len(diags), diags)
128+
}
129+
130+
func TestCheck_LevelZeroDefault(t *testing.T) {
131+
src := []byte("# Title\n")
132+
f, err := lint.NewFile("test.md", src)
133+
require.NoError(t, err)
134+
r := &Rule{Level: 0}
135+
diags := r.Check(f)
136+
require.Len(t, diags, 0, "Level 0 should default to 1; expected 0 diagnostics, got %d: %+v", len(diags), diags)
48137
}
49138

50139
func TestCheck_WrongLevel(t *testing.T) {
@@ -54,6 +143,7 @@ func TestCheck_WrongLevel(t *testing.T) {
54143
r := &Rule{Level: 1}
55144
diags := r.Check(f)
56145
require.Len(t, diags, 1, "expected 1 diagnostic, got %d: %+v", len(diags), diags)
146+
require.Equal(t, "first heading should be level 1, got 2", diags[0].Message)
57147
}
58148

59149
func TestCheck_Level2Config(t *testing.T) {
@@ -103,6 +193,12 @@ func TestApplySettings_LevelOutOfRange(t *testing.T) {
103193
require.Error(t, err, "expected error for level > 6")
104194
}
105195

196+
func TestApplySettings_LevelZero(t *testing.T) {
197+
r := &Rule{Level: 1}
198+
err := r.ApplySettings(map[string]any{"level": 0})
199+
require.Error(t, err, "expected error for level 0")
200+
}
201+
106202
func TestApplySettings_UnknownKey(t *testing.T) {
107203
r := &Rule{Level: 1}
108204
err := r.ApplySettings(map[string]any{"unknown": true})

0 commit comments

Comments
 (0)