Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package rumdl for openSUSE:Factory checked in at 2026-04-04 19:06:14 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/rumdl (Old) and /work/SRC/openSUSE:Factory/.rumdl.new.21863 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "rumdl" Sat Apr 4 19:06:14 2026 rev:53 rq:1344412 version:0.1.66 Changes: -------- --- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes 2026-04-02 17:44:09.830774942 +0200 +++ /work/SRC/openSUSE:Factory/.rumdl.new.21863/rumdl.changes 2026-04-04 19:08:08.102890444 +0200 @@ -1,0 +2,11 @@ +Fri Apr 03 05:29:21 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.1.66: + * Added + - vscode: implement multiline warning range support in VS Code + fix simulation (7e4e70b) + * Fixed + - md075: skip pipes inside math spans to prevent false + positives (0420515) + +------------------------------------------------------------------- Old: ---- rumdl-0.1.65.obscpio New: ---- rumdl-0.1.66.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ rumdl.spec ++++++ --- /var/tmp/diff_new_pack.7t9QJ8/_old 2026-04-04 19:08:09.322940466 +0200 +++ /var/tmp/diff_new_pack.7t9QJ8/_new 2026-04-04 19:08:09.330940794 +0200 @@ -17,7 +17,7 @@ Name: rumdl -Version: 0.1.65 +Version: 0.1.66 Release: 0 Summary: Markdown Linter written in Rust License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.7t9QJ8/_old 2026-04-04 19:08:09.378942762 +0200 +++ /var/tmp/diff_new_pack.7t9QJ8/_new 2026-04-04 19:08:09.390943254 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/rvben/rumdl.git</param> <param name="scm">git</param> <param name="submodules">enable</param> - <param name="revision">v0.1.65</param> + <param name="revision">v0.1.66</param> <param name="match-tag">v*.*.*</param> <param name="versionformat">@PARENT_TAG@</param> <param name="versionrewrite-pattern">v(.*)</param> ++++++ _servicedata ++++++ --- /var/tmp/diff_new_pack.7t9QJ8/_old 2026-04-04 19:08:09.426944730 +0200 +++ /var/tmp/diff_new_pack.7t9QJ8/_new 2026-04-04 19:08:09.434945058 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/rvben/rumdl.git</param> - <param name="changesrevision">87784fedd4dd421736fae5a63bcf990eb718cbca</param></service></servicedata> + <param name="changesrevision">d6ab169988ebd3576b11ddb41a0528ea33e42a90</param></service></servicedata> (No newline at EOF) ++++++ rumdl-0.1.65.obscpio -> rumdl-0.1.66.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/.github/workflows/performance.yml new/rumdl-0.1.66/.github/workflows/performance.yml --- old/rumdl-0.1.65/.github/workflows/performance.yml 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/.github/workflows/performance.yml 2026-04-02 23:40:29.000000000 +0200 @@ -14,17 +14,55 @@ default: 'false' type: boolean + # Gate complexity regressions on every PR + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + complexity-gate: + name: Complexity Regression Gate + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - uses: jdx/mise-action@v4 + with: + experimental: true + install: false + + - name: Install mise tools + run: | + for attempt in 1 2 3; do + mise install --yes && break + echo "Attempt $attempt failed, retrying in 10s..." + sleep 10 + done + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci + + - name: Run linear complexity regression tests + run: make test-complexity + env: + RUST_BACKTRACE: 1 + performance: name: Run Performance Tests runs-on: ubuntu-latest + if: github.event_name != 'pull_request' steps: - name: Checkout code uses: actions/checkout@v6 - - name: Setup mise - uses: jdx/mise-action@v4 + - uses: jdx/mise-action@v4 with: experimental: true install: false @@ -38,14 +76,14 @@ done - name: Install Rust components - run: | - rustup component add rustfmt - rustup component add clippy - cargo install cargo-nextest + run: rustup component add rustfmt clippy + + - uses: Swatinem/rust-cache@v2 + with: + shared-key: ci - name: Run performance tests - run: | - cargo nextest run --profile performance + run: make test-performance env: RUST_BACKTRACE: 1 RUST_LOG: ${{ inputs.verbose == 'true' && 'debug' || 'warn' }} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/CHANGELOG.md new/rumdl-0.1.66/CHANGELOG.md --- old/rumdl-0.1.65/CHANGELOG.md 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/CHANGELOG.md 2026-04-02 23:40:29.000000000 +0200 @@ -8,6 +8,17 @@ + +## [0.1.66](https://github.com/rvben/rumdl/compare/v0.1.65...v0.1.66) - 2026-04-02 + +### Added + +- **vscode**: implement multiline warning range support in VS Code fix simulation ([7e4e70b](https://github.com/rvben/rumdl/commit/7e4e70b64496609fb4b4038cd95fb0b3f911eda2)) + +### Fixed + +- **md075**: skip pipes inside math spans to prevent false positives ([0420515](https://github.com/rvben/rumdl/commit/0420515230f5f94aaa458e41c960cbd71074d313)) + ## [0.1.65](https://github.com/rvben/rumdl/compare/v0.1.64...v0.1.65) - 2026-04-02 ### Added diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/Cargo.lock new/rumdl-0.1.66/Cargo.lock --- old/rumdl-0.1.65/Cargo.lock 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/Cargo.lock 2026-04-02 23:40:29.000000000 +0200 @@ -2269,7 +2269,7 @@ [[package]] name = "rumdl" -version = "0.1.65" +version = "0.1.66" dependencies = [ "anyhow", "assert_cmd", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/Cargo.toml new/rumdl-0.1.66/Cargo.toml --- old/rumdl-0.1.65/Cargo.toml 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/Cargo.toml 2026-04-02 23:40:29.000000000 +0200 @@ -1,6 +1,6 @@ [package] name = "rumdl" -version = "0.1.65" +version = "0.1.66" edition = "2024" rust-version = "1.94.0" description = "A fast Markdown linter written in Rust (Ru(st) MarkDown Linter)" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/src/config/tests.rs new/rumdl-0.1.66/src/config/tests.rs --- old/rumdl-0.1.65/src/config/tests.rs 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/src/config/tests.rs 2026-04-02 23:40:29.000000000 +0200 @@ -2839,3 +2839,198 @@ ".rumdl.toml should be in loaded_files" ); } + +#[test] +fn test_extends_base_values_propagate_when_child_silent() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("base.toml"), "[global]\ndisable = [\"MD013\"]\n").unwrap(); + fs::write(dir.path().join(".rumdl.toml"), "extends = \"base.toml\"\n").unwrap(); + + let sourced = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(".rumdl.toml").to_str().unwrap()), + None, + true, + None, + ) + .unwrap(); + let config: Config = sourced.into_validated_unchecked().into(); + + assert_eq!(config.global.disable, vec!["MD013".to_string()]); +} + +#[test] +fn test_extends_child_disable_replaces_base() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("base.toml"), "[global]\ndisable = [\"MD013\"]\n").unwrap(); + fs::write( + dir.path().join(".rumdl.toml"), + "extends = \"base.toml\"\n[global]\ndisable = [\"MD001\"]\n", + ) + .unwrap(); + + let sourced = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(".rumdl.toml").to_str().unwrap()), + None, + true, + None, + ) + .unwrap(); + let config: Config = sourced.into_validated_unchecked().into(); + + assert_eq!(config.global.disable, vec!["MD001".to_string()]); +} + +#[test] +fn test_extends_three_level_chain_propagates_from_root() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("root.toml"), "[global]\ndisable = [\"MD013\"]\n").unwrap(); + fs::write(dir.path().join("middle.toml"), "extends = \"root.toml\"\n").unwrap(); + fs::write(dir.path().join(".rumdl.toml"), "extends = \"middle.toml\"\n").unwrap(); + + let sourced = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(".rumdl.toml").to_str().unwrap()), + None, + true, + None, + ) + .unwrap(); + let config: Config = sourced.into_validated_unchecked().into(); + + assert_eq!(config.global.disable, vec!["MD013".to_string()]); +} + +#[test] +fn test_extends_rule_config_inherits_from_base() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("base.toml"), "[MD013]\nline-length = 120\n").unwrap(); + fs::write(dir.path().join(".rumdl.toml"), "extends = \"base.toml\"\n").unwrap(); + + let sourced = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(".rumdl.toml").to_str().unwrap()), + None, + true, + None, + ) + .unwrap(); + let config: Config = sourced.into_validated_unchecked().into(); + + let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length"); + assert_eq!(line_length, Some(120)); +} + +#[test] +fn test_extends_child_rule_config_overrides_base() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("base.toml"), "[MD013]\nline-length = 100\n").unwrap(); + fs::write( + dir.path().join(".rumdl.toml"), + "extends = \"base.toml\"\n[MD013]\nline-length = 160\n", + ) + .unwrap(); + + let sourced = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(".rumdl.toml").to_str().unwrap()), + None, + true, + None, + ) + .unwrap(); + let config: Config = sourced.into_validated_unchecked().into(); + + let line_length = get_rule_config_value::<usize>(&config, "MD013", "line-length"); + assert_eq!(line_length, Some(160)); +} + +#[test] +fn test_extends_enable_wins_over_inherited_disable() { + let dir = tempdir().unwrap(); + fs::write( + dir.path().join("base.toml"), + "[global]\ndisable = [\"MD013\", \"MD001\"]\n", + ) + .unwrap(); + fs::write( + dir.path().join(".rumdl.toml"), + "extends = \"base.toml\"\n[global]\nenable = [\"MD001\"]\n", + ) + .unwrap(); + + let sourced = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(".rumdl.toml").to_str().unwrap()), + None, + true, + None, + ) + .unwrap(); + let config: Config = sourced.into_validated_unchecked().into(); + + assert!( + !config.global.disable.contains(&"MD001".to_string()), + "MD001 should not be disabled when explicitly enabled" + ); + assert!( + config.global.disable.contains(&"MD013".to_string()), + "MD013 should still be disabled (only MD001 was re-enabled)" + ); +} + +#[test] +fn test_extends_cycle_returns_error() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join("a.toml"), "extends = \"b.toml\"\n").unwrap(); + fs::write(dir.path().join("b.toml"), "extends = \"a.toml\"\n").unwrap(); + + let result = + SourcedConfig::load_with_discovery_impl(Some(dir.path().join("a.toml").to_str().unwrap()), None, true, None); + + assert!( + matches!(result, Err(ConfigError::CircularExtends { .. })), + "Expected CircularExtends error, got: {result:?}" + ); +} + +#[test] +fn test_extends_missing_file_returns_error() { + let dir = tempdir().unwrap(); + fs::write(dir.path().join(".rumdl.toml"), "extends = \"nonexistent.toml\"\n").unwrap(); + + let result = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(".rumdl.toml").to_str().unwrap()), + None, + true, + None, + ); + + assert!( + matches!(result, Err(ConfigError::ExtendsNotFound { .. })), + "Expected ExtendsNotFound error, got: {result:?}" + ); +} + +#[test] +fn test_extends_depth_limit_returns_error() { + let dir = tempdir().unwrap(); + // Build MAX_EXTENDS_DEPTH + 1 levels so the loader hits the depth guard. + // Mirrors MAX_EXTENDS_DEPTH = 10 from src/config/loading.rs. + let max_depth: usize = 10; + fs::write(dir.path().join("level_0.toml"), "[global]\n").unwrap(); + for i in 1..=max_depth { + fs::write( + dir.path().join(format!("level_{i}.toml")), + format!("extends = \"level_{}.toml\"\n", i - 1), + ) + .unwrap(); + } + + let result = SourcedConfig::load_with_discovery_impl( + Some(dir.path().join(format!("level_{max_depth}.toml")).to_str().unwrap()), + None, + true, + None, + ); + + assert!( + matches!(result, Err(ConfigError::ExtendsDepthExceeded { .. })), + "Expected ExtendsDepthExceeded error, got: {result:?}" + ); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/src/formatter.rs new/rumdl-0.1.66/src/formatter.rs --- old/rumdl-0.1.65/src/formatter.rs 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/src/formatter.rs 2026-04-02 23:40:29.000000000 +0200 @@ -545,3 +545,126 @@ diff } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_diff_identical_content_reports_no_changes() { + let content = "line one\nline two\nline three\n"; + let result = generate_diff(content, content, "test.md"); + assert!( + result.contains("No changes"), + "Expected 'No changes' for identical inputs, got:\n{result}" + ); + } + + #[test] + fn test_generate_diff_single_line_change() { + let original = "line one\nline two\nline three\n"; + let modified = "line one\nLINE TWO\nline three\n"; + let result = generate_diff(original, modified, "test.md"); + + assert!(result.contains("--- test.md"), "Missing original header"); + assert!(result.contains("+++ test.md (fixed)"), "Missing modified header"); + assert!(result.contains("-line two"), "Missing removed line"); + assert!(result.contains("+LINE TWO"), "Missing added line"); + } + + #[test] + fn test_generate_diff_line_added_to_modified() { + let original = "line one\nline three\n"; + let modified = "line one\nline two\nline three\n"; + let result = generate_diff(original, modified, "test.md"); + + assert!(result.contains("+line two"), "Expected added line in diff"); + } + + #[test] + fn test_generate_diff_line_removed_from_original() { + let original = "line one\nline two\nline three\n"; + let modified = "line one\nline three\n"; + let result = generate_diff(original, modified, "test.md"); + + assert!(result.contains("-line two"), "Expected removed line in diff"); + } + + #[test] + fn test_generate_diff_includes_three_lines_of_context() { + let lines: Vec<String> = (1..=10).map(|i| format!("line {i}")).collect(); + let mut modified = lines.clone(); + modified[4] = "CHANGED".to_string(); + + let original_str = lines.join("\n"); + let modified_str = modified.join("\n"); + let result = generate_diff(&original_str, &modified_str, "test.md"); + + assert!(result.contains(" line 4"), "Expected context line before change"); + assert!(result.contains(" line 6"), "Expected context line after change"); + assert!(result.contains("-line 5"), "Expected removed line"); + assert!(result.contains("+CHANGED"), "Expected added line"); + } + + #[test] + fn test_generate_diff_hunk_header_format() { + let original = "a\nb\nc\n"; + let modified = "a\nB\nc\n"; + let result = generate_diff(original, modified, "f.md"); + + assert!(result.contains("@@"), "Expected @@ hunk header in diff:\n{result}"); + } + + #[test] + fn test_format_toml_value_string_is_quoted() { + let val = toml::Value::String("hello world".to_string()); + assert_eq!(format_toml_value(&val), "\"hello world\""); + } + + #[test] + fn test_format_toml_value_integer() { + let val = toml::Value::Integer(42); + assert_eq!(format_toml_value(&val), "42"); + } + + #[test] + fn test_format_toml_value_boolean_true() { + assert_eq!(format_toml_value(&toml::Value::Boolean(true)), "true"); + } + + #[test] + fn test_format_toml_value_boolean_false() { + assert_eq!(format_toml_value(&toml::Value::Boolean(false)), "false"); + } + + #[test] + fn test_format_toml_value_array_of_strings() { + let val = toml::Value::Array(vec![ + toml::Value::String("a".to_string()), + toml::Value::String("b".to_string()), + ]); + assert_eq!(format_toml_value(&val), r#"["a", "b"]"#); + } + + #[test] + fn test_format_toml_value_empty_array() { + let val = toml::Value::Array(vec![]); + assert_eq!(format_toml_value(&val), "[]"); + } + + #[test] + fn test_format_toml_value_table_is_placeholder() { + let val = toml::Value::Table(toml::map::Map::new()); + assert_eq!(format_toml_value(&val), "<table>"); + } + + #[test] + fn test_format_toml_value_nested_array() { + let val = toml::Value::Array(vec![ + toml::Value::Integer(1), + toml::Value::Integer(2), + toml::Value::Integer(3), + ]); + assert_eq!(format_toml_value(&val), "[1, 2, 3]"); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/src/rules/md075_orphaned_table_rows.rs new/rumdl-0.1.66/src/rules/md075_orphaned_table_rows.rs --- old/rumdl-0.1.65/src/rules/md075_orphaned_table_rows.rs 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/src/rules/md075_orphaned_table_rows.rs 2026-04-02 23:40:29.000000000 +0200 @@ -48,7 +48,7 @@ Self { md060_formatter } } - /// Check if a line should be skipped (frontmatter, code block, HTML, ESM, mkdocstrings) + /// Check if a line should be skipped (frontmatter, code block, HTML, ESM, mkdocstrings, math) fn should_skip_line(&self, ctx: &crate::lint_context::LintContext, line_idx: usize) -> bool { if let Some(line_info) = ctx.lines.get(line_idx) { line_info.in_front_matter @@ -58,6 +58,7 @@ || line_info.in_mdx_comment || line_info.in_esm_block || line_info.in_mkdocstrings + || line_info.in_math_block } else { false } @@ -1300,6 +1301,34 @@ } #[test] + fn test_display_math_block_with_pipes_not_flagged() { + let rule = MD075OrphanedTableRows::default(); + let content = "# Math\n\n$$\n|A| + |B| = |A \\cup B|\n|A| + |B| = |A \\cup B|\n$$\n"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Pipes inside display math blocks should not trigger MD075" + ); + } + + #[test] + fn test_math_absolute_value_bars_not_flagged() { + let rule = MD075OrphanedTableRows::default(); + let content = "\ +# Math + +Roughly (for privacy reasons, this isn't exactly what the student said), +the student talked about having done small cases on the size $|S|$, +and figuring out that $|S|$ was even, but then running out of ideas."; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert!(result.is_empty(), "Math absolute value bars should not trigger MD075"); + } + + #[test] fn test_prose_with_double_backticks_and_pipes_not_flagged() { let rule = MD075OrphanedTableRows::default(); let content = "\ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/src/utils/table_utils.rs new/rumdl-0.1.66/src/utils/table_utils.rs --- old/rumdl-0.1.65/src/utils/table_utils.rs 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/src/utils/table_utils.rs 2026-04-02 23:40:29.000000000 +0200 @@ -29,27 +29,36 @@ pub struct TableUtils; impl TableUtils { - /// Returns true if the line has at least one unescaped pipe separator outside inline code spans. + /// Returns true if the line has at least one unescaped pipe separator outside inline code and + /// math spans. /// - /// This helps distinguish actual table separators from command/prose examples like - /// `` `echo a | sed 's/a/b/'` `` where the pipe is fully inside inline code. - fn has_unescaped_pipe_outside_inline_code(text: &str) -> bool { + /// Skips pipes inside backtick code spans (`` `...` ``) and dollar-sign math spans (`$...$`, + /// `$$...$$`) to avoid false positives from prose like `` `echo a | sed 's/a/b/'` `` or math + /// like `$|S|$` (absolute value notation). + /// + /// Note: a bare `$` that opens a span without a matching closing `$` keeps the scanner in + /// math mode for the rest of the line, suppressing any subsequent pipes. This is conservative + /// and means that `$5 | $10`-style price comparisons (without outer pipes) are not detected + /// as table separators — an accepted trade-off to avoid false positives from real math. + fn has_unescaped_pipe_outside_spans(text: &str) -> bool { let chars: Vec<char> = text.chars().collect(); let mut i = 0; let mut in_code = false; let mut code_delim_len = 0usize; + let mut in_math = false; + let mut math_delim_len = 0usize; while i < chars.len() { let ch = chars[i]; - if ch == '\\' && !in_code { - // Skip escaped character (only outside code spans — + if ch == '\\' && !in_code && !in_math { + // Skip escaped character (only outside code and math spans — // backslashes are literal inside code spans per CommonMark). i += if i + 1 < chars.len() { 2 } else { 1 }; continue; } - if ch == '`' { + if ch == '`' && !in_math { let mut run = 1usize; while i + run < chars.len() && chars[i + run] == '`' { run += 1; @@ -60,6 +69,7 @@ in_code = false; code_delim_len = 0; } + // Mismatched backtick run inside a code span: consumed but span stays open. } else { in_code = true; code_delim_len = run; @@ -69,7 +79,28 @@ continue; } - if ch == '|' && !in_code { + if ch == '$' && !in_code { + let mut run = 1usize; + while i + run < chars.len() && chars[i + run] == '$' { + run += 1; + } + + if in_math { + if run == math_delim_len { + in_math = false; + math_delim_len = 0; + } + // Mismatched $-run inside a math span: consumed but span stays open. + } else { + in_math = true; + math_delim_len = run; + } + + i += run; + continue; + } + + if ch == '|' && !in_code && !in_math { return true; } @@ -124,9 +155,9 @@ } // For rows without explicit outer pipes, require a real separator outside - // inline code spans to avoid prose/command false positives. + // inline code and math spans to avoid prose/command false positives. let has_outer_pipes = trimmed.starts_with('|') && trimmed.ends_with('|'); - if !has_outer_pipes && !Self::has_unescaped_pipe_outside_inline_code(trimmed) { + if !has_outer_pipes && !Self::has_unescaped_pipe_outside_spans(trimmed) { return false; } @@ -939,6 +970,24 @@ assert!(TableUtils::is_potential_table_row("`!foo && bar` | `(!foo) && bar`")); assert!(!TableUtils::is_potential_table_row("`echo a | sed 's/a/b/'`")); + // Math spans: pipes inside $...$ are not table separators + assert!(!TableUtils::is_potential_table_row( + "Text with $|S|$ math notation here." + )); + assert!(!TableUtils::is_potential_table_row( + "Size $|S|$ was even, check $|T|$ too." + )); + assert!(!TableUtils::is_potential_table_row("Display $$|A| + |B|$$ math here.")); + // Math pipe in cell with outer pipes is still a table row + assert!(TableUtils::is_potential_table_row("| cell with $|S|$ math |")); + // Pipe after fully closed math spans is still detected + assert!(TableUtils::is_potential_table_row("$a$ | $b$")); + assert!(TableUtils::is_potential_table_row("$f(x)$ and $g(x)$ | result")); + // $5 | $10 style price comparisons are suppressed as a deliberate trade-off: + // the leading $ opens a math span, consuming the pipe. Tables with bare dollar + // amounts should use outer pipes (| $5 | $10 |) to be correctly detected. + assert!(!TableUtils::is_potential_table_row("$5 | $10")); + // Single pipe not enough assert!(!TableUtils::is_potential_table_row("Just one |")); assert!(!TableUtils::is_potential_table_row("| Just one")); @@ -1642,13 +1691,13 @@ fn test_has_unescaped_pipe_backslash_literal_in_code_span() { // Per CommonMark: backslashes are literal inside code spans. // `foo\` is a complete code span, so the pipe after it is outside code. - assert!(TableUtils::has_unescaped_pipe_outside_inline_code(r"`foo\` | bar")); + assert!(TableUtils::has_unescaped_pipe_outside_spans(r"`foo\` | bar")); // Escaped backtick outside code span: \` is not a code span opener - assert!(TableUtils::has_unescaped_pipe_outside_inline_code(r"\`foo | bar\`")); + assert!(TableUtils::has_unescaped_pipe_outside_spans(r"\`foo | bar\`")); // Pipe inside code span should not count - assert!(!TableUtils::has_unescaped_pipe_outside_inline_code(r"`foo | bar`")); + assert!(!TableUtils::has_unescaped_pipe_outside_spans(r"`foo | bar`")); } #[test] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/tests/cli/check_runner_tests.rs new/rumdl-0.1.66/tests/cli/check_runner_tests.rs --- old/rumdl-0.1.65/tests/cli/check_runner_tests.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/rumdl-0.1.66/tests/cli/check_runner_tests.rs 2026-04-02 23:40:29.000000000 +0200 @@ -0,0 +1,116 @@ +use std::fs; +use std::io::Write; +use std::process::{Command, Stdio}; +use tempfile::tempdir; + +fn rumdl() -> Command { + Command::new(env!("CARGO_BIN_EXE_rumdl")) +} + +#[test] +fn test_parallel_issue_count_is_deterministic() { + let dir = tempdir().unwrap(); + + for i in 0..20 { + fs::write( + dir.path().join(format!("file_{i:02}.md")), + format!("# File {i}\n\nLine with trailing spaces \n"), + ) + .unwrap(); + } + + let run = || { + rumdl() + .args(["check", ".", "--enable", "MD009"]) + .current_dir(dir.path()) + .output() + .expect("Failed to run rumdl") + }; + + let first = run(); + let second = run(); + + let stdout1 = String::from_utf8_lossy(&first.stdout); + let stdout2 = String::from_utf8_lossy(&second.stdout); + + // rumdl processes files in parallel so per-file line order is non-deterministic, + // but the total issue count must be stable across runs. + assert!( + stdout1.contains("Found 20 issues"), + "First run: expected 20 issues, got:\n{stdout1}" + ); + assert!( + stdout2.contains("Found 20 issues"), + "Second run: expected 20 issues, got:\n{stdout2}" + ); + + // Every file must appear in the output of both runs. + for i in 0..20 { + let name = format!("file_{i:02}.md"); + assert!(stdout1.contains(&name), "First run missing {name}:\n{stdout1}"); + assert!(stdout2.contains(&name), "Second run missing {name}:\n{stdout2}"); + } +} + +#[test] +fn test_per_directory_config_selects_nearest_ancestor() { + let dir = tempdir().unwrap(); + + fs::write(dir.path().join(".rumdl.toml"), "[global]\ndisable = [\"MD009\"]\n").unwrap(); + + let strict = dir.path().join("subdir").join("strict"); + fs::create_dir_all(&strict).unwrap(); + fs::write(strict.join(".rumdl.toml"), "[global]\nenable = [\"MD009\"]\n").unwrap(); + + fs::write(dir.path().join("root_file.md"), "# Root\n\nTrailing spaces here \n").unwrap(); + fs::write(strict.join("strict_file.md"), "# Strict\n\nTrailing spaces here \n").unwrap(); + + let output = rumdl() + .args(["check", "."]) + .current_dir(dir.path()) + .output() + .expect("Failed to run rumdl"); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + + assert!( + combined.contains("strict_file.md"), + "Expected strict_file.md to appear in violations:\n{combined}" + ); + + assert!( + !combined.contains("root_file.md"), + "root_file.md should not have violations (MD009 disabled by root config):\n{combined}" + ); +} + +#[test] +fn test_stdin_input_is_linted() { + let mut child = rumdl() + .args(["check", "--stdin", "--enable", "MD009", "--stdin-filename", "stdin.md"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("Failed to spawn rumdl"); + + child + .stdin + .as_mut() + .unwrap() + .write_all(b"# Test\n\nTrailing spaces \n") + .unwrap(); + + let output = child.wait_with_output().unwrap(); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{stdout}{stderr}"); + + assert!( + !output.status.success(), + "Expected non-zero exit for stdin with violations:\n{combined}" + ); + assert!(combined.contains("MD009"), "Expected MD009 in output:\n{combined}"); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/tests/cli/mod.rs new/rumdl-0.1.66/tests/cli/mod.rs --- old/rumdl-0.1.65/tests/cli/mod.rs 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/tests/cli/mod.rs 2026-04-02 23:40:29.000000000 +0200 @@ -1,3 +1,4 @@ +mod check_runner_tests; mod cli_alias_test; mod cli_duplication_test; mod cli_explain_test; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.65/tests/vscode/vscode_extension_fixes.rs new/rumdl-0.1.66/tests/vscode/vscode_extension_fixes.rs --- old/rumdl-0.1.65/tests/vscode/vscode_extension_fixes.rs 2026-04-02 10:17:06.000000000 +0200 +++ new/rumdl-0.1.66/tests/vscode/vscode_extension_fixes.rs 2026-04-02 23:40:29.000000000 +0200 @@ -64,7 +64,28 @@ Ok(result_lines.join("\n") + if content.ends_with('\n') { "\n" } else { "" }) } else { - Err("Multi-line warning ranges not implemented yet".to_string()) + if warning_end_line > lines.len() { + return Err("Invalid warning end line number".to_string()); + } + + let start_line_content = lines[warning_start_line - 1]; + let end_line_content = lines[warning_end_line - 1]; + + let start_byte = warning_start_col.saturating_sub(1); + let end_byte = warning_end_col.saturating_sub(1); + + if start_byte > start_line_content.len() || end_byte > end_line_content.len() { + return Err("Invalid warning column range for multiline fix".to_string()); + } + + let before = &start_line_content[..start_byte]; + let after = &end_line_content[end_byte..]; + let new_line = format!("{}{}{}", before, fix.replacement, after); + + let mut result_lines: Vec<String> = lines.iter().map(|s| s.to_string()).collect(); + result_lines.splice((warning_start_line - 1)..warning_end_line, std::iter::once(new_line)); + + Ok(result_lines.join("\n") + if content.ends_with('\n') { "\n" } else { "" }) } } @@ -462,4 +483,126 @@ "All rules with fixes should pass the duplication test" ); } + + #[test] + fn test_simulate_vscode_fix_multiline_splice_is_correct() { + use rumdl_lib::config::{Config, MarkdownFlavor}; + use rumdl_lib::lint_context::LintContext; + use rumdl_lib::rule::{ + CrossFileScope, Fix, FixCapability, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity, + }; + + // A test-only rule that returns a single warning spanning lines 2–3, + // with a fixed replacement string. This exercises the multiline splice + // branch of simulate_vscode_fix directly. + #[derive(Clone)] + struct MultilineTestRule; + + impl Rule for MultilineTestRule { + fn name(&self) -> &'static str { + "TEST" + } + fn description(&self) -> &'static str { + "Test rule for multiline splice" + } + fn check(&self, _ctx: &LintContext) -> LintResult { + // Warning spans line 2 col 1 → line 3 col 6. + // end_column 6 is exclusive: "END X" has 5 chars, so col 6 points past the last char. + Ok(vec![LintWarning { + line: 2, + column: 1, + end_line: 3, + end_column: 6, + message: "test".to_string(), + fix: Some(Fix { + range: 0..0, + replacement: "REPLACED".to_string(), + }), + severity: Severity::Warning, + rule_name: Some("TEST".to_string()), + }]) + } + fn fix(&self, ctx: &LintContext) -> Result<String, LintError> { + Ok(ctx.content.to_string()) + } + fn category(&self) -> RuleCategory { + RuleCategory::Other + } + fn as_any(&self) -> &dyn std::any::Any { + self + } + fn fix_capability(&self) -> FixCapability { + FixCapability::FullyFixable + } + fn cross_file_scope(&self) -> CrossFileScope { + CrossFileScope::None + } + fn from_config(_config: &Config) -> Box<dyn Rule> + where + Self: Sized, + { + Box::new(MultilineTestRule) + } + } + + // 4-line content: line 1 "BEFORE", line 2 "START HERE", line 3 "END X", line 4 "AFTER" + // The warning spans line 2 col 1 → line 3 col 6 (the entirety of "START HERE\nEND X"). + // After the splice, lines 2–3 should be replaced with "REPLACED". + let content = "BEFORE\nSTART HERE\nEND X\nAFTER\n"; + let rule = MultilineTestRule; + + // Verify the warning is indeed multiline before testing the fix path. + let ctx = LintContext::new(content, MarkdownFlavor::Standard, None); + let warnings = rule.check(&ctx).unwrap(); + let multiline_warning = warnings + .iter() + .find(|w| w.end_line != w.line) + .expect("MultilineTestRule should produce a multiline warning"); + assert_eq!(multiline_warning.line, 2); + assert_eq!(multiline_warning.end_line, 3); + + let result = simulate_vscode_fix(content, &rule); + let fixed = result.expect("Multiline fix should succeed"); + + // Lines 2–3 ("START HERE" and "END X") should be replaced by "REPLACED". + assert_eq!( + fixed, "BEFORE\nREPLACED\nAFTER\n", + "Multiline splice should replace lines 2–3 with the replacement text. Got: {fixed:?}" + ); + } + + #[test] + fn test_simulate_vscode_fix_handles_multiline_warning_range() { + use rumdl_lib::config::MarkdownFlavor; + use rumdl_lib::lint_context::LintContext; + + // This test verifies that when rules DO produce multiline warnings, + // simulate_vscode_fix handles them rather than returning "not implemented". + // Currently, no rules in create_test_case_for_rule produce multiline warnings + // with their test fixtures, so this loop is a no-op today. It acts as a + // regression guard for future rules. + let rule_names = [ + "MD001", "MD003", "MD004", "MD005", "MD007", "MD009", "MD010", "MD011", "MD012", "MD013", "MD014", "MD018", + "MD019", "MD020", "MD021", "MD022", "MD023", "MD024", "MD025", "MD026", "MD027", "MD028", "MD029", "MD030", + "MD031", "MD032", "MD033", "MD034", "MD035", "MD036", "MD037", "MD038", "MD039", "MD040", "MD041", "MD042", + "MD043", "MD044", "MD045", "MD046", "MD047", "MD048", "MD049", "MD050", "MD051", "MD052", "MD053", "MD054", + "MD055", "MD056", "MD057", "MD058", + ]; + + for rule_name in rule_names { + if let Some((content, rule)) = create_test_case_for_rule(rule_name) { + let ctx = LintContext::new(content, MarkdownFlavor::Standard, None); + if let Ok(warnings) = rule.check(&ctx) { + let has_multiline = warnings.iter().any(|w| w.end_line != w.line); + if has_multiline { + let result = simulate_vscode_fix(content, rule.as_ref()); + assert!( + result != Err("Multi-line warning ranges not implemented yet".to_string()), + "Rule {rule_name} has multiline warnings but simulate_vscode_fix returned 'not implemented'" + ); + } + } + } + } + } } ++++++ rumdl.obsinfo ++++++ --- /var/tmp/diff_new_pack.7t9QJ8/_old 2026-04-04 19:08:10.454986880 +0200 +++ /var/tmp/diff_new_pack.7t9QJ8/_new 2026-04-04 19:08:10.470987536 +0200 @@ -1,5 +1,5 @@ name: rumdl -version: 0.1.65 -mtime: 1775117826 -commit: 87784fedd4dd421736fae5a63bcf990eb718cbca +version: 0.1.66 +mtime: 1775166029 +commit: d6ab169988ebd3576b11ddb41a0528ea33e42a90 ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst /work/SRC/openSUSE:Factory/.rumdl.new.21863/vendor.tar.zst differ: char 7, line 1
