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-22 17:01:03 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/rumdl (Old) and /work/SRC/openSUSE:Factory/.rumdl.new.11940 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "rumdl" Wed Apr 22 17:01:03 2026 rev:59 rq:1348691 version:0.1.78 Changes: -------- --- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes 2026-04-21 12:45:52.833062982 +0200 +++ /work/SRC/openSUSE:Factory/.rumdl.new.11940/rumdl.changes 2026-04-22 17:01:53.077672143 +0200 @@ -1,0 +2,12 @@ +Wed Apr 22 06:27:08 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.1.78: + * Fixed + - lsp: discover .config/rumdl.toml when walking up from a file + (9d32fa7) +- Update to version 0.1.77: + * Fixed + - md046: ignore container content when detecting code-block + style (2685388) + +------------------------------------------------------------------- Old: ---- rumdl-0.1.76.obscpio New: ---- rumdl-0.1.78.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ rumdl.spec ++++++ --- /var/tmp/diff_new_pack.Imlu0c/_old 2026-04-22 17:01:54.877746422 +0200 +++ /var/tmp/diff_new_pack.Imlu0c/_new 2026-04-22 17:01:54.881746587 +0200 @@ -17,7 +17,7 @@ Name: rumdl -Version: 0.1.76 +Version: 0.1.78 Release: 0 Summary: Markdown Linter written in Rust License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.Imlu0c/_old 2026-04-22 17:01:54.929748568 +0200 +++ /var/tmp/diff_new_pack.Imlu0c/_new 2026-04-22 17:01:54.933748733 +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.76</param> + <param name="revision">v0.1.78</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.Imlu0c/_old 2026-04-22 17:01:54.981750714 +0200 +++ /var/tmp/diff_new_pack.Imlu0c/_new 2026-04-22 17:01:54.985750879 +0200 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/rvben/rumdl.git</param> - <param name="changesrevision">3af73dca6bf9cf509a1cb24620e75c942385eb98</param></service></servicedata> + <param name="changesrevision">0fa78b2a41fc7f2c16cdb6ccc4b74d10d34a86e4</param></service></servicedata> (No newline at EOF) ++++++ rumdl-0.1.76.obscpio -> rumdl-0.1.78.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/CHANGELOG.md new/rumdl-0.1.78/CHANGELOG.md --- old/rumdl-0.1.76/CHANGELOG.md 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/CHANGELOG.md 2026-04-21 15:02:50.000000000 +0200 @@ -19,6 +19,20 @@ + + +## [0.1.78](https://github.com/rvben/rumdl/compare/v0.1.77...v0.1.78) - 2026-04-21 + +### Fixed + +- **lsp**: discover .config/rumdl.toml when walking up from a file ([9d32fa7](https://github.com/rvben/rumdl/commit/9d32fa76b8d6baac3387ce588758a3bec6a3390a)) + +## [0.1.77](https://github.com/rvben/rumdl/compare/v0.1.76...v0.1.77) - 2026-04-21 + +### Fixed + +- **md046**: ignore container content when detecting code-block style ([2685388](https://github.com/rvben/rumdl/commit/2685388a223764504d6a994118605d1a2890aadd)) + ## [0.1.76](https://github.com/rvben/rumdl/compare/v0.1.75...v0.1.76) - 2026-04-19 ### Fixed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/Cargo.lock new/rumdl-0.1.78/Cargo.lock --- old/rumdl-0.1.76/Cargo.lock 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/Cargo.lock 2026-04-21 15:02:50.000000000 +0200 @@ -2268,7 +2268,7 @@ [[package]] name = "rumdl" -version = "0.1.76" +version = "0.1.78" dependencies = [ "anyhow", "assert_cmd", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/Cargo.toml new/rumdl-0.1.78/Cargo.toml --- old/rumdl-0.1.76/Cargo.toml 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/Cargo.toml 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ [package] name = "rumdl" -version = "0.1.76" +version = "0.1.78" 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.76/npm/cli-darwin-arm64/package.json new/rumdl-0.1.78/npm/cli-darwin-arm64/package.json --- old/rumdl-0.1.76/npm/cli-darwin-arm64/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/cli-darwin-arm64/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-arm64", - "version": "0.1.76", + "version": "0.1.78", "description": "rumdl binary for macOS ARM64 (Apple Silicon)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/npm/cli-darwin-x64/package.json new/rumdl-0.1.78/npm/cli-darwin-x64/package.json --- old/rumdl-0.1.76/npm/cli-darwin-x64/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/cli-darwin-x64/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-x64", - "version": "0.1.76", + "version": "0.1.78", "description": "rumdl binary for macOS x64 (Intel)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/npm/cli-linux-arm64/package.json new/rumdl-0.1.78/npm/cli-linux-arm64/package.json --- old/rumdl-0.1.76/npm/cli-linux-arm64/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/cli-linux-arm64/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64", - "version": "0.1.76", + "version": "0.1.78", "description": "rumdl binary for Linux ARM64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/npm/cli-linux-arm64-musl/package.json new/rumdl-0.1.78/npm/cli-linux-arm64-musl/package.json --- old/rumdl-0.1.76/npm/cli-linux-arm64-musl/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/cli-linux-arm64-musl/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64-musl", - "version": "0.1.76", + "version": "0.1.78", "description": "rumdl binary for Linux ARM64 (musl/Alpine)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/npm/cli-linux-x64/package.json new/rumdl-0.1.78/npm/cli-linux-x64/package.json --- old/rumdl-0.1.76/npm/cli-linux-x64/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/cli-linux-x64/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64", - "version": "0.1.76", + "version": "0.1.78", "description": "rumdl binary for Linux x64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/npm/cli-linux-x64-musl/package.json new/rumdl-0.1.78/npm/cli-linux-x64-musl/package.json --- old/rumdl-0.1.76/npm/cli-linux-x64-musl/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/cli-linux-x64-musl/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64-musl", - "version": "0.1.76", + "version": "0.1.78", "description": "rumdl binary for Linux x64 (musl/Alpine)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/npm/cli-win32-x64/package.json new/rumdl-0.1.78/npm/cli-win32-x64/package.json --- old/rumdl-0.1.76/npm/cli-win32-x64/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/cli-win32-x64/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-win32-x64", - "version": "0.1.76", + "version": "0.1.78", "description": "rumdl binary for Windows x64", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/npm/rumdl/package.json new/rumdl-0.1.78/npm/rumdl/package.json --- old/rumdl-0.1.76/npm/rumdl/package.json 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/npm/rumdl/package.json 2026-04-21 15:02:50.000000000 +0200 @@ -1,6 +1,6 @@ { "name": "rumdl", - "version": "0.1.76", + "version": "0.1.78", "description": "A fast Markdown linter written in Rust", "license": "MIT", "repository": { @@ -33,12 +33,12 @@ "node": ">=18.0.0" }, "optionalDependencies": { - "@rumdl/cli-darwin-x64": "0.1.76", - "@rumdl/cli-darwin-arm64": "0.1.76", - "@rumdl/cli-linux-x64": "0.1.76", - "@rumdl/cli-linux-arm64": "0.1.76", - "@rumdl/cli-linux-x64-musl": "0.1.76", - "@rumdl/cli-linux-arm64-musl": "0.1.76", - "@rumdl/cli-win32-x64": "0.1.76" + "@rumdl/cli-darwin-x64": "0.1.78", + "@rumdl/cli-darwin-arm64": "0.1.78", + "@rumdl/cli-linux-x64": "0.1.78", + "@rumdl/cli-linux-arm64": "0.1.78", + "@rumdl/cli-linux-x64-musl": "0.1.78", + "@rumdl/cli-linux-arm64-musl": "0.1.78", + "@rumdl/cli-win32-x64": "0.1.78" } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/src/config/loading.rs new/rumdl-0.1.78/src/config/loading.rs --- old/rumdl-0.1.76/src/config/loading.rs 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/src/config/loading.rs 2026-04-21 15:02:50.000000000 +0200 @@ -11,7 +11,7 @@ use super::source_tracking::{ ConfigSource, ConfigValidationWarning, SourcedConfig, SourcedConfigFragment, SourcedGlobalConfig, SourcedValue, }; -use super::types::{Config, ConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES, RuleConfig}; +use super::types::{Config, ConfigError, GlobalConfig, MARKDOWNLINT_CONFIG_FILES, RUMDL_CONFIG_FILES, RuleConfig}; use super::validation::validate_config_sourced_internal; /// Maximum depth for extends chains to prevent runaway recursion @@ -415,7 +415,6 @@ fn discover_config_upward() -> Option<(std::path::PathBuf, std::path::PathBuf)> { use std::env; - const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]; const MAX_DEPTH: usize = 100; // Prevent infinite traversal let start_dir = match env::current_dir() { @@ -440,7 +439,7 @@ // Check for config files in order of precedence (only if not already found) if found_config.is_none() { - for config_name in CONFIG_FILES { + for config_name in RUMDL_CONFIG_FILES { let config_path = current_dir.join(config_name); if config_path.exists() { @@ -915,8 +914,6 @@ /// /// Returns the config file path if found. Does NOT use CWD. pub fn discover_config_for_dir(dir: &Path, project_root: &Path) -> Option<PathBuf> { - const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]; - let mut current_dir = dir.to_path_buf(); loop { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/src/config/types.rs new/rumdl-0.1.78/src/config/types.rs --- old/rumdl-0.1.76/src/config/types.rs 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/src/config/types.rs 2026-04-21 15:02:50.000000000 +0200 @@ -494,6 +494,20 @@ } } +/// Names of rumdl-native config files, searched in precedence order when +/// walking up a directory tree. +/// +/// This is the single source of truth for config discovery. Both the CLI +/// (`SourcedConfig::discover_config_upward`, `discover_config_for_dir`) and +/// the LSP (`RumdlLanguageServer::resolve_config_for_file`) must use this +/// list; any deviation causes silent config-not-found bugs where the CLI +/// recognises a config but the LSP does not (or vice versa). +/// +/// See `src/lsp/tests.rs::test_lsp_cli_resolver_parity_on_fixtures` for +/// the side-by-side resolver parity test that pins this invariant across +/// several directory layouts. +pub const RUMDL_CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", ".config/rumdl.toml", "pyproject.toml"]; + pub const MARKDOWNLINT_CONFIG_FILES: &[&str] = &[ ".markdownlint-cli2.jsonc", ".markdownlint-cli2.yaml", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/src/lsp/configuration.rs new/rumdl-0.1.78/src/lsp/configuration.rs --- old/rumdl-0.1.76/src/lsp/configuration.rs 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/src/lsp/configuration.rs 2026-04-21 15:02:50.000000000 +0200 @@ -8,7 +8,7 @@ use anyhow::Result; use tower_lsp::lsp_types::*; -use crate::config::Config; +use crate::config::{Config, MARKDOWNLINT_CONFIG_FILES, RUMDL_CONFIG_FILES}; use crate::rule::Rule; use super::server::{ConfigCacheEntry, RumdlLanguageServer}; @@ -342,16 +342,10 @@ // has since been created in the directory. If so, treat as a cache miss // so we pick up the new config file. if entry.from_global_fallback { - const CONFIG_FILES: &[&str] = &[ - ".rumdl.toml", - "rumdl.toml", - "pyproject.toml", - ".markdownlint.json", - ".markdownlint-cli2.jsonc", - ".markdownlint-cli2.yaml", - ".markdownlint-cli2.yml", - ]; - let config_now_exists = CONFIG_FILES.iter().any(|name| search_dir.join(name).exists()); + let config_now_exists = RUMDL_CONFIG_FILES + .iter() + .chain(MARKDOWNLINT_CONFIG_FILES.iter()) + .any(|name| search_dir.join(name).exists()); if config_now_exists { log::debug!( "Config cache fallback entry for {} is stale: config file now exists, re-resolving", @@ -403,10 +397,13 @@ let mut found_config: Option<(Config, Option<PathBuf>)> = None; loop { - // Try to find a config file in the current directory - const CONFIG_FILES: &[&str] = &[".rumdl.toml", "rumdl.toml", "pyproject.toml", ".markdownlint.json"]; - - for config_file_name in CONFIG_FILES { + // Try to find a config file in the current directory. + // + // Must mirror CLI discovery (`SourcedConfig::discover_config_for_dir`): + // rumdl-native files take precedence, then markdownlint files. Any drift + // here produces silent config-not-found bugs where the CLI recognises a + // config but the LSP does not. See `test_lsp_cli_resolver_parity_on_fixtures`. + for config_file_name in RUMDL_CONFIG_FILES.iter().chain(MARKDOWNLINT_CONFIG_FILES.iter()) { let config_path = current_dir.join(config_file_name); if config_path.exists() { // For pyproject.toml, verify it contains [tool.rumdl] section (same as CLI) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/src/lsp/tests.rs new/rumdl-0.1.78/src/lsp/tests.rs --- old/rumdl-0.1.76/src/lsp/tests.rs 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/src/lsp/tests.rs 2026-04-21 15:02:50.000000000 +0200 @@ -6668,3 +6668,243 @@ "rename_provider must be advertised by default" ); } + +/// Resolve the config file path that the LSP's per-file resolver picked, +/// by calling `resolve_config_for_file` and then inspecting the cache. +/// Used by parity tests to compare LSP and CLI resolution on the same tree. +async fn lsp_resolve_config_path(server: &RumdlLanguageServer, file: &std::path::Path) -> Option<PathBuf> { + let _ = server.resolve_config_for_file(file).await; + let search_dir = file.parent().unwrap_or(file).to_path_buf(); + server + .config_cache + .read() + .await + .get(&search_dir) + .and_then(|e| e.config_file.clone()) +} + +/// Verifies that the LSP's per-file resolver and the CLI's +/// `discover_config_for_dir` pick the same config file on a variety of +/// layouts (same filename set, same per-directory precedence, same +/// closer-ancestor-wins behaviour across levels, same pyproject.toml +/// `[tool.rumdl]` gating). Any future drift between the two paths — the +/// class of bug behind rumdl-vscode#115 — will fail this test. +#[tokio::test] +async fn test_lsp_cli_resolver_parity_on_fixtures() { + use crate::config::SourcedConfig; + use std::fs; + use tempfile::tempdir; + + struct Fixture { + name: &'static str, + /// (relative_path, contents); empty contents means "create the file empty" + files: &'static [(&'static str, &'static str)], + /// Relative path to the markdown file whose directory we resolve from + from: &'static str, + } + + let fixtures = &[ + Fixture { + name: "dotconfig_rumdl_at_root", + files: &[(".config/rumdl.toml", "[global]\n"), ("sub/deeper/test.md", "")], + from: "sub/deeper/test.md", + }, + Fixture { + name: "rumdl_toml_at_root", + files: &[("rumdl.toml", "[global]\n"), ("sub/test.md", "")], + from: "sub/test.md", + }, + Fixture { + name: "closer_wins_rumdl_over_dotconfig", + files: &[ + (".config/rumdl.toml", "[global]\n"), + ("sub/rumdl.toml", "[global]\n"), + ("sub/deeper/test.md", ""), + ], + from: "sub/deeper/test.md", + }, + Fixture { + name: "closer_markdownlint_beats_farther_rumdl", + files: &[ + (".config/rumdl.toml", "[global]\n"), + ("sub/.markdownlint.json", "{}"), + ("sub/deeper/test.md", ""), + ], + from: "sub/deeper/test.md", + }, + Fixture { + name: "rumdl_beats_markdownlint_same_dir", + files: &[ + ("sub/.config/rumdl.toml", "[global]\n"), + ("sub/.markdownlint.json", "{}"), + ("sub/test.md", ""), + ], + from: "sub/test.md", + }, + Fixture { + name: "pyproject_without_tool_rumdl_is_skipped", + files: &[ + ("pyproject.toml", "[tool.black]\nline-length = 100\n"), + (".config/rumdl.toml", "[global]\n"), + ("sub/test.md", ""), + ], + from: "sub/test.md", + }, + Fixture { + name: "pyproject_with_tool_rumdl_beats_nothing", + files: &[("pyproject.toml", "[tool.rumdl]\n"), ("sub/test.md", "")], + from: "sub/test.md", + }, + ]; + + for fx in fixtures { + let temp = tempdir().unwrap(); + let project = std::fs::canonicalize(temp.path()).unwrap(); + + for (rel, contents) in fx.files { + let path = project.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, contents).unwrap(); + } + + let md_file = project.join(fx.from); + let md_dir = md_file.parent().unwrap().to_path_buf(); + + // CLI resolution + let cli_path = SourcedConfig::discover_config_for_dir(&md_dir, &project); + + // LSP resolution (workspace root = project so walk-up bounds match CLI) + let server = create_test_server(); + { + let mut roots = server.workspace_roots.write().await; + roots.push(project.clone()); + } + let lsp_path = lsp_resolve_config_path(&server, &md_file).await; + + assert_eq!( + lsp_path.as_ref().map(|p| std::fs::canonicalize(p).unwrap()), + cli_path.as_ref().map(|p| std::fs::canonicalize(p).unwrap()), + "LSP and CLI must resolve the same config file for fixture `{}`. \ + LSP picked {:?}, CLI picked {:?}", + fx.name, + lsp_path, + cli_path, + ); + } +} + +/// Regression test for rumdl-vscode#115: an opt-in rule enabled via +/// `extend-enable` in a `.config/rumdl.toml` at a parent directory must fire +/// from the LSP, matching CLI behaviour. +/// +/// Before the fix, the LSP's walk-up search only looked for `.rumdl.toml`, +/// `rumdl.toml`, `pyproject.toml`, `.markdownlint.json` — missing +/// `.config/rumdl.toml` — so it fell through to `Config::default()` and +/// silently disabled opt-in rules. +#[tokio::test] +async fn test_resolve_config_finds_dotconfig_rumdl_toml() { + use std::fs; + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + let temp_path = temp_dir.path(); + + let project = temp_path.join("project"); + let dotconfig = project.join(".config"); + fs::create_dir_all(&dotconfig).unwrap(); + + let config_file = dotconfig.join("rumdl.toml"); + fs::write( + &config_file, + r#" +[global] +extend-enable = ["MD060"] + +[MD060] +style = "aligned" +"#, + ) + .unwrap(); + + // File nested below the config's parent directory + let deep_dir = project.join("sub").join("deeper"); + fs::create_dir_all(&deep_dir).unwrap(); + let test_file = deep_dir.join("test.md"); + fs::write(&test_file, "# Test\n").unwrap(); + + let server = create_test_server(); + { + let mut roots = server.workspace_roots.write().await; + roots.push(project.clone()); + } + + let config = server.resolve_config_for_file(&test_file).await; + + // MD060 must appear in extend_enable so opt-in filtering picks it up + assert!( + config.global.extend_enable.iter().any(|r| r == "MD060"), + "LSP should discover `.config/rumdl.toml` when walking up from a nested file. \ + extend_enable was {:?}", + config.global.extend_enable + ); + + // And the rule-specific [MD060] table should be loaded + assert!( + config.rules.contains_key("MD060"), + "LSP should load [MD060] table from `.config/rumdl.toml`" + ); +} + +/// Regression test for the exact scenario reported in rumdl-vscode#115: +/// the LSP server is launched without any workspace folder (single-file +/// open in VS Code), its CWD has no config, and the only relevant config +/// lives in an ancestor directory as `.config/rumdl.toml`. The resolver +/// must still find it by walking up to filesystem root. +#[tokio::test] +async fn test_resolve_config_no_workspace_finds_dotconfig_rumdl_toml() { + use std::fs; + use tempfile::tempdir; + + let temp_dir = tempdir().unwrap(); + // Canonicalize to avoid `/private/tmp` vs `/tmp` mismatches on macOS when + // the resolver walks up through symlink'd parents. + let temp_path = std::fs::canonicalize(temp_dir.path()).unwrap(); + + let project = temp_path.join("project"); + let dotconfig = project.join(".config"); + fs::create_dir_all(&dotconfig).unwrap(); + fs::write( + dotconfig.join("rumdl.toml"), + r#" +[global] +extend-enable = ["MD060"] + +[MD060] +style = "aligned" +"#, + ) + .unwrap(); + + let deep_dir = project.join("sub").join("deeper"); + fs::create_dir_all(&deep_dir).unwrap(); + let test_file = deep_dir.join("test.md"); + fs::write(&test_file, "# Test\n").unwrap(); + + let server = create_test_server(); + // Explicitly leave workspace_roots empty — this is the single-file-open + // scenario where VS Code launches the LSP with no workspace folder. + assert!(server.workspace_roots.read().await.is_empty()); + + let config = server.resolve_config_for_file(&test_file).await; + + assert!( + config.global.extend_enable.iter().any(|r| r == "MD060"), + "With no workspace root, LSP must still walk up to filesystem root \ + and discover `.config/rumdl.toml`. extend_enable was {:?}", + config.global.extend_enable + ); + assert!( + config.rules.contains_key("MD060"), + "With no workspace root, `[MD060]` table must still load" + ); +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.76/src/rules/md046_code_block_style.rs new/rumdl-0.1.78/src/rules/md046_code_block_style.rs --- old/rumdl-0.1.76/src/rules/md046_code_block_style.rs 2026-04-19 22:15:01.000000000 +0200 +++ new/rumdl-0.1.78/src/rules/md046_code_block_style.rs 2026-04-21 15:02:50.000000000 +0200 @@ -9,12 +9,21 @@ pub use md046_config::CodeBlockStyle; use md046_config::MD046Config; -/// Pre-computed context arrays for indented code block detection +/// Pre-computed context arrays for indented code block detection. struct IndentContext<'a> { in_list_context: &'a [bool], in_tab_context: &'a [bool], in_admonition_context: &'a [bool], - in_jsx_context: &'a [bool], + /// Lines belonging to a non-code container whose body can legitimately be + /// indented by 4+ spaces or contain verbatim fence markers: HTML/MDX + /// comments, raw HTML blocks, JSX blocks, mkdocstrings blocks, footnote + /// definitions, and blockquotes. + /// + /// These lines are excluded from `detect_style`'s style tally, from + /// `is_indented_code_block_with_context`, and from + /// `categorize_indented_blocks`'s fence rewriting — keeping detection in + /// lockstep with the warning-side skip list used in `check`. + in_comment_or_html: &'a [bool], } /// Rule MD046: Code block style @@ -271,8 +280,12 @@ return false; } - // Skip if inside a JSX component block - if ctx.in_jsx_context.get(i).copied().unwrap_or(false) { + // Skip if inside an HTML/MDX comment, raw HTML block, JSX block, + // mkdocstrings block, footnote definition, or blockquote. These + // containers can legitimately hold 4+ space indented text that is + // not a code block. Counting them would desync style detection from + // the warning-side skip list in `check`. + if ctx.in_comment_or_html.get(i).copied().unwrap_or(false) { return false; } @@ -283,7 +296,8 @@ && calculate_indentation_width_default(lines[i - 1]) >= 4 && !ctx.in_list_context[i - 1] && !(is_mkdocs && ctx.in_tab_context[i - 1]) - && !(is_mkdocs && ctx.in_admonition_context[i - 1]); + && !(is_mkdocs && ctx.in_admonition_context[i - 1]) + && !ctx.in_comment_or_html.get(i - 1).copied().unwrap_or(false); // If no blank line before and previous line is not indented code, // it's likely list continuation, not a code block @@ -294,6 +308,30 @@ true } + /// Pre-compute which lines sit inside a non-code container whose body may + /// legitimately be indented by 4+ spaces without being an indented code + /// block: HTML comments, raw HTML blocks, JSX blocks, MDX comments, + /// mkdocstrings blocks, footnote definitions, and blockquotes. + /// + /// This mirrors the skip list used in `check` when emitting indented + /// code-block warnings, keeping style detection and warning emission in + /// lockstep. + fn precompute_comment_or_html_context(ctx: &crate::lint_context::LintContext, line_count: usize) -> Vec<bool> { + (0..line_count) + .map(|i| { + ctx.line_info(i + 1).is_some_and(|info| { + info.in_html_comment + || info.in_mdx_comment + || info.in_html_block + || info.in_jsx_block + || info.in_mkdocstrings + || info.in_footnote_definition + || info.blockquote.is_some() + }) + }) + .collect() + } + /// Pre-compute which lines are in MkDocs tab context with a single forward pass fn precompute_mkdocs_tab_context(&self, lines: &[&str]) -> Vec<bool> { let mut in_tab_context = vec![false; lines.len()]; @@ -406,26 +444,16 @@ &self, lines: &[&str], is_mkdocs: bool, - in_list_context: &[bool], - in_tab_context: &[bool], - in_admonition_context: &[bool], - in_jsx_context: &[bool], + ictx: &IndentContext<'_>, ) -> (Vec<bool>, Vec<bool>) { let mut is_misplaced = vec![false; lines.len()]; let mut contains_fences = vec![false; lines.len()]; - let ictx = IndentContext { - in_list_context, - in_tab_context, - in_admonition_context, - in_jsx_context, - }; - // Find contiguous indented blocks and categorize them let mut i = 0; while i < lines.len() { // Find the start of an indented block - if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, &ictx) { + if !self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) { i += 1; continue; } @@ -434,8 +462,7 @@ let block_start = i; let mut block_end = i; - while block_end < lines.len() - && self.is_indented_code_block_with_context(lines, block_end, is_mkdocs, &ictx) + while block_end < lines.len() && self.is_indented_code_block_with_context(lines, block_end, is_mkdocs, ictx) { block_end += 1; } @@ -588,12 +615,28 @@ let mut fenced_count = 0; let mut indented_count = 0; - // Count all code block occurrences (prevalence-based approach) + // Count all code block occurrences (prevalence-based approach). + // + // Both counts must ignore fence markers and indented text that live + // inside a non-code container (HTML/MDX comments, raw HTML/JSX + // blocks, mkdocstrings, footnote definitions, blockquotes) so that + // the detected style stays in lockstep with the warning-side skip + // list in `check`. Without this, a document that contains a single + // real code block plus a fake fence or indented paragraph nested in + // a comment is wrongly classified and the real block gets flagged. let mut in_fenced = false; let mut prev_was_indented = false; for (i, line) in lines.iter().enumerate() { + let in_container = ictx.in_comment_or_html.get(i).copied().unwrap_or(false); + if self.is_fenced_code_block_start(line) { + if in_container { + // Fence marker inside a container — not a real fence, + // don't flip state or count it. + prev_was_indented = false; + continue; + } if !in_fenced { // Opening fence fenced_count += 1; @@ -602,6 +645,7 @@ // Closing fence in_fenced = false; } + prev_was_indented = false; } else if !in_fenced && self.is_indented_code_block_with_context(lines, i, is_mkdocs, ictx) { // Count each continuous indented block once if !prev_was_indented { @@ -669,9 +713,7 @@ let target_style = match self.config.style { CodeBlockStyle::Consistent => { let in_list_context = self.precompute_block_continuation_context(lines); - let in_jsx_context: Vec<bool> = (0..lines.len()) - .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block)) - .collect(); + let in_comment_or_html = Self::precompute_comment_or_html_context(ctx, lines.len()); let in_tab_context = if is_mkdocs { self.precompute_mkdocs_tab_context(lines) } else { @@ -686,7 +728,7 @@ in_list_context: &in_list_context, in_tab_context: &in_tab_context, in_admonition_context: &in_admonition_context, - in_jsx_context: &in_jsx_context, + in_comment_or_html: &in_comment_or_html, }; self.detect_style(lines, is_mkdocs, &ictx) .unwrap_or(CodeBlockStyle::Fenced) @@ -793,10 +835,7 @@ // Determine target style let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs; - // Pre-compute JSX block context from LineInfo - let in_jsx_context: Vec<bool> = (0..lines.len()) - .map(|i| ctx.line_info(i + 1).is_some_and(|info| info.in_jsx_block)) - .collect(); + let in_comment_or_html = Self::precompute_comment_or_html_context(ctx, lines.len()); // Pre-compute list, tab, and admonition contexts once let in_list_context = self.precompute_block_continuation_context(lines); @@ -811,38 +850,24 @@ vec![false; lines.len()] }; + let ictx = IndentContext { + in_list_context: &in_list_context, + in_tab_context: &in_tab_context, + in_admonition_context: &in_admonition_context, + in_comment_or_html: &in_comment_or_html, + }; + let target_style = match self.config.style { - CodeBlockStyle::Consistent => { - let ictx = IndentContext { - in_list_context: &in_list_context, - in_tab_context: &in_tab_context, - in_admonition_context: &in_admonition_context, - in_jsx_context: &in_jsx_context, - }; - self.detect_style(lines, is_mkdocs, &ictx) - .unwrap_or(CodeBlockStyle::Fenced) - } + CodeBlockStyle::Consistent => self + .detect_style(lines, is_mkdocs, &ictx) + .unwrap_or(CodeBlockStyle::Fenced), _ => self.config.style, }; // Categorize indented blocks: // - misplaced_fence_lines: complete fenced blocks that were over-indented (safe to dedent) // - unsafe_fence_lines: contain fence markers but aren't complete (skip fixing to avoid broken output) - let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks( - lines, - is_mkdocs, - &in_list_context, - &in_tab_context, - &in_admonition_context, - &in_jsx_context, - ); - - let ictx = IndentContext { - in_list_context: &in_list_context, - in_tab_context: &in_tab_context, - in_admonition_context: &in_admonition_context, - in_jsx_context: &in_jsx_context, - }; + let (misplaced_fence_lines, unsafe_fence_lines) = self.categorize_indented_blocks(lines, is_mkdocs, &ictx); let mut result = String::with_capacity(content.len()); let mut in_fenced_block = false; @@ -1059,13 +1084,14 @@ use super::*; use crate::lint_context::LintContext; - /// Test helper: detect_style with automatic context computation - fn detect_style_from_content( - rule: &MD046CodeBlockStyle, - content: &str, - is_mkdocs: bool, - in_jsx_context: &[bool], - ) -> Option<CodeBlockStyle> { + /// Test helper: detect_style with automatic context computation. + /// + /// The container context (HTML/MDX comments, HTML/JSX blocks, + /// mkdocstrings, footnote definitions, blockquotes) is not populated by + /// this helper — callers that need to exercise those paths should go + /// through the full `rule.check(&ctx)` entry point so the real LineInfo + /// is computed from a `LintContext`. + fn detect_style_from_content(rule: &MD046CodeBlockStyle, content: &str, is_mkdocs: bool) -> Option<CodeBlockStyle> { let lines: Vec<&str> = content.lines().collect(); let in_list_context = rule.precompute_block_continuation_context(&lines); let in_tab_context = if is_mkdocs { @@ -1078,11 +1104,12 @@ } else { vec![false; lines.len()] }; + let in_comment_or_html = vec![false; lines.len()]; let ictx = IndentContext { in_list_context: &in_list_context, in_tab_context: &in_tab_context, in_admonition_context: &in_admonition_context, - in_jsx_context, + in_comment_or_html: &in_comment_or_html, }; rule.detect_style(&lines, is_mkdocs, &ictx) } @@ -1396,7 +1423,7 @@ fn test_detect_style_fenced() { let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); let content = "```\ncode\n```"; - let style = detect_style_from_content(&rule, content, false, &[]); + let style = detect_style_from_content(&rule, content, false); assert_eq!(style, Some(CodeBlockStyle::Fenced)); } @@ -1405,7 +1432,7 @@ fn test_detect_style_indented() { let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); let content = "Text\n\n code\n\nMore"; - let style = detect_style_from_content(&rule, content, false, &[]); + let style = detect_style_from_content(&rule, content, false); assert_eq!(style, Some(CodeBlockStyle::Indented)); } @@ -1414,7 +1441,7 @@ fn test_detect_style_none() { let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); let content = "No code blocks here"; - let style = detect_style_from_content(&rule, content, false, &[]); + let style = detect_style_from_content(&rule, content, false); assert_eq!(style, None); } @@ -1540,11 +1567,11 @@ Content in second tab"#; // In MkDocs mode, tab content should not be detected as indented code blocks - let style = detect_style_from_content(&rule, content, true, &[]); + let style = detect_style_from_content(&rule, content, true); assert_eq!(style, None); // No code blocks detected // In standard mode, it would detect indented code blocks - let style = detect_style_from_content(&rule, content, false, &[]); + let style = detect_style_from_content(&rule, content, false); assert_eq!(style, Some(CodeBlockStyle::Indented)); } @@ -2205,6 +2232,156 @@ } #[test] + fn test_consistent_style_indented_html_comment() { + // Under the default `Consistent` style, indented content inside an + // HTML comment must not contribute to the document's code-block style + // tally. Otherwise a single fenced block alongside an indented HTML + // comment flips the detected style to `Indented`, emitting a spurious + // "Use indented code blocks" warning against the only real code block. + let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); + let content = "# MD046 false-positive reproduction\n\ + \n\ + <!--\n \ + This is just an indented comment, not a code block.\n\ + \n \ + A second line is required to trigger the false-positive.\n\ + \n \ + Actually, three lines are required.\n\ + -->\n\ + \n\ + ```md\n\ + This should be fine, since it's the only code block and therefore consistent.\n\ + ```\n"; + + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result, + vec![], + "A single fenced block and an indented HTML comment must produce no MD046 warnings", + ); + } + + #[test] + fn test_consistent_style_indented_html_block() { + // Indented content inside a raw HTML block (e.g. a `<div>` tag pair) + // must not count as an indented code block when `detect_style` picks + // the document's predominant style. + // + // Per CommonMark, a type-6 HTML block is terminated by a blank line, + // so the content here is kept contiguous to remain inside the block. + let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); + let content = "# Heading\n\ + \n\ + <div class=\"note\">\n \ + line one of indented html content\n \ + line two of indented html content\n \ + line three of indented html content\n\ + </div>\n\ + \n\ + ```md\n\ + real fenced block\n\ + ```\n"; + + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result, + vec![], + "Indented content inside a raw HTML block must not influence MD046 style detection", + ); + } + + #[test] + fn test_consistent_style_fake_fence_inside_html_comment() { + // Fence markers inside an HTML comment must not contribute to the + // fenced count during style detection. Otherwise a document whose + // only real code block is indented gets flagged "Use fenced code + // blocks" under `Consistent` because the verbatim ``` inside the + // comment ties the count. + let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); + let content = "# Title\n\ + \n\ + <!--\n\ + ```\n\ + fake fence inside comment\n\ + ```\n\ + -->\n\ + \n \ + real indented code block line 1\n \ + real indented code block line 2\n"; + + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result, + vec![], + "Fence markers inside an HTML comment must not influence MD046 style detection", + ); + } + + #[test] + fn test_consistent_style_indented_footnote_definition() { + // Footnote-definition continuation lines are commonly indented by 4+ + // spaces. They must not be counted as indented code blocks during + // style detection under `Consistent`. + let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); + let content = "# Heading\n\ + \n\ + Reference to a footnote[^note].\n\ + \n\ + [^note]: First line of the footnote.\n \ + Second indented continuation line.\n \ + Third indented continuation line.\n \ + Fourth indented continuation line.\n\ + \n\ + ```md\n\ + real fenced block\n\ + ```\n"; + + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result, + vec![], + "Footnote-definition continuation content must not influence MD046 style detection", + ); + } + + #[test] + fn test_consistent_style_indented_blockquote() { + // Indented content inside a blockquote (`> foo`) must not be + // counted as an indented code block by `detect_style`. The check-side + // skip list already excludes `blockquote.is_some()` for indented + // warnings, so detection must match to keep `Consistent` stable. + let rule = MD046CodeBlockStyle::new(CodeBlockStyle::Consistent); + let content = "# Heading\n\ + \n\ + > line one of quoted indented content\n\ + >\n\ + > line two of quoted indented content\n\ + >\n\ + > line three of quoted indented content\n\ + \n\ + ```md\n\ + real fenced block\n\ + ```\n"; + + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result, + vec![], + "Indented content inside a blockquote must not influence MD046 style detection", + ); + } + + #[test] fn test_four_space_indented_fence_is_not_valid_fence() { // Per CommonMark 0.31.2: "An opening code fence may be indented 0-3 spaces." // 4+ spaces means it's NOT a valid fence opener - it becomes an indented code block ++++++ rumdl.obsinfo ++++++ --- /var/tmp/diff_new_pack.Imlu0c/_old 2026-04-22 17:01:56.181800233 +0200 +++ /var/tmp/diff_new_pack.Imlu0c/_new 2026-04-22 17:01:56.193800729 +0200 @@ -1,5 +1,5 @@ name: rumdl -version: 0.1.76 -mtime: 1776629701 -commit: 3af73dca6bf9cf509a1cb24620e75c942385eb98 +version: 0.1.78 +mtime: 1776776570 +commit: 0fa78b2a41fc7f2c16cdb6ccc4b74d10d34a86e4 ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst /work/SRC/openSUSE:Factory/.rumdl.new.11940/vendor.tar.zst differ: char 7, line 1
