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

Reply via email to