Skip to content

Commit 4f9e7f2

Browse files
authored
feat(parser): support setext headings (#487)
A paragraph line followed by a line of = or - (up to three leading spaces, single marker run, trailing whitespace only) now produces an h1/h2 setext heading. While a paragraph is open the underline takes precedence over other block starts, so Foo\n--- becomes an h2 rather than a paragraph plus thematic break, matching CommonMark. Fixes 15 CommonMark spec examples in both modes (baseline 740 -> 710).
1 parent 72e343b commit 4f9e7f2

2 files changed

Lines changed: 53 additions & 31 deletions

File tree

crates/ox_content_parser/src/parser/block.rs

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use memchr::memchr;
2-
use ox_content_ast::{Node, Paragraph, Span};
2+
use ox_content_ast::{Heading, Node, Paragraph, Span};
33

44
use super::Parser;
55
use crate::error::{ParseError, ParseResult};
@@ -134,6 +134,26 @@ impl<'a> Parser<'a> {
134134
break;
135135
}
136136

137+
// Setext heading underline: while a paragraph is open this
138+
// takes precedence over every block start (`Foo\n---` is an
139+
// h2, not a paragraph followed by a thematic break), so it
140+
// must be checked before `line_starts_block`.
141+
if let Some(depth) = self.setext_underline_depth(line_start, cursor) {
142+
let heading_end = if let Some(off) = memchr(b'\n', &bytes[line_start..]) {
143+
line_start + off + 1
144+
} else {
145+
self.source.len()
146+
};
147+
self.position = heading_end;
148+
let content = self.source[start..content_end].trim();
149+
let children = self.parse_inline(content, start)?;
150+
return Ok(Some(Node::Heading(Heading {
151+
depth,
152+
children,
153+
span: Span::new(start as u32, heading_end as u32),
154+
})));
155+
}
156+
137157
// Check for block-level element that would end paragraph.
138158
if self.line_starts_block() {
139159
break;
@@ -160,4 +180,36 @@ impl<'a> Parser<'a> {
160180

161181
Ok(Some(Node::Paragraph(Paragraph { children, span })))
162182
}
183+
184+
/// Returns the setext heading depth (1 for `=`, 2 for `-`) when the
185+
/// line starting at `line_start` is a setext underline: at most three
186+
/// leading spaces, a run of a single marker character, and nothing but
187+
/// trailing whitespace. `first_non_ws` is the position of the line's
188+
/// first non-space/tab byte (already computed by the paragraph loop).
189+
fn setext_underline_depth(&self, line_start: usize, first_non_ws: usize) -> Option<u8> {
190+
let bytes = self.source.as_bytes();
191+
// A tab in the indent always reaches column 4+, so spaces only.
192+
if first_non_ws - line_start > 3
193+
|| bytes[line_start..first_non_ws].iter().any(|&byte| byte != b' ')
194+
{
195+
return None;
196+
}
197+
let marker = bytes[first_non_ws];
198+
let depth = match marker {
199+
b'=' => 1,
200+
b'-' => 2,
201+
_ => return None,
202+
};
203+
let mut i = first_non_ws;
204+
while i < bytes.len() && bytes[i] == marker {
205+
i += 1;
206+
}
207+
while i < bytes.len() && matches!(bytes[i], b' ' | b'\t' | b'\r') {
208+
i += 1;
209+
}
210+
if i < bytes.len() && bytes[i] != b'\n' {
211+
return None;
212+
}
213+
Some(depth)
214+
}
163215
}

crates/ox_content_renderer/tests/spec_fixtures/commonmark-known-failures.txt

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -34,33 +34,19 @@ core 48 Thematic-breaks
3434
core 49 Thematic-breaks
3535
core 55 Thematic-breaks
3636
core 57 Thematic-breaks
37-
core 59 Thematic-breaks
3837
core 60 Thematic-breaks
3938
core 61 Thematic-breaks
4039
core 68 ATX-headings
4140
core 69 ATX-headings
4241
core 71 ATX-headings
4342
core 75 ATX-headings
4443
core 76 ATX-headings
45-
core 80 Setext-headings
46-
core 81 Setext-headings
47-
core 82 Setext-headings
48-
core 83 Setext-headings
49-
core 84 Setext-headings
5044
core 85 Setext-headings
51-
core 86 Setext-headings
5245
core 87 Setext-headings
53-
core 89 Setext-headings
54-
core 90 Setext-headings
55-
core 91 Setext-headings
5646
core 93 Setext-headings
5747
core 94 Setext-headings
58-
core 95 Setext-headings
59-
core 96 Setext-headings
6048
core 99 Setext-headings
6149
core 100 Setext-headings
62-
core 102 Setext-headings
63-
core 103 Setext-headings
6450
core 107 Indented-code-blocks
6551
core 109 Indented-code-blocks
6652
core 110 Indented-code-blocks
@@ -74,7 +60,6 @@ core 118 Indented-code-blocks
7460
core 121 Fenced-code-blocks
7561
core 134 Fenced-code-blocks
7662
core 138 Fenced-code-blocks
77-
core 141 Fenced-code-blocks
7863
core 145 Fenced-code-blocks
7964
core 147 Fenced-code-blocks
8065
core 148 HTML-blocks
@@ -404,33 +389,19 @@ gfm 48 Thematic-breaks
404389
gfm 49 Thematic-breaks
405390
gfm 55 Thematic-breaks
406391
gfm 57 Thematic-breaks
407-
gfm 59 Thematic-breaks
408392
gfm 60 Thematic-breaks
409393
gfm 61 Thematic-breaks
410394
gfm 68 ATX-headings
411395
gfm 69 ATX-headings
412396
gfm 71 ATX-headings
413397
gfm 75 ATX-headings
414398
gfm 76 ATX-headings
415-
gfm 80 Setext-headings
416-
gfm 81 Setext-headings
417-
gfm 82 Setext-headings
418-
gfm 83 Setext-headings
419-
gfm 84 Setext-headings
420399
gfm 85 Setext-headings
421-
gfm 86 Setext-headings
422400
gfm 87 Setext-headings
423-
gfm 89 Setext-headings
424-
gfm 90 Setext-headings
425-
gfm 91 Setext-headings
426401
gfm 93 Setext-headings
427402
gfm 94 Setext-headings
428-
gfm 95 Setext-headings
429-
gfm 96 Setext-headings
430403
gfm 99 Setext-headings
431404
gfm 100 Setext-headings
432-
gfm 102 Setext-headings
433-
gfm 103 Setext-headings
434405
gfm 107 Indented-code-blocks
435406
gfm 109 Indented-code-blocks
436407
gfm 110 Indented-code-blocks
@@ -444,7 +415,6 @@ gfm 118 Indented-code-blocks
444415
gfm 121 Fenced-code-blocks
445416
gfm 134 Fenced-code-blocks
446417
gfm 138 Fenced-code-blocks
447-
gfm 141 Fenced-code-blocks
448418
gfm 145 Fenced-code-blocks
449419
gfm 147 Fenced-code-blocks
450420
gfm 148 HTML-blocks

0 commit comments

Comments
 (0)