From 1a4be64dcfa17a1173d8a7b21ebca44f3971938d Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 22 Jul 2026 17:07:47 +0200 Subject: [PATCH 1/4] Collapse fence marker strategies (#360) Use one private `Strategy` enum for all fence-marker rewriting and retain matched-block preservation in `rewrite_fence_line`. Add generated strategy invariants, output snapshots, and developer guidance to lock the normalized fence behaviour. --- docs/developers-guide.md | 15 +++++ src/fences.rs | 51 +++++++--------- src/fences_properties.rs | 59 +++++++++++++++++++ tests/fences.rs | 27 ++++++++- .../fences_compress_overlong_backticks.snap | 7 +++ .../fences_compress_tilde_fences.snap | 7 +++ .../fences_preserve_interior_conflict.snap | 9 +++ 7 files changed, 143 insertions(+), 32 deletions(-) create mode 100644 src/fences_properties.rs create mode 100644 tests/snapshots/fences_compress_overlong_backticks.snap create mode 100644 tests/snapshots/fences_compress_tilde_fences.snap create mode 100644 tests/snapshots/fences_preserve_interior_conflict.snap diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 275c7a46..84f07e01 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -648,6 +648,21 @@ Together, these helpers make the rest of the processing pipeline deal with a single normalized fence, and avoid carrying separate logic for detached specifier lines. + +### Strategy enum + +`Strategy` is the canonical private choice for fence-marker rewriting. Its +variants have the following effects: + +- `Compress` rewrites the marker to exactly three backticks while preserving + indentation and the language specifier. +- `Preserve` retains the original marker character and run length when an + interior fence would otherwise become structural. + +All fence-marker rewriting must dispatch on `Strategy` through `rewrite_marker`; +`rewrite_fence_line` retains the additional matched-block handling required by +the preserve path. + ### Architecture The earlier implementation used two slice-and-index helpers, diff --git a/src/fences.rs b/src/fences.rs index 7b15a569..d6f14694 100644 --- a/src/fences.rs +++ b/src/fences.rs @@ -17,6 +17,10 @@ use crate::wrap::{FenceObservation, FenceTracker, ObservedFence}; mod attachment; +#[cfg(test)] +#[path = "fences_properties.rs"] +mod properties; + use attachment::attach_to_next_fence; static FENCE_RE: LazyLock = lazy_regex!( @@ -75,16 +79,11 @@ fn normalize_specifier(line: &str) -> (String, String) { (cleaned, indent) } +/// Select how a recognized fence marker is normalized. #[derive(Clone, Copy)] -enum FenceRewrite { +enum Strategy { Compress, - PreserveDelimiters, -} - -#[derive(Clone, Copy)] -enum MarkerStrategy { - Compressed, - PreserveDelimiter, + Preserve, } /// A retained source line together with its compressed rewrite, computed once @@ -107,14 +106,14 @@ struct PendingFenceBlock { fn marker_char(marker: &str) -> Option { marker.chars().next() } -fn rewrite_marker(line: &str, strategy: MarkerStrategy) -> Option { +fn rewrite_marker(line: &str, strategy: Strategy) -> Option { let cap = FENCE_RE.captures(line)?; let indent = cap.get(1).map_or("", |m| m.as_str()); let original_marker = cap.get(2).map_or("", |m| m.as_str()); let lang = cap.get(3).map_or("", |m| m.as_str()); let marker = match strategy { - MarkerStrategy::Compressed => "```", - MarkerStrategy::PreserveDelimiter => original_marker, + Strategy::Compress => "```", + Strategy::Preserve => original_marker, }; Some(if is_null_lang(lang) { format!("{indent}{marker}") @@ -123,14 +122,6 @@ fn rewrite_marker(line: &str, strategy: MarkerStrategy) -> Option { }) } -fn compressed_fence_line(line: &str) -> Option { - rewrite_marker(line, MarkerStrategy::Compressed) -} - -fn preserved_fence_line(line: &str) -> Option { - rewrite_marker(line, MarkerStrategy::PreserveDelimiter) -} - fn interior_fence_requires_preserved_delimiters( opening_marker: &str, parsed: Option<(&str, &str, &str)>, @@ -147,24 +138,24 @@ fn interior_fence_requires_preserved_delimiters( marker_ch == opening_ch || marker_ch == '`' } -fn opening_rewrite(has_conflicting_interior_fence: bool) -> FenceRewrite { +fn opening_rewrite(has_conflicting_interior_fence: bool) -> Strategy { if has_conflicting_interior_fence { - FenceRewrite::PreserveDelimiters + Strategy::Preserve } else { - FenceRewrite::Compress + Strategy::Compress } } -/// Emit a delimiter line, reusing its cached compressed rewrite for the +/// Emit a fence line, reusing its cached compressed rewrite for the /// `Compress` strategy and computing the preserved rewrite on demand. /// -/// The `PreserveDelimiters` strategy is only chosen once per block, so its +/// The `Preserve` strategy is only chosen once per block, so its /// rewrite is not worth caching per line. -fn rewrite_delimiter(cached: CachedLine, rewrite: FenceRewrite) -> String { +fn rewrite_fence_line(cached: CachedLine, strategy: Strategy) -> String { let CachedLine { line, compressed } = cached; - match rewrite { - FenceRewrite::Compress => compressed.unwrap_or(line), - FenceRewrite::PreserveDelimiters => preserved_fence_line(&line).unwrap_or(line), + match strategy { + Strategy::Compress => compressed.unwrap_or(line), + Strategy::Preserve => rewrite_marker(&line, Strategy::Preserve).unwrap_or(line), } } fn flush_unmatched_block(block: PendingFenceBlock, out: &mut Vec) { @@ -186,7 +177,7 @@ fn flush_matched_block(block: PendingFenceBlock, out: &mut Vec) { let closing_index = block.lines.len() - 1; for (index, cached) in block.lines.into_iter().enumerate() { let emitted = if index == 0 || index == closing_index { - rewrite_delimiter(cached, rewrite) + rewrite_fence_line(cached, rewrite) } else { cached.line }; @@ -235,7 +226,7 @@ impl<'a> ParsedLine<'a> { line, observation: observed.observation, fence: observed.fence, - compressed: compressed_fence_line(line), + compressed: rewrite_marker(line, Strategy::Compress), } } diff --git a/src/fences_properties.rs b/src/fences_properties.rs new file mode 100644 index 00000000..83c9e738 --- /dev/null +++ b/src/fences_properties.rs @@ -0,0 +1,59 @@ +//! Test-only property-test companion to the `fences` module. +//! +//! These tests generate syntactically valid fence lines to verify the private +//! `Strategy` dispatch contract independently of matched-block tracking. +//! Kani is deliberately not used: the project has no Kani dev-dependency or +//! harness infrastructure, while these generated cases cover both finite +//! strategy states across the bounded marker lengths relevant to this module. + +use proptest::{prelude::*, strategy::Strategy as ProptestStrategy}; + +use super::{Strategy, rewrite_marker}; + +fn fence_line_strategy() -> impl ProptestStrategy { + ( + prop::collection::vec(Just(' '), 0..=8), + prop_oneof![Just('`'), Just('~')], + 3_usize..=10, + prop_oneof![Just(String::new()), "[a-z][a-z0-9_+.-]{0,8}"], + ) + .prop_map(|(indent, marker, marker_length, language)| { + let indent: String = indent.into_iter().collect(); + let marker_run: String = std::iter::repeat_n(marker, marker_length).collect(); + let line = format!("{indent}{marker_run}{language}"); + (line, indent, marker, marker_length, language) + }) +} + +proptest! { + #[test] + fn compress_writes_three_backticks_and_preserves_indent_and_language( + (line, indent, _marker, _marker_length, language) in fence_line_strategy(), + ) { + let rewritten = rewrite_marker(&line, Strategy::Compress).expect("generated fence matches"); + + prop_assert_eq!(rewritten, format!("{indent}```{language}")); + } + + #[test] + fn preserve_retains_the_original_marker_run( + (line, indent, marker, marker_length, language) in fence_line_strategy(), + ) { + let rewritten = rewrite_marker(&line, Strategy::Preserve).expect("generated fence matches"); + let expected_marker: String = std::iter::repeat_n(marker, marker_length).collect(); + + prop_assert_eq!(rewritten, format!("{indent}{expected_marker}{language}")); + } + + #[test] + fn rewriting_is_idempotent( + (line, _indent, _marker, _marker_length, _language) in fence_line_strategy(), + ) { + for strategy in [Strategy::Compress, Strategy::Preserve] { + let once = rewrite_marker(&line, strategy).expect("generated fence matches"); + let twice = rewrite_marker(&once, strategy).expect("rewritten fence matches"); + + prop_assert_eq!(twice, once); + } + } +} diff --git a/tests/fences.rs b/tests/fences.rs index 0434c2eb..5b50768d 100644 --- a/tests/fences.rs +++ b/tests/fences.rs @@ -3,9 +3,32 @@ #[macro_use] #[path = "common/mod.rs"] mod common; -use mdtablefix::{attach_orphan_specifiers, compress_fences}; -use rstest::rstest; +fn assert_fence_snapshot(name: &str, input: &[String]) { + insta::with_settings!({ + snapshot_path => "snapshots", + prepend_module_to_snapshot => false, + }, { + insta::assert_snapshot!(name, compress_fences(input).join("\n")); + }); +} + +#[rstest] +#[case( + "fences_compress_overlong_backticks", + lines_vec!["`````rust", "fn main() {}", "`````"] +)] +#[case( + "fences_compress_tilde_fences", + lines_vec!["~~~~~python", "print('hello')", "~~~~~"] +)] +#[case( + "fences_preserve_interior_conflict", + lines_vec!["````markdown", "```rust", "fn main() {}", "```", "````"] +)] +fn fence_normalization_snapshots(#[case] name: &str, #[case] input: Vec) { + assert_fence_snapshot(name, &input); +} #[test] fn compresses_backtick_fences() { let input = lines_vec!["````rust", "code", "````"]; diff --git a/tests/snapshots/fences_compress_overlong_backticks.snap b/tests/snapshots/fences_compress_overlong_backticks.snap new file mode 100644 index 00000000..3fb62bd0 --- /dev/null +++ b/tests/snapshots/fences_compress_overlong_backticks.snap @@ -0,0 +1,7 @@ +--- +source: tests/fences.rs +expression: "compress_fences(&input).join(\"\\n\")" +--- +```rust +fn main() {} +``` diff --git a/tests/snapshots/fences_compress_tilde_fences.snap b/tests/snapshots/fences_compress_tilde_fences.snap new file mode 100644 index 00000000..ca3478ee --- /dev/null +++ b/tests/snapshots/fences_compress_tilde_fences.snap @@ -0,0 +1,7 @@ +--- +source: tests/fences.rs +expression: "compress_fences(&input).join(\"\\n\")" +--- +```python +print('hello') +``` diff --git a/tests/snapshots/fences_preserve_interior_conflict.snap b/tests/snapshots/fences_preserve_interior_conflict.snap new file mode 100644 index 00000000..d45411a3 --- /dev/null +++ b/tests/snapshots/fences_preserve_interior_conflict.snap @@ -0,0 +1,9 @@ +--- +source: tests/fences.rs +expression: "compress_fences(&input).join(\"\\n\")" +--- +````markdown +```rust +fn main() {} +``` +```` From 9f5142b307e161c04a686c0daa7403d464078bee Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 24 Jul 2026 02:50:48 +0200 Subject: [PATCH 2/4] Cover null fence language preservation (#360) Exercise both strategy rewrites for null language variants and verify the conflicting matched-block preserve path drops the absent language suffix. Clarify that matched-block selection belongs to `flush_matched_block`. --- docs/developers-guide.md | 6 +++--- src/fences_properties.rs | 26 +++++++++++++++++++++++--- tests/fences.rs | 10 ++++++++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 84f07e01..33fb6a67 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -659,9 +659,9 @@ variants have the following effects: - `Preserve` retains the original marker character and run length when an interior fence would otherwise become structural. -All fence-marker rewriting must dispatch on `Strategy` through `rewrite_marker`; -`rewrite_fence_line` retains the additional matched-block handling required by -the preserve path. +All fence-marker rewriting must dispatch on `Strategy` through `rewrite_marker`. +`flush_matched_block` selects the matched-block strategy, while +`rewrite_fence_line` only dispatches it and falls back to the original line. ### Architecture diff --git a/src/fences_properties.rs b/src/fences_properties.rs index 83c9e738..f2cf7067 100644 --- a/src/fences_properties.rs +++ b/src/fences_properties.rs @@ -15,7 +15,15 @@ fn fence_line_strategy() -> impl ProptestStrategy Date: Fri, 24 Jul 2026 02:56:10 +0200 Subject: [PATCH 3/4] Restore fence test imports Reapply imports dropped while replaying the fence refactor so its snapshot and rstest coverage compile after the rebase.\n\nKeep the formatter's removal of an adjacent extra blank line. --- docs/developers-guide.md | 1 - tests/fences.rs | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 33fb6a67..5759ea93 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -648,7 +648,6 @@ Together, these helpers make the rest of the processing pipeline deal with a single normalized fence, and avoid carrying separate logic for detached specifier lines. - ### Strategy enum `Strategy` is the canonical private choice for fence-marker rewriting. Its diff --git a/tests/fences.rs b/tests/fences.rs index 592c4c41..222a6373 100644 --- a/tests/fences.rs +++ b/tests/fences.rs @@ -4,6 +4,9 @@ #[path = "common/mod.rs"] mod common; +use mdtablefix::{attach_orphan_specifiers, compress_fences}; +use rstest::rstest; + fn assert_fence_snapshot(name: &str, input: &[String]) { insta::with_settings!({ snapshot_path => "snapshots", From 750a1a331bd010bf45a0ea24049d5d9ce4a6f79a Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 24 Jul 2026 17:52:12 +0200 Subject: [PATCH 4/4] Clarify fence compression cache scope Describe the cached rewrite accurately: it avoids repeated compression work and supports unmatched-block handling, while preserved markers are rebuilt on demand. --- src/fences.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/fences.rs b/src/fences.rs index d6f14694..ac50eb3c 100644 --- a/src/fences.rs +++ b/src/fences.rs @@ -89,9 +89,8 @@ enum Strategy { /// A retained source line together with its compressed rewrite, computed once /// when the line was parsed. /// -/// Caching `compressed` here lets every flush path emit the line without running -/// the normalization regex again, including `flush_unmatched_block`, which may -/// rewrite any retained line. +/// Caching `compressed` avoids repeated compression work and supports +/// `flush_unmatched_block`, which rewrites only the opening delimiter. struct CachedLine { line: String, /// The line rewritten with a compressed three-backtick delimiter, or `None`