Repository: kivikakk/comrak (Rust)

Issue:
NodeHeading should have a property, indicating ATX closed header
It'd nice if NodeHeading provide an indication either given ATX-style header is closed or not:
```
# ATX style header
```
vs
```
# closed-ATX style header #
```

### Motivation:
I'm working on [markdown/commonmark linter](https://github.com/ekropotin/quickmark), which is being powered by Comrak. 
The linting rules are being ported from https://github.com/markdownlint/markdownlint.
[One on the rules](https://github.com/markdownlint/markdownlint/blob/main/docs/RULES.md#md003---header-style) should be triggered when inconsistent heading styles are used across a document, for example, when there is a mix of ATX and closed ATX-style headers. To enforce this rule, I need to differentiate between regular ATX headers and closed ATX headers.


Discussion:
I'd be OK with adding this to Comrak. I should note, though, that in general it aims to provide an AST and not a CST/concrete syntax tree, so you might find a fair few other such things needing to be added to use it for a linter.



Patch:
diff --git a/src/nodes.rs b/src/nodes.rs
index 2cea9d88..7047dbbc 100644
--- a/src/nodes.rs
+++ b/src/nodes.rs
@@ -418,6 +418,10 @@ pub struct NodeHeading {
 
     /// Whether the heading is setext (if not, ATX).
     pub setext: bool,
+
+    /// Whether this ATX heading had a closing sequence of trailing hashes.
+    /// Only meaningful for ATX headings (i.e. when `setext` is false).
+    pub closed: bool,
 }
 
 /// The metadata of an included HTML block.
diff --git a/src/parser/mod.rs b/src/parser/mod.rs
index e1c77fb6..e9debbad 100644
--- a/src/parser/mod.rs
+++ b/src/parser/mod.rs
@@ -820,6 +820,7 @@ where
         container_ast.value = NodeValue::Heading(NodeHeading {
             level,
             setext: false,
+            closed: false,
         });
 
         true
@@ -928,6 +929,7 @@ where
                     scanners::SetextChar::Hyphen => 2,
                 },
                 setext: true,
+                closed: false,
             });
             let adv = line.len() - 1 - self.offset;
             self.advance_offset(line, adv, false);
@@ -1452,9 +1454,11 @@ where
                         // do nothing
                     } else if container.data().value.accepts_lines() {
                         let mut line = line;
-                        if let NodeValue::Heading(ref nh) = container.data().value {
+                        if let NodeValue::Heading(ref mut nh) = container.data_mut().value {
                             if !nh.setext {
-                                line = strings::chop_trailing_hashes(line);
+                                let (new_line, closed) = strings::chop_trailing_hashes(line);
+                                line = new_line;
+                                nh.closed = closed;
                             }
                         };
                         let count = self.first_nonspace - self.offset;
diff --git a/src/strings.rs b/src/strings.rs
index 9f8b8acb..8087c024 100644
--- a/src/strings.rs
+++ b/src/strings.rs
@@ -160,7 +160,11 @@ pub fn is_space_or_tab(ch: u8) -> bool {
     matches!(ch, 9 | 32)
 }
 
-pub fn chop_trailing_hashes(mut line: &str) -> &str {
+/// Chop any trailing sequence of `#` characters from an ATX heading line.
+///
+/// Returns the possibly-chopped line and a boolean indicating whether
+/// trailing hashes were removed (i.e. the heading had a closing sequence).
+pub fn chop_trailing_hashes(mut line: &str) -> (&str, bool) {
     line = rtrim_slice(line);
 
     let orig_n = line.len() - 1;
@@ -169,15 +173,15 @@ pub fn chop_trailing_hashes(mut line: &str) -> &str {
     let bytes = line.as_bytes();
     while bytes[n] == b'#' {
         if n == 0 {
-            return line;
+            return (line, false);
         }
         n -= 1;
     }
 
     if n != orig_n && is_space_or_tab(bytes[n]) {
-        rtrim_slice(&line[..n])
+        (rtrim_slice(&line[..n]), true)
     } else {
-        line
+        (line, false)
     }
 }
 


Test patch:
diff --git a/src/tests/core.rs b/src/tests/core.rs
index 9d82da93..66a5a57f 100644
--- a/src/tests/core.rs
+++ b/src/tests/core.rs
@@ -67,8 +67,13 @@ fn thematic_breaks() {
 #[test]
 fn atx_heading() {
     html(
-        concat!("# h1\n", "foo\n", "## h2\n"),
-        concat!("<h1>h1</h1>\n", "<p>foo</p>\n", "<h2>h2</h2>\n"),
+        concat!("# h1\n", "foo\n", "## h2\n", "####### foo\n"),
+        concat!(
+            "<h1>h1</h1>\n",
+            "<p>foo</p>\n",
+            "<h2>h2</h2>\n",
+            "<p>####### foo</p>\n"
+        ),
     );
 }
 
@@ -78,8 +83,10 @@ fn atx_heading_sourcepos() {
         [],
         "# h1\n"
         "foo\n"
-        "## h2\n",
-        (document (1:1-3:5) [
+        "## h2\n"
+        // https://spec.commonmark.org/0.31.2/#example-63
+        "####### foo\n",
+        (document (1:1-4:11) [
             (heading (1:1-1:4) [
                 (text (1:3-1:4) "h1")
             ])
@@ -89,6 +96,135 @@ fn atx_heading_sourcepos() {
             (heading (3:1-3:5) [
                 (text (3:4-3:5) "h2")
             ])
+            (paragraph (4:1-4:11) [
+                (text (4:1-4:11) "####### foo")
+            ])
+        ])
+    );
+}
+
+#[test]
+fn atx_heading_closed() {
+    let input = concat!(
+        // https://spec.commonmark.org/0.31.2/#example-71
+        "## Heading ##\n",
+        "   ## Heading ##\n",
+        // https://spec.commonmark.org/0.31.2/#example-72
+        "## Heading #########\n",
+        "#### Heading ##\n",
+        // https://spec.commonmark.org/0.31.2/#example-73
+        "## Heading ###   \n",
+    );
+
+    let arena = Arena::<AstNode>::new();
+    let options = Options::default();
+    let root = parse_document(&arena, input, &options);
+
+    let headings = root
+        .descendants()
+        .filter(|n| matches!(n.data().value, NodeValue::Heading(_)));
+
+    for heading in headings {
+        if let NodeValue::Heading(ref nh) = heading.data().value {
+            assert!(
+                nh.closed,
+                "Expected all ATX headings to be closed: {:?}",
+                heading
+            );
+        }
+    }
+}
+
+#[test]
+fn atx_heading_not_closed() {
+    let input = concat!(
+        // https://spec.commonmark.org/0.31.2/#example-62
+        "# Heading\n",
+        // https://spec.commonmark.org/0.31.2/#example-74
+        "### Heading ### b\n",
+        // https://spec.commonmark.org/0.31.2/#example-75
+        "# Heading#\n",
+        // https://spec.commonmark.org/0.31.2/#example-76
+        "### Heading \\###\n",
+        "## Heading #\\##\n",
+        "# Heading \\#\n",
+    );
+
+    let arena = Arena::<AstNode>::new();
+    let options = Options::default();
+    let root = parse_document(&arena, input, &options);
+
+    let headings = root
+        .descendants()
+        .filter(|n| matches!(n.data().value, NodeValue::Heading(_)));
+
+    for heading in headings {
+        if let NodeValue::Heading(ref nh) = heading.data().value {
+            assert!(
+                !nh.closed,
+                "Expected all ATX headings to be not closed: {:?}",
+                heading
+            );
+        }
+    }
+}
+
+#[test]
+fn atx_heading_closed_sourcepos() {
+    assert_ast_match!(
+        [],
+        // https://spec.commonmark.org/0.31.2/#example-71
+        "## Heading ##\n"
+        "   ## Heading ##\n"
+        // https://spec.commonmark.org/0.31.2/#example-72
+        "## Heading #########\n"
+        "#### Heading ##\n"
+        // Not part of the spec, but for completeness
+        "######### Not heading ##\n"
+        // https://spec.commonmark.org/0.31.2/#example-73
+        "## Heading ###   \n"
+        // https://spec.commonmark.org/0.31.2/#example-74
+        "### Heading ### b\n"
+        // https://spec.commonmark.org/0.31.2/#example-75
+        "# Heading#\n"
+        // https://spec.commonmark.org/0.31.2/#example-76
+        "### Heading \\###\n"
+        "## Heading #\\##\n"
+        "# Heading \\#\n",
+        (document (1:1-11:12) [
+            (heading (1:1-1:13) [
+                (text (1:4-1:10) "Heading")
+            ])
+            (heading (2:4-2:16) [
+                (text (2:7-2:13) "Heading")
+            ])
+            (heading (3:1-3:20) [
+                (text (3:4-3:10) "Heading")
+            ])
+            (heading (4:1-4:15) [
+                (text (4:6-4:12) "Heading")
+            ])
+            (paragraph (5:1-5:24) [
+                (text (5:1-5:24) "######### Not heading ##")
+            ])
+            (heading (6:1-6:17) [
+                (text (6:4-6:10) "Heading")
+            ])
+            (heading (7:1-7:17) [
+                (text (7:5-7:17) "Heading ### b")
+            ])
+            (heading (8:1-8:10) [
+                (text (8:3-8:10) "Heading#")
+            ])
+            (heading (9:1-9:16) [
+                (text (9:5-9:16) "Heading ###")
+            ])
+            (heading (10:1-10:15) [
+                (text (10:4-10:15) "Heading ###")
+            ])
+            (heading (11:1-11:12) [
+                (text (11:3-11:12) "Heading #")
+            ])
         ])
     );
 }
diff --git a/src/tests/sourcepos.rs b/src/tests/sourcepos.rs
index 89ac8fc3..aaf1b30a 100644
--- a/src/tests/sourcepos.rs
+++ b/src/tests/sourcepos.rs
@@ -150,13 +150,15 @@ hello world
 );
 
 const HEADING: TestCase = (
-    &[sourcepos!((5:1-5:13))],
+    &[sourcepos!((5:1-5:13)), sourcepos!((7:1-7:16))],
     r#"---
 a: b
 ---
 
 # Hello World
 
+# Test heading #
+
 hello world
 "#,
 );