Skip to content

Opt-in option to pair same-length nested code fences by depth #1158

Description

@marmeladema

Would you accept an opt-in Options flag (e.g. ENABLE_NESTED_FENCES, off by default) that pairs same-length fenced code blocks by nesting depth instead of closing at the first bare fence? I'd like to check the idea is in scope before writing a PR, per CONTRIBUTING.

Markdown written by LLMs very often nests fenced blocks using the same fence length. A typical case is a plan or a README draft wrapped in ```markdown that itself contains ```rust snippets. CommonMark correctly closes the outer block at the inner snippet's bare closing fence, which splits the document: the rest of the outer block renders as prose, and its real closing fence opens a stray empty block.

With pulldown-cmark 0.13.4 and default options:

```markdown
# Plan

```rust
fn main() {}
```

Done.
```

renders as:

<pre><code class="language-markdown"># Plan

```rust
fn main() {}
</code></pre>
<p>Done.</p>
<pre><code></code></pre>

The "right" fix is for the author to use a longer outer fence ( ````), but applications that render model output (chat UIs, coding-agent frontends) don't control the author. Several such projects currently work around it with a text pre-pass that detects nesting and lengthens the outer fences before parsing. We do the same, but a pre-pass is fragile:

  • it has to re-implement container handling (list items, block quotes, lazy continuation) to know where a fence line really is, or give up on those cases;
  • to stay safe it has to re-parse after each rewrite and check the block really ends where it expected.

Inside the block parser, the container context is already known, so the rule would be simple and exact there. As far as I can tell, it's a counter local to the fenced-block loop (see the last question below).

Proposed semantics (only when the option is enabled)

While inside a fenced code block opened with fence character c and length n:

  1. A line that would be a valid opening fence of the same character c with length ≥ n and a non-empty info string increments a nesting depth. The line stays part of the code block's content.
  2. A line that would be a valid closing fence (same c, length ≥ n, no info string):
    • if depth > 0, decrements the depth, and the line stays content;
    • if depth == 0, closes the block as today.
  3. If the block reaches the end of its container or the document with depth > 0, it ends there as it would today for an unclosed fence.

Notes:

  • Inner fences with a different character, or shorter than n, are ignored, exactly as today.
  • A nested opener without an info string is indistinguishable from a closer, so it keeps today's behaviour. The heuristic only uses the one signal CommonMark already relies on: closing fences can't carry an info string.
  • With the option off, output is unchanged and the spec tests are unaffected.
  • The one known trade-off: a document that has a literal info-string fence line inside a code block, with no matching bare fence after it, would keep the block open until the end of its container. That is the same outcome as an unclosed fence today.

Questions for maintainers

  • Is this in scope as an opt-in option, or would you prefer it to live outside the crate? There is no spec or prior art in other CommonMark parsers that I'm aware of, so it's a pragmatic extension rather than a standard one.
  • If in scope, is this implementation shape what you'd expect? From reading parse_fenced_code_block in firstpass.rs, it looks like a local depth counter in its body loop is enough:
    • The container check at the top of each iteration already ends the block when a line leaves its list item or block quote. Containers therefore need no special handling.
    • A line that scan_code_fence accepts as the same character and length ≥ n, but scan_closing_code_fence rejects (because it has an info string), increments the depth.
    • A closer at depth > 0 decrements it. Either way, the line falls through to append_code_text.
    • The one subtlety: scan_closing_code_fence returns Some(0) on empty input, which is how the loop ends at end of input, so that case must still close regardless of depth.
    • The new flag would be ENABLE_NESTED_FENCES = 1 << 16.

I'm happy to write the PR with spec-style tests (including list, block-quote and unclosed cases) if you're open to it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions