From ebb0c81557e21f1febbdafd86efef52a7b1da99a Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 26 Jul 2026 19:12:16 +0200 Subject: [PATCH 1/2] docs: document ProcessBuffer and continuation invariants Add `///` doc comments to the private state-machine helpers that carry non-obvious invariants, explaining *why* each exists rather than restating the control flow, per the AGENTS.md commenting guidance. - `ProcessBuffer::{flush, push_verbatim, handle_fence_line, handle_table_line}` in `src/process/buffer.rs` now state the buffer/ordering invariants each upholds and the edge cases (ellipsis timing, verbatim ordering, guard ordering) that motivate them. - Continuation helpers `reopen_pending_span`, `suppresses_join_space_after_nested_open_paren`, and `unclosed_parenthesis_depth` in `src/wrap/continuation.rs` now record the CommonMark soft-break and nested-parenthesis rules they encode. Docs-only change; no behaviour is altered. Closes #366 Co-Authored-By: Claude Opus 4.8 (1M context) --- src/process/buffer.rs | 38 ++++++++++++++++++++++++++++++++++++++ src/wrap/continuation.rs | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/src/process/buffer.rs b/src/process/buffer.rs index c84037d6..a827d7a1 100644 --- a/src/process/buffer.rs +++ b/src/process/buffer.rs @@ -65,6 +65,16 @@ impl ProcessBuffer { /// lines into the output. pub(super) fn into_out(self) -> Vec { self.out } + /// Drains any buffered lines into `out`, reflowing them as a table when + /// the run was recognised as one. + /// + /// Upholds the buffer invariant that `buf` is empty and `in_table` is + /// `false` on return, so the next line starts a fresh detection window. + /// Ellipsis replacement is applied here, *before* [`reflow_table`], because + /// the substitution must reach the cell text while it is still row-shaped; + /// running it after reflow would have to re-parse the emitted table. An + /// empty buffer short-circuits so ordering against [`push_out`](Self::push_out) + /// is preserved without emitting a spurious blank flush. pub(super) fn flush(&mut self) { debug!( in_table = self.in_table, @@ -88,11 +98,26 @@ impl ProcessBuffer { self.in_table = false; } + /// Emits `line` verbatim after first flushing any pending table. + /// + /// The flush is mandatory: a verbatim line (a code fence, for instance) + /// closes whatever table run preceded it, and appending it directly to + /// `out` without flushing would let it jump ahead of buffered rows that + /// belong earlier in the document. Flushing first keeps source ordering + /// intact. pub(super) fn push_verbatim(&mut self, line: &str) { self.flush(); self.out.push(line.to_string()); } + /// Consumes a code-fence marker line, returning `true` when it was handled. + /// + /// A fence marker can never be part of a table, so it must terminate the + /// current run; the line is emitted through [`push_verbatim`](Self::push_verbatim) + /// so the pending table flushes first and ordering is preserved. Non-marker + /// lines return `false` immediately, signalling the caller to fall through + /// to its in-fence and table-detection handling; this method deliberately + /// makes no decision about lines *inside* a fence. pub(super) fn handle_fence_line(&mut self, line: &str, is_fence_marker: bool) -> bool { if !is_fence_marker { return false; @@ -102,6 +127,19 @@ impl ProcessBuffer { true } + /// Routes a non-fence line through table detection, buffering it or handing + /// it back for verbatim emission. + /// + /// Returns `None` when the line has been absorbed into the pending table + /// run (`buf`), and `Some(line)` when the caller should emit it after the + /// buffer has been flushed. The invariant is that `buf` only ever holds + /// genuine table rows: every path that meets a line which cannot belong to + /// the current table flushes before yielding it, so a stray row can never + /// make [`reflow_table`] bail on an otherwise valid table. The ordering of + /// the guards is load-bearing — indented code blocks and block boundaries + /// (see the inline comments) must be recognised *before* the permissive + /// pipe heuristic, which would otherwise swallow lines that merely happen + /// to contain a `|`. pub(super) fn handle_table_line(&mut self, line: String) -> Option { // A leading indent of four or more columns marks a Markdown indented // code block, so such a line must stay verbatim and never enter table diff --git a/src/wrap/continuation.rs b/src/wrap/continuation.rs index 884cd1bd..ba274f97 100644 --- a/src/wrap/continuation.rs +++ b/src/wrap/continuation.rs @@ -110,6 +110,22 @@ pub(super) fn apply_continuation_chunk( } } +/// Splits the pending buffer at a close-then-reopen boundary, emitting the +/// resolved prefix and re-seeding `pending` with the freshly reopened span. +/// +/// A single continuation chunk can close the currently open code span and open +/// a new one; this helper exists for exactly that case. It flushes everything +/// up to `split_at`, then rebuilds `pending.rest` so it holds only the reopened +/// span, prefixed with its `new_len` backtick run. Two invariants must survive +/// the rewrite: `synthetic_join_spaces` offsets are rebased onto the new, +/// shorter `rest` (dropping any that fall before the split), and +/// `open_fence_len` is re-derived from that `rest` so subsequent chunks scan +/// against the correct fence width. When the reopened opener sits at end of +/// line (`opener_at_eol`) the mode is forced to `TightCodeSpan`: `CommonMark` +/// would turn the following soft break into a space inside the span, and +/// preserving it would emit an MD038 (space-inside-code-span) violation. +/// Returns `true` when the reopened span is already closed, signalling the +/// caller to flush immediately. fn reopen_pending_span( writer: &mut ParagraphWriter<'_>, pending: &mut PendingPrefix, @@ -277,6 +293,17 @@ fn leading_run_needs_space( } } +/// Reports whether a join space must be suppressed because the open code span +/// ends on a nested, still-unclosed `(`. +/// +/// Normally a soft-wrapped continuation is joined with a single space, but that +/// space is wrong when the pending text ends mid-token inside a code span — for +/// example a Rust path such as `foo((bar` split after the inner `(`. The guard +/// only fires when a span is actually open (`open_fence_len > 0`) and `existing` +/// ends with `(`, and only when the parenthesis nesting inside the span is +/// deeper than one: a single open paren is an ordinary word boundary that should +/// still take the space, whereas depth `> 1` signals a nested construct whose +/// halves must abut. See [`unclosed_parenthesis_depth`] for the depth count. fn suppresses_join_space_after_nested_open_paren(existing: &str, open_fence_len: usize) -> bool { if open_fence_len == 0 || !existing.ends_with('(') { return false; @@ -289,6 +316,14 @@ fn suppresses_join_space_after_nested_open_paren(existing: &str, open_fence_len: unclosed_parenthesis_depth(code_tail) > 1 } +/// Counts the net depth of unclosed `(` runs left open by `text`. +/// +/// The count saturates at zero on `)`, so a span containing more closers than +/// openers (or a leading `)`) never underflows the `usize` and reports depth +/// `0` rather than panicking. This tolerance is deliberate: the caller only +/// cares whether nesting is deeper than one, and code spans may legitimately +/// carry unbalanced parentheses that must not abort the join-space heuristic in +/// [`suppresses_join_space_after_nested_open_paren`]. fn unclosed_parenthesis_depth(text: &str) -> usize { text.chars().fold(0usize, |depth, ch| match ch { '(' => depth.saturating_add(1), From 9a0f5c309163398dda8f1df5e62edcab698188c6 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 27 Jul 2026 20:04:05 +0200 Subject: [PATCH 2/2] docs: qualify ProcessBuffer::flush empty-buffer postcondition The reworded invariant claimed `buf` is empty and `in_table` is `false` unconditionally on return, but the empty-buffer guard short-circuits before clearing `in_table`. The `flush_empty_buffer_is_noop` test pins that behaviour. Scope the postcondition to the non-empty case and state that an empty flush deliberately leaves `in_table` untouched. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/process/buffer.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/process/buffer.rs b/src/process/buffer.rs index a827d7a1..09bfa138 100644 --- a/src/process/buffer.rs +++ b/src/process/buffer.rs @@ -68,13 +68,15 @@ impl ProcessBuffer { /// Drains any buffered lines into `out`, reflowing them as a table when /// the run was recognised as one. /// - /// Upholds the buffer invariant that `buf` is empty and `in_table` is - /// `false` on return, so the next line starts a fresh detection window. + /// When the buffer is non-empty this upholds the invariant that `buf` is + /// empty and `in_table` is `false` on return, so the next line starts a + /// fresh detection window. An empty buffer short-circuits and leaves + /// `in_table` untouched: with nothing to drain there is no run to close, so + /// flushing is a no-op that preserves ordering against + /// [`push_out`](Self::push_out) without emitting a spurious blank flush. /// Ellipsis replacement is applied here, *before* [`reflow_table`], because /// the substitution must reach the cell text while it is still row-shaped; - /// running it after reflow would have to re-parse the emitted table. An - /// empty buffer short-circuits so ordering against [`push_out`](Self::push_out) - /// is preserved without emitting a spurious blank flush. + /// running it after reflow would have to re-parse the emitted table. pub(super) fn flush(&mut self) { debug!( in_table = self.in_table,