Skip to content

Commit 4495644

Browse files
authored
feat(parser): support indented code blocks (#488)
Lines indented four or more columns (tab-aware, tab stop 4) now form an indented code block instead of being folded into paragraphs. The content is each line minus its first four columns; interior blank lines keep whatever remains after the same strip, and trailing blank lines stay outside the block. Since no block can start at four columns of indent, such lines also no longer interrupt an open paragraph (lazy hanging indents keep working), which line_starts_block now encodes directly. Fixes 38 CommonMark spec examples in both modes (baseline 710 -> 634).
1 parent 4f9e7f2 commit 4495644

6 files changed

Lines changed: 128 additions & 81 deletions

File tree

crates/ox_content_parser/src/parser.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ mod block_quote;
1313
mod cursor;
1414
mod fenced_code;
1515
mod html;
16+
mod indented_code;
1617
mod inline;
1718
mod inline_helpers;
1819
mod inline_html;

crates/ox_content_parser/src/parser/block.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ impl<'a> Parser<'a> {
2929
return Ok(None);
3030
};
3131

32+
// Four columns of indentation start an indented code block; no
33+
// other block construct can begin on such a line. (This runs at
34+
// block level only — an indented line after an open paragraph is
35+
// lazy continuation, handled by `parse_paragraph`.)
36+
if self.line_indent_width(start, trimmed_start) >= 4 {
37+
return self.parse_indented_code(start);
38+
}
39+
3240
// Fast block dispatch.
3341
//
3442
// Most documentation lines are plain paragraph text. The old shape

crates/ox_content_parser/src/parser/cursor.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ impl<'a> Parser<'a> {
8888
return false;
8989
};
9090

91+
// A line indented four or more columns cannot start any block, so
92+
// it can never interrupt a paragraph either (lazy continuation).
93+
if self.line_indent_width(line_start, trimmed_start) >= 4 {
94+
return false;
95+
}
96+
9197
let starts_block = match bytes[trimmed_start] {
9298
b'#' => self.try_parse_heading_start(line_start, trimmed_start),
9399
b'-' | b'*' => {
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
//! Indented code blocks (CommonMark "Indented code blocks").
2+
//!
3+
//! A run of lines indented at least four columns forms a code block whose
4+
//! content is every line with the first four columns of indentation
5+
//! removed. Interior blank lines are part of the block (contributing
6+
//! whatever remains after the same four-column strip); leading and
7+
//! trailing blank lines are not.
8+
9+
use ox_content_ast::{CodeBlock, Node, Span};
10+
11+
use super::Parser;
12+
use crate::error::ParseResult;
13+
14+
impl<'a> Parser<'a> {
15+
/// Parses an indented code block starting at `start` (the beginning of
16+
/// a non-blank line whose indentation is at least four columns).
17+
pub(super) fn parse_indented_code(&mut self, start: usize) -> ParseResult<Option<Node<'a>>> {
18+
let mut value = self.allocator.new_string();
19+
let mut pos = start;
20+
let mut end = start;
21+
// Blank lines are buffered until the next sufficiently indented
22+
// line proves they are interior; trailing ones stay unconsumed.
23+
let mut pending_blank_lines = 0usize;
24+
let mut pending_blank_start = start;
25+
26+
while pos < self.source.len() {
27+
let line_start = pos;
28+
let Some(first_non_ws) = self.first_non_whitespace_in_line(line_start) else {
29+
if pending_blank_lines == 0 {
30+
pending_blank_start = line_start;
31+
}
32+
pending_blank_lines += 1;
33+
pos = self.next_line_start(line_start);
34+
continue;
35+
};
36+
37+
if indent_width(self.source.as_bytes(), line_start, first_non_ws) < 4 {
38+
break;
39+
}
40+
let content_start = strip_indent_columns(self.source.as_bytes(), line_start, 4);
41+
42+
if pending_blank_lines > 0 {
43+
let mut blank_pos = pending_blank_start;
44+
for _ in 0..pending_blank_lines {
45+
let blank_content = strip_indent_columns(self.source.as_bytes(), blank_pos, 4);
46+
let blank_line = self.line_at(blank_pos);
47+
let blank_end = blank_pos + blank_line.len();
48+
value.push_str(&self.source[blank_content.min(blank_end)..blank_end]);
49+
value.push('\n');
50+
blank_pos = self.next_line_start(blank_pos);
51+
}
52+
pending_blank_lines = 0;
53+
}
54+
55+
let line = self.line_at(line_start);
56+
let line_end = line_start + line.len();
57+
value.push_str(&self.source[content_start..line_end]);
58+
value.push('\n');
59+
pos = self.next_line_start(line_start);
60+
end = pos;
61+
}
62+
63+
self.position = end;
64+
let span = Span::new(start as u32, end as u32);
65+
Ok(Some(Node::CodeBlock(CodeBlock {
66+
lang: None,
67+
meta: None,
68+
value: value.into_bump_str(),
69+
span,
70+
})))
71+
}
72+
73+
/// Returns the indentation width in columns of the current line, where
74+
/// a tab advances to the next multiple of four.
75+
pub(super) fn line_indent_width(&self, line_start: usize, first_non_ws: usize) -> usize {
76+
indent_width(self.source.as_bytes(), line_start, first_non_ws)
77+
}
78+
}
79+
80+
fn indent_width(bytes: &[u8], line_start: usize, first_non_ws: usize) -> usize {
81+
let mut columns = 0usize;
82+
for &byte in &bytes[line_start..first_non_ws] {
83+
match byte {
84+
b'\t' => columns = (columns / 4 + 1) * 4,
85+
_ => columns += 1,
86+
}
87+
}
88+
columns
89+
}
90+
91+
/// Returns the byte offset just past the first `columns` columns of
92+
/// indentation on the line at `line_start`. Tabs advance to the next tab
93+
/// stop; since tab stops are multiples of four, stripping four columns
94+
/// never lands inside a tab.
95+
fn strip_indent_columns(bytes: &[u8], line_start: usize, columns: usize) -> usize {
96+
let mut column = 0usize;
97+
let mut i = line_start;
98+
while column < columns && i < bytes.len() {
99+
match bytes[i] {
100+
b' ' => {
101+
column += 1;
102+
i += 1;
103+
}
104+
b'\t' => {
105+
column = (column / 4 + 1) * 4;
106+
i += 1;
107+
}
108+
_ => break,
109+
}
110+
}
111+
i
112+
}
Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
---
22
source: crates/ox_content_parser/tests/snapshot_parse.rs
3-
assertion_line: 32
43
description: " line a\n\n line b\n line c\n"
54
---
65
Document [0..34]
7-
Paragraph [0..11]
8-
Text "line a" [0..6]
9-
Paragraph [12..34]
10-
Text "line b\n line c" [12..29]
6+
CodeBlock lang=None meta=None value="line a\n\nline b\nline c\n" [0..34]

0 commit comments

Comments
 (0)