Setext Heading Detection & Single-Dash Fix

March 29, 2026 · View on GitHub

Overview

Improved the robustness of fix_false_setext_headings() in the markdown parser. The function corrects a single - under text from being treated as a setext H2 heading (comrak's default per CommonMark) to a Paragraph + List(Item), since in an editor context a lone - is almost always the start of a list item.

During investigation, confirmed that -- and --- under text are legitimate setext H2 headings per the CommonMark specification and should not be overridden.

Key Files

  • src/markdown/parser.rsfix_false_setext_headings() function and test suite

CommonMark Setext Heading Rules

SourceResultStandard?
Text\n= or Text\n===H1 headingYes (CommonMark)
Text\n-Paragraph + List (editor override)No — editor UX decision
Text\n--H2 headingYes (CommonMark)
Text\n---H2 headingYes (CommonMark)
(blank)\n---\n(blank)Thematic break (horizontal rule)Yes (CommonMark)
--- at document start/endYAML frontmatter delimiterYes (with frontmatter extension)

The spec requires only one or more - or = characters for a setext underline. The distinction between --- as a heading underline vs. thematic break depends on whether there is text directly above it (heading) or blank lines around it (thematic break).

Implementation Details

Problem: comrak end_line overshoot

Comrak's AST node for a setext heading may set end_line to include trailing blank lines or even the start of the next block element. The original code assumed the underline was always at source_lines[end_line - 1], which pointed at the wrong line in multi-paragraph documents.

Example: for "Text\n-\n\nMore text", comrak reports the heading as start_line=1, end_line=3, but the underline (-) is on line 2, not line 3.

Fix: backwards scan for underline

Instead of blindly using end_line, the code now scans backwards through the heading's source line range to find the last line consisting entirely of - characters — that's the actual setext underline:

let mut underline_info: Option<(&str, usize)> = None;
for idx in (start_idx..end_idx).rev() {
    if let Some(line) = source_lines.get(idx) {
        let t = line.trim();
        if !t.is_empty() && t.chars().all(|c| c == '-') {
            underline_info = Some((t, idx + 1)); // 1-indexed
            break;
        }
    }
}

Only a single - triggers the false-setext correction; -- and longer are left as valid headings.

Tests

TestValidates
test_setext_h2_double_dash_is_validText\n-- → H2 (CommonMark compliant)
test_setext_h2_triple_dash_is_validText\n--- → H2 (CommonMark compliant)
test_triple_dash_horizontal_rule_with_blank_lines\n---\n → ThematicBreak
test_yaml_frontmatter_preservedYAML --- delimiters parse correctly
test_single_dash_false_setext_still_worksText\n- → Paragraph + List
test_single_dash_false_setext_in_longer_docSingle-dash fix works when comrak's end_line overshoots
test_multiline_setext_h2Multi-line text + -- → valid H2