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-03-20 21:26:38 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/rumdl (Old) and /work/SRC/openSUSE:Factory/.rumdl.new.8177 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "rumdl" Fri Mar 20 21:26:38 2026 rev:47 rq:1341565 version:0.1.57 Changes: -------- --- /work/SRC/openSUSE:Factory/rumdl/rumdl.changes 2026-03-19 17:41:20.643670878 +0100 +++ /work/SRC/openSUSE:Factory/.rumdl.new.8177/rumdl.changes 2026-03-20 21:27:29.680686659 +0100 @@ -1,0 +2,33 @@ +Fri Mar 20 13:52:22 UTC 2026 - Johannes Kastl <[email protected]> + +- Update to version 0.1.57: + * Fixed + - MD041: MDX-style inline disable comments ({/* <!-- + rumdl-disable MD041 --> */}) now correctly suppress MD041 + (#538) + - MD041: Extracted shared first_content_line_idx() helper to + prevent check/fix path inconsistencies +- Update to version 0.1.56: + * Added + - MD057: Obsidian attachment folder auto-detection when flavor + = "obsidian" is set — supports all 4 Obsidian attachment + modes (vault root, named folder, same as file, subfolder + under file) (#537) + - MD057: New search-paths config option for specifying + additional directories to search when resolving relative + links +- Update to version 0.1.55: + * Fixed + - MD064: Fixed false positives inside indented fenced code + blocks when --- horizontal rules appear later in the document + (#536) + - Replaced Options::all() with an explicit pulldown-cmark + option allowlist, excluding + ENABLE_YAML_STYLE_METADATA_BLOCKS which misinterprets --- + horizontal rules as YAML metadata delimiters (works around + pulldown-cmark#1000) + - rumdl handles front matter detection independently and + correctly (requires --- at line 1, not anywhere in the + document) + +------------------------------------------------------------------- Old: ---- rumdl-0.1.54.obscpio New: ---- rumdl-0.1.57.obscpio ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ rumdl.spec ++++++ --- /var/tmp/diff_new_pack.0Lz9Ue/_old 2026-03-20 21:27:30.808733546 +0100 +++ /var/tmp/diff_new_pack.0Lz9Ue/_new 2026-03-20 21:27:30.808733546 +0100 @@ -17,7 +17,7 @@ Name: rumdl -Version: 0.1.54 +Version: 0.1.57 Release: 0 Summary: Markdown Linter written in Rust License: MIT ++++++ _service ++++++ --- /var/tmp/diff_new_pack.0Lz9Ue/_old 2026-03-20 21:27:30.852735375 +0100 +++ /var/tmp/diff_new_pack.0Lz9Ue/_new 2026-03-20 21:27:30.852735375 +0100 @@ -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.54</param> + <param name="revision">v0.1.57</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.0Lz9Ue/_old 2026-03-20 21:27:30.876736372 +0100 +++ /var/tmp/diff_new_pack.0Lz9Ue/_new 2026-03-20 21:27:30.880736539 +0100 @@ -1,6 +1,6 @@ <servicedata> <service name="tar_scm"> <param name="url">https://github.com/rvben/rumdl.git</param> - <param name="changesrevision">8eeed34c6f8f3bacc43f59a6a34f3c15dd594b20</param></service></servicedata> + <param name="changesrevision">b52183a24937296947580d86711ecde803ba6e83</param></service></servicedata> (No newline at EOF) ++++++ rumdl-0.1.54.obscpio -> rumdl-0.1.57.obscpio ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/CHANGELOG.md new/rumdl-0.1.57/CHANGELOG.md --- old/rumdl-0.1.54/CHANGELOG.md 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/CHANGELOG.md 2026-03-20 13:26:48.000000000 +0100 @@ -7,6 +7,28 @@ ## [Unreleased] +## [0.1.57] - 2026-03-20 + +### Fixed + +- **MD041**: MDX-style inline disable comments (`{/* <!-- rumdl-disable MD041 --> */}`) now correctly suppress MD041 ([#538](https://github.com/rvben/rumdl/issues/538)) +- **MD041**: Extracted shared `first_content_line_idx()` helper to prevent check/fix path inconsistencies + +## [0.1.56] - 2026-03-19 + +### Added + +- **MD057**: Obsidian attachment folder auto-detection when `flavor = "obsidian"` is set — supports all 4 Obsidian attachment modes (vault root, named folder, same as file, subfolder under file) ([#537](https://github.com/rvben/rumdl/issues/537)) +- **MD057**: New `search-paths` config option for specifying additional directories to search when resolving relative links + +## [0.1.55] - 2026-03-19 + +### Fixed + +- **MD064**: Fixed false positives inside indented fenced code blocks when `---` horizontal rules appear later in the document ([#536](https://github.com/rvben/rumdl/issues/536)) + - Replaced `Options::all()` with an explicit pulldown-cmark option allowlist, excluding `ENABLE_YAML_STYLE_METADATA_BLOCKS` which misinterprets `---` horizontal rules as YAML metadata delimiters (works around [pulldown-cmark#1000](https://github.com/pulldown-cmark/pulldown-cmark/issues/1000)) + - rumdl handles front matter detection independently and correctly (requires `---` at line 1, not anywhere in the document) + ## [0.1.54] - 2026-03-18 ### Fixed diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/Cargo.lock new/rumdl-0.1.57/Cargo.lock --- old/rumdl-0.1.54/Cargo.lock 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/Cargo.lock 2026-03-20 13:26:48.000000000 +0100 @@ -2247,7 +2247,7 @@ [[package]] name = "rumdl" -version = "0.1.54" +version = "0.1.57" dependencies = [ "anyhow", "assert_cmd", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/Cargo.toml new/rumdl-0.1.57/Cargo.toml --- old/rumdl-0.1.54/Cargo.toml 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/Cargo.toml 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ [package] name = "rumdl" -version = "0.1.54" +version = "0.1.57" 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.54/README.md new/rumdl-0.1.57/README.md --- old/rumdl-0.1.54/README.md 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/README.md 2026-03-20 13:26:48.000000000 +0100 @@ -196,7 +196,7 @@ mise install rumdl # Use a specific version for the project -mise use [email protected] +mise use [email protected] ``` ### Using Nix (macOS/Linux) @@ -346,7 +346,7 @@ ```yaml repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.1.54 + rev: v0.1.57 hooks: - id: rumdl # Lint only (fails on issues) - id: rumdl-fmt # Auto-format and fail if issues remain @@ -368,7 +368,7 @@ ```yaml repos: - repo: https://github.com/rvben/rumdl-pre-commit - rev: v0.1.54 + rev: v0.1.57 hooks: - id: rumdl args: [--no-exclude] # Disable all exclude patterns diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/docs/md057.md new/rumdl-0.1.57/docs/md057.md --- old/rumdl-0.1.54/docs/md057.md 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/docs/md057.md 2026-03-20 13:26:48.000000000 +0100 @@ -140,6 +140,42 @@ Fragment (`#section`) and query (`?v=1`) suffixes are preserved in the suggested fix. +### `search-paths` + +Additional directories to search when a relative link target is not found relative to the +file's directory. Paths are resolved relative to the project root (where `.rumdl.toml` or +`pyproject.toml` is found), or relative to the current working directory. + +```toml +# .rumdl.toml +[MD057] +search-paths = ["assets", "images", "attachments"] +``` + +With this configuration, a link like `` will first be checked relative +to the markdown file's directory. If not found there, MD057 will also look in `assets/photo.png`, +`images/photo.png`, and `attachments/photo.png`. + +**Obsidian users:** When `flavor = "obsidian"` is set in the global config, the attachment +folder is auto-detected from `.obsidian/app.json`, so this option is typically not needed. +Use it for custom setups or non-Obsidian tools with similar asset directory conventions. + +```toml +# .rumdl.toml — Obsidian auto-detection (no search-paths needed) +[global] +flavor = "obsidian" +``` + +Obsidian supports 4 attachment location modes configured via `attachmentFolderPath` in +`.obsidian/app.json`: + +| Setting | `attachmentFolderPath` | Resolution | +|---------|----------------------|------------| +| Vault folder (root) | `""` (empty) | `<vault-root>/` | +| Specified folder | `"Attachments"` | `<vault-root>/Attachments/` | +| Same folder as file | `"./"` | Same directory as the markdown file | +| Subfolder under file | `"./assets"` | `<file-dir>/assets/` | + ### Build-Generated Files Documentation sites often compile markdown files to HTML during build. MD057 automatically diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-darwin-arm64/package.json new/rumdl-0.1.57/npm/cli-darwin-arm64/package.json --- old/rumdl-0.1.54/npm/cli-darwin-arm64/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/cli-darwin-arm64/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-arm64", - "version": "0.1.54", + "version": "0.1.57", "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.54/npm/cli-darwin-x64/package.json new/rumdl-0.1.57/npm/cli-darwin-x64/package.json --- old/rumdl-0.1.54/npm/cli-darwin-x64/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/cli-darwin-x64/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-darwin-x64", - "version": "0.1.54", + "version": "0.1.57", "description": "rumdl binary for macOS x64 (Intel)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-linux-arm64/package.json new/rumdl-0.1.57/npm/cli-linux-arm64/package.json --- old/rumdl-0.1.54/npm/cli-linux-arm64/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/cli-linux-arm64/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64", - "version": "0.1.54", + "version": "0.1.57", "description": "rumdl binary for Linux ARM64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-linux-arm64-musl/package.json new/rumdl-0.1.57/npm/cli-linux-arm64-musl/package.json --- old/rumdl-0.1.54/npm/cli-linux-arm64-musl/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/cli-linux-arm64-musl/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-arm64-musl", - "version": "0.1.54", + "version": "0.1.57", "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.54/npm/cli-linux-x64/package.json new/rumdl-0.1.57/npm/cli-linux-x64/package.json --- old/rumdl-0.1.54/npm/cli-linux-x64/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/cli-linux-x64/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64", - "version": "0.1.54", + "version": "0.1.57", "description": "rumdl binary for Linux x64 (glibc)", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/npm/cli-linux-x64-musl/package.json new/rumdl-0.1.57/npm/cli-linux-x64-musl/package.json --- old/rumdl-0.1.54/npm/cli-linux-x64-musl/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/cli-linux-x64-musl/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-linux-x64-musl", - "version": "0.1.54", + "version": "0.1.57", "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.54/npm/cli-win32-x64/package.json new/rumdl-0.1.57/npm/cli-win32-x64/package.json --- old/rumdl-0.1.54/npm/cli-win32-x64/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/cli-win32-x64/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "@rumdl/cli-win32-x64", - "version": "0.1.54", + "version": "0.1.57", "description": "rumdl binary for Windows x64", "license": "MIT", "repository": { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/npm/rumdl/package.json new/rumdl-0.1.57/npm/rumdl/package.json --- old/rumdl-0.1.54/npm/rumdl/package.json 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/npm/rumdl/package.json 2026-03-20 13:26:48.000000000 +0100 @@ -1,6 +1,6 @@ { "name": "rumdl", - "version": "0.1.54", + "version": "0.1.57", "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.54", - "@rumdl/cli-darwin-arm64": "0.1.54", - "@rumdl/cli-linux-x64": "0.1.54", - "@rumdl/cli-linux-arm64": "0.1.54", - "@rumdl/cli-linux-x64-musl": "0.1.54", - "@rumdl/cli-linux-arm64-musl": "0.1.54", - "@rumdl/cli-win32-x64": "0.1.54" + "@rumdl/cli-darwin-x64": "0.1.57", + "@rumdl/cli-darwin-arm64": "0.1.57", + "@rumdl/cli-linux-x64": "0.1.57", + "@rumdl/cli-linux-arm64": "0.1.57", + "@rumdl/cli-linux-x64-musl": "0.1.57", + "@rumdl/cli-linux-arm64-musl": "0.1.57", + "@rumdl/cli-win32-x64": "0.1.57" } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/code_block_tools/processor.rs new/rumdl-0.1.57/src/code_block_tools/processor.rs --- old/rumdl-0.1.54/src/code_block_tools/processor.rs 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/src/code_block_tools/processor.rs 2026-03-20 13:26:48.000000000 +0100 @@ -11,7 +11,8 @@ use super::registry::ToolRegistry; use crate::config::MarkdownFlavor; use crate::rule::{LintWarning, Severity}; -use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; +use crate::utils::rumdl_parser_options; +use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; /// Special built-in tool name for rumdl's own markdown linting. /// When this tool is configured for markdown blocks, the processor skips @@ -307,7 +308,7 @@ let mut blocks = Vec::new(); let mut current_block: Option<FencedCodeBlockBuilder> = None; - let options = Options::all(); + let options = rumdl_parser_options(); let parser = Parser::new_ext(content, options).into_offset_iter(); let lines: Vec<&str> = content.lines().collect(); diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/lint_context/element_parsers.rs new/rumdl-0.1.57/src/lint_context/element_parsers.rs --- old/rumdl-0.1.54/src/lint_context/element_parsers.rs 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/src/lint_context/element_parsers.rs 2026-03-20 13:26:48.000000000 +0100 @@ -3,6 +3,7 @@ use crate::utils::mkdocs_admonitions; use crate::utils::mkdocs_tabs; use crate::utils::regex_cache::URL_SIMPLE_REGEX; +use crate::utils::rumdl_parser_options; use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; use regex::Regex; use std::sync::LazyLock; @@ -569,7 +570,7 @@ use crate::utils::blockquote::effective_indent_in_blockquote; let mut lazy_lines = Vec::new(); - let parser = Parser::new_ext(content, Options::all()); + let parser = Parser::new_ext(content, rumdl_parser_options()); // Stack of (expected_indent_within_context, blockquote_level) for nested items let mut item_stack: Vec<(usize, usize)> = vec![]; diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/rules/md041_first_line_heading/mod.rs new/rumdl-0.1.57/src/rules/md041_first_line_heading/mod.rs --- old/rumdl-0.1.54/src/rules/md041_first_line_heading/mod.rs 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/src/rules/md041_first_line_heading/mod.rs 2026-03-20 13:26:48.000000000 +0100 @@ -127,6 +127,37 @@ false } + /// Find the first content line index (0-indexed) in the document. + /// + /// Skips front matter, blank lines, HTML/MDX comments, ESM blocks, + /// kramdown extensions, MkDocs anchors, reference definitions, and badges. + /// Used by both check() and fix() to ensure consistent behavior. + fn first_content_line_idx(ctx: &crate::lint_context::LintContext) -> Option<usize> { + let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs; + + for (idx, line_info) in ctx.lines.iter().enumerate() { + if line_info.in_front_matter + || line_info.is_blank + || line_info.in_esm_block + || line_info.in_html_comment + || line_info.in_mdx_comment + || line_info.in_kramdown_extension_block + || line_info.is_kramdown_block_ial + { + continue; + } + let line_content = line_info.content(ctx.content); + if is_mkdocs && is_mkdocs_anchor_line(line_content) { + continue; + } + if Self::is_non_content_line(line_content) { + continue; + } + return Some(idx); + } + None + } + /// Check if a line consists only of badge/shield images /// Common patterns: /// - `` @@ -385,6 +416,7 @@ // Preamble: invisible/structural tokens that don't count as content let is_preamble = trimmed.is_empty() || line_info.in_html_comment + || line_info.in_mdx_comment || line_info.in_html_block || Self::is_non_content_line(line_content) || (is_mkdocs && is_mkdocs_anchor_line(line_content)) @@ -501,53 +533,9 @@ return Ok(warnings); } - // Find the first non-blank line after front matter using cached info - let mut first_content_line_num = None; - let mut skip_lines = 0; - - // Skip front matter (YAML, TOML, JSON, malformed) - for line_info in &ctx.lines { - if line_info.in_front_matter { - skip_lines += 1; - } else { - break; - } - } - - // Check if we're in MkDocs flavor - let is_mkdocs = ctx.flavor == crate::config::MarkdownFlavor::MkDocs; - - for (line_num, line_info) in ctx.lines.iter().enumerate().skip(skip_lines) { - let line_content = line_info.content(ctx.content); - let trimmed = line_content.trim(); - // Skip ESM blocks in MDX files (import/export statements) - if line_info.in_esm_block { - continue; - } - // Skip HTML comments - they are non-visible and should not affect MD041 - if line_info.in_html_comment { - continue; - } - // Skip MkDocs anchor lines (empty link with attr_list) when in MkDocs flavor - if is_mkdocs && is_mkdocs_anchor_line(line_content) { - continue; - } - // Skip kramdown extension blocks and block IALs (preamble detection) - if line_info.in_kramdown_extension_block || line_info.is_kramdown_block_ial { - continue; - } - if !trimmed.is_empty() && !Self::is_non_content_line(line_content) { - first_content_line_num = Some(line_num); - break; - } - } - - if first_content_line_num.is_none() { - // No non-blank lines after front matter + let Some(first_line_idx) = Self::first_content_line_idx(ctx) else { return Ok(warnings); - } - - let first_line_idx = first_content_line_num.unwrap(); + }; // Check if the first non-blank line is a heading of the required level let first_line_info = &ctx.lines[first_line_idx]; @@ -642,13 +630,10 @@ return Ok(ctx.content.to_string()); } - // Respect inline disable comments - let first_content_line = ctx - .lines - .iter() - .enumerate() - .find(|(_, li)| !li.in_front_matter && !li.is_blank) - .map(|(i, _)| i + 1) // 1-indexed + // Respect inline disable comments — use the same first-content-line + // logic as check() so both paths agree on which line to check. + let first_content_line = Self::first_content_line_idx(ctx) + .map(|i| i + 1) // 1-indexed .unwrap_or(1); if ctx.inline_config().is_rule_disabled(self.name(), first_content_line) { return Ok(ctx.content.to_string()); @@ -2438,4 +2423,104 @@ assert_eq!(patched, fixed, "Applying Fix directly should match fix() output"); } + + #[test] + fn test_mdx_disable_on_line_1_no_heading() { + // The exact user scenario from issue #538: + // MDX disable comment on line 1, NO heading anywhere. + // The disable is the ONLY reason MD041 should not fire. + let content = "{/* <!-- rumdl-disable MD041 MD034 --> */}\n<Note>\nThis documentation is linted with http://rumdl.dev/\n</Note>"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None); + + // check() should produce a warning on line 2 (<Note> is the first content line) + let rule = MD041FirstLineHeading::default(); + let warnings = rule.check(&ctx).unwrap(); + // The rule itself produces a warning, but the engine filters it via inline config. + // MD041's check() doesn't filter inline config itself — the engine does. + // What matters is that the warning is on line 2 (not line 1), so the engine + // can see the disable is active at line 2 and suppress it. + if !warnings.is_empty() { + assert_eq!( + warnings[0].line, 2, + "Warning must be on line 2 (first content line after MDX comment), not line 1" + ); + } + } + + #[test] + fn test_mdx_disable_fix_returns_unchanged() { + // fix() should return content unchanged when MDX disable is active + let content = "{/* <!-- rumdl-disable MD041 --> */}\n<Note>\nContent\n</Note>"; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None); + let rule = MD041FirstLineHeading { + fix_enabled: true, + ..MD041FirstLineHeading::default() + }; + let result = rule.fix(&ctx).unwrap(); + assert_eq!( + result, content, + "fix() should not modify content when MD041 is disabled via MDX comment" + ); + } + + #[test] + fn test_mdx_comment_without_disable_heading_on_next_line() { + let rule = MD041FirstLineHeading::default(); + + // MDX comment (not a disable directive) on line 1, heading on line 2 + let content = "{/* Some MDX comment */}\n# My Document\n\nContent."; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None); + let result = rule.check(&ctx).unwrap(); + assert!( + result.is_empty(), + "MDX comment is preamble; heading on next line should satisfy MD041" + ); + } + + #[test] + fn test_mdx_comment_without_heading_triggers_warning() { + let rule = MD041FirstLineHeading::default(); + + // MDX comment on line 1, non-heading content on line 2 + let content = "{/* Some MDX comment */}\nThis is not a heading\n\nContent."; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None); + let result = rule.check(&ctx).unwrap(); + assert_eq!( + result.len(), + 1, + "MDX comment followed by non-heading should trigger MD041" + ); + assert_eq!( + result[0].line, 2, + "Warning should be on line 2 (the first content line after MDX comment)" + ); + } + + #[test] + fn test_multiline_mdx_comment_followed_by_heading() { + let rule = MD041FirstLineHeading::default(); + + // Multi-line MDX comment followed by heading + let content = "{/*\nSome multi-line\nMDX comment\n*/}\n# My Document\n\nContent."; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::MDX, None); + let result = rule.check(&ctx).unwrap(); + assert!( + result.is_empty(), + "Multi-line MDX comment should be preamble; heading after it satisfies MD041" + ); + } + + #[test] + fn test_html_comment_still_works_as_preamble_regression() { + let rule = MD041FirstLineHeading::default(); + + // Plain HTML comment on line 1, heading on line 2 + let content = "<!-- Some comment -->\n# My Document\n\nContent."; + let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + assert!( + result.is_empty(), + "HTML comment should still be treated as preamble (regression test)" + ); + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/rules/md057_existing_relative_links/md057_config.rs new/rumdl-0.1.57/src/rules/md057_existing_relative_links/md057_config.rs --- old/rumdl-0.1.54/src/rules/md057_existing_relative_links/md057_config.rs 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/src/rules/md057_existing_relative_links/md057_config.rs 2026-03-20 13:26:48.000000000 +0100 @@ -32,6 +32,24 @@ /// and suggests the shorter equivalent `file.md`. #[serde(alias = "compact_paths")] pub compact_paths: bool, + + /// Additional directories to search when a relative link is not found + /// relative to the file's directory. + /// + /// Paths are resolved relative to the project root (where `.rumdl.toml` or + /// `pyproject.toml` is found), or relative to the current working directory. + /// + /// For Obsidian users: the attachment folder is auto-detected from + /// `.obsidian/app.json` when `flavor = "obsidian"` is set, so this option + /// is typically not needed. Use it for custom setups or non-Obsidian tools. + /// + /// Example: + /// ```toml + /// [MD057] + /// search-paths = ["assets", "images", "attachments"] + /// ``` + #[serde(alias = "search_paths")] + pub search_paths: Vec<String>, } impl RuleConfig for MD057Config { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/rules/md057_existing_relative_links.rs new/rumdl-0.1.57/src/rules/md057_existing_relative_links.rs --- old/rumdl-0.1.54/src/rules/md057_existing_relative_links.rs 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/src/rules/md057_existing_relative_links.rs 2026-03-20 13:26:48.000000000 +0100 @@ -17,6 +17,7 @@ mod md057_config; use crate::rule_config_serde::RuleConfig; use crate::utils::mkdocs_config::resolve_docs_dir; +use crate::utils::obsidian_config::resolve_attachment_folder; pub use md057_config::{AbsoluteLinksOption, MD057Config}; // Thread-safe cache for file existence checks to avoid redundant filesystem operations @@ -115,6 +116,8 @@ base_path: Arc<Mutex<Option<PathBuf>>>, /// Configuration for the rule config: MD057Config, + /// Markdown flavor (used for Obsidian attachment folder auto-detection) + flavor: crate::config::MarkdownFlavor, } impl Default for MD057ExistingRelativeLinks { @@ -122,6 +125,7 @@ Self { base_path: Arc::new(Mutex::new(None)), config: MD057Config::default(), + flavor: crate::config::MarkdownFlavor::default(), } } } @@ -151,9 +155,17 @@ Self { base_path: Arc::new(Mutex::new(None)), config, + flavor: crate::config::MarkdownFlavor::default(), } } + /// Set the markdown flavor for Obsidian attachment auto-detection + #[cfg(test)] + fn with_flavor(mut self, flavor: crate::config::MarkdownFlavor) -> Self { + self.flavor = flavor; + self + } + /// Check if a URL is external or should be skipped for validation. /// /// Returns `true` (skip validation) for: @@ -283,6 +295,50 @@ base_path.join(link) } + /// Compute additional search paths for fallback link resolution. + /// + /// Combines Obsidian attachment folder auto-detection (when flavor is Obsidian) + /// with explicitly configured `search-paths`. + fn compute_search_paths( + &self, + flavor: crate::config::MarkdownFlavor, + source_file: Option<&Path>, + base_path: &Path, + ) -> Vec<PathBuf> { + let mut paths = Vec::new(); + + // Auto-detect Obsidian attachment folder + if flavor == crate::config::MarkdownFlavor::Obsidian + && let Some(attachment_dir) = resolve_attachment_folder(source_file.unwrap_or(base_path), base_path) + && attachment_dir != *base_path + { + paths.push(attachment_dir); + } + + // Add explicitly configured search paths + for search_path in &self.config.search_paths { + let resolved = if Path::new(search_path).is_absolute() { + PathBuf::from(search_path) + } else { + // Resolve relative to CWD (project root) + CURRENT_DIR.join(search_path) + }; + if resolved != *base_path && !paths.contains(&resolved) { + paths.push(resolved); + } + } + + paths + } + + /// Check if a link target exists in any of the additional search paths. + fn exists_in_search_paths(decoded_path: &str, search_paths: &[PathBuf]) -> bool { + search_paths.iter().any(|dir| { + let candidate = dir.join(decoded_path); + file_exists_or_markdown_extension(&candidate) + }) + } + /// Check if a relative link can be compacted and return the simplified form. /// /// Returns `None` if compact-paths is disabled, the link has no traversal, @@ -439,6 +495,9 @@ return Ok(warnings); }; + // Compute additional search paths for fallback link resolution + let extra_search_paths = self.compute_search_paths(ctx.flavor, ctx.source_file.as_deref(), &base_path); + // Use LintContext links instead of expensive regex parsing if !ctx.links.is_empty() { // Use LineIndex for correct position calculation across all line ending types @@ -626,6 +685,11 @@ continue; // Markdown source exists, link is valid } + // Try additional search paths (Obsidian attachment folder, configured paths) + if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) { + continue; + } + // File doesn't exist and no source file found // Use actual URL position from regex capture group // Note: capture group positions are absolute within the line string @@ -762,6 +826,11 @@ continue; // Markdown source exists, link is valid } + // Try additional search paths (Obsidian attachment folder, configured paths) + if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) { + continue; + } + // File doesn't exist and no source file found // Images already have correct position from parser warnings.push(LintWarning { @@ -889,6 +958,11 @@ continue; // Markdown source exists, link is valid } + // Try additional search paths (Obsidian attachment folder, configured paths) + if Self::exists_in_search_paths(&decoded_path, &extra_search_paths) { + continue; + } + // File doesn't exist and no source file found // Calculate column position: find URL within the line let line_idx = ref_def.line - 1; @@ -968,7 +1042,9 @@ Self: Sized, { let rule_config = crate::rule_config_serde::load_rule_config::<MD057Config>(config); - Box::new(Self::from_config_struct(rule_config)) + let mut rule = Self::from_config_struct(rule_config); + rule.flavor = config.global.flavor; + Box::new(rule) } fn cross_file_scope(&self) -> CrossFileScope { @@ -994,6 +1070,10 @@ // Get the directory containing this file for resolving relative links let file_dir = file_path.parent(); + // Compute additional search paths for fallback link resolution + let base_path = file_dir.map(|d| d.to_path_buf()).unwrap_or_else(|| CURRENT_DIR.clone()); + let extra_search_paths = self.compute_search_paths(self.flavor, Some(file_path), &base_path); + for cross_link in &file_index.cross_file_links { // URL-decode the path for filesystem operations // The stored path is URL-encoded (e.g., "%F0%9F%91%A4" for emoji 👤) @@ -1036,7 +1116,7 @@ false }; - if !has_md_source { + if !has_md_source && !Self::exists_in_search_paths(&decoded_target, &extra_search_paths) { warnings.push(LintWarning { rule_name: Some(self.name().to_string()), line: cross_link.line, @@ -2883,4 +2963,437 @@ "Warning should include the reference path" ); } + + #[test] + fn test_search_paths_inline_link() { + let temp_dir = tempdir().unwrap(); + let base_path = temp_dir.path(); + + // Create an "assets" directory with an image + let assets_dir = base_path.join("assets"); + std::fs::create_dir_all(&assets_dir).unwrap(); + std::fs::write(assets_dir.join("photo.png"), "fake image").unwrap(); + + let config = MD057Config { + search_paths: vec![assets_dir.to_string_lossy().into_owned()], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path); + + let content = "# Test\n\n[Photo](photo.png)\n"; + let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Should find photo.png via search-paths. Got: {result:?}" + ); + } + + #[test] + fn test_search_paths_image() { + let temp_dir = tempdir().unwrap(); + let base_path = temp_dir.path(); + + let assets_dir = base_path.join("attachments"); + std::fs::create_dir_all(&assets_dir).unwrap(); + std::fs::write(assets_dir.join("diagram.svg"), "<svg/>").unwrap(); + + let config = MD057Config { + search_paths: vec![assets_dir.to_string_lossy().into_owned()], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path); + + let content = "# Test\n\n\n"; + let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Should find diagram.svg via search-paths. Got: {result:?}" + ); + } + + #[test] + fn test_search_paths_reference_definition() { + let temp_dir = tempdir().unwrap(); + let base_path = temp_dir.path(); + + let assets_dir = base_path.join("images"); + std::fs::create_dir_all(&assets_dir).unwrap(); + std::fs::write(assets_dir.join("logo.png"), "fake").unwrap(); + + let config = MD057Config { + search_paths: vec![assets_dir.to_string_lossy().into_owned()], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path); + + let content = "# Test\n\nSee [logo][ref].\n\n[ref]: logo.png\n"; + let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Should find logo.png via search-paths in reference definition. Got: {result:?}" + ); + } + + #[test] + fn test_search_paths_still_warns_when_truly_missing() { + let temp_dir = tempdir().unwrap(); + let base_path = temp_dir.path(); + + let assets_dir = base_path.join("assets"); + std::fs::create_dir_all(&assets_dir).unwrap(); + + let config = MD057Config { + search_paths: vec![assets_dir.to_string_lossy().into_owned()], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path); + + let content = "# Test\n\n\n"; + let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result.len(), + 1, + "Should still warn when file doesn't exist in any search path. Got: {result:?}" + ); + } + + #[test] + fn test_search_paths_nonexistent_directory() { + let temp_dir = tempdir().unwrap(); + let base_path = temp_dir.path(); + + let config = MD057Config { + search_paths: vec!["/nonexistent/path/that/does/not/exist".to_string()], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path); + + let content = "# Test\n\n\n"; + let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result.len(), + 1, + "Nonexistent search path should not cause errors, just not find the file. Got: {result:?}" + ); + } + + #[test] + fn test_obsidian_attachment_folder_named() { + let temp_dir = tempdir().unwrap(); + let vault = temp_dir.path().join("vault"); + std::fs::create_dir_all(vault.join(".obsidian")).unwrap(); + std::fs::create_dir_all(vault.join("Attachments")).unwrap(); + std::fs::create_dir_all(vault.join("notes")).unwrap(); + + std::fs::write( + vault.join(".obsidian/app.json"), + r#"{"attachmentFolderPath": "Attachments"}"#, + ) + .unwrap(); + std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap(); + + let notes_dir = vault.join("notes"); + let source_file = notes_dir.join("test.md"); + std::fs::write(&source_file, "# Test\n\n\n").unwrap(); + + let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir); + + let content = "# Test\n\n\n"; + let ctx = + crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file)); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Obsidian attachment folder should resolve photo.png. Got: {result:?}" + ); + } + + #[test] + fn test_obsidian_attachment_same_folder_as_file() { + let temp_dir = tempdir().unwrap(); + let vault = temp_dir.path().join("vault-rf"); + std::fs::create_dir_all(vault.join(".obsidian")).unwrap(); + std::fs::create_dir_all(vault.join("notes")).unwrap(); + + std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap(); + + // Image in the same directory as the file — default behavior, no extra search needed + let notes_dir = vault.join("notes"); + let source_file = notes_dir.join("test.md"); + std::fs::write(&source_file, "placeholder").unwrap(); + std::fs::write(notes_dir.join("photo.png"), "fake").unwrap(); + + let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir); + + let content = "# Test\n\n\n"; + let ctx = + crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file)); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "'./' attachment mode resolves to same folder — should work by default. Got: {result:?}" + ); + } + + #[test] + fn test_obsidian_not_triggered_without_obsidian_flavor() { + let temp_dir = tempdir().unwrap(); + let vault = temp_dir.path().join("vault-nf"); + std::fs::create_dir_all(vault.join(".obsidian")).unwrap(); + std::fs::create_dir_all(vault.join("Attachments")).unwrap(); + std::fs::create_dir_all(vault.join("notes")).unwrap(); + + std::fs::write( + vault.join(".obsidian/app.json"), + r#"{"attachmentFolderPath": "Attachments"}"#, + ) + .unwrap(); + std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap(); + + let notes_dir = vault.join("notes"); + let source_file = notes_dir.join("test.md"); + std::fs::write(&source_file, "placeholder").unwrap(); + + let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir); + + let content = "# Test\n\n\n"; + // Standard flavor — NOT Obsidian + let ctx = + crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, Some(source_file)); + let result = rule.check(&ctx).unwrap(); + + assert_eq!( + result.len(), + 1, + "Without Obsidian flavor, attachment folder should not be auto-detected. Got: {result:?}" + ); + } + + #[test] + fn test_search_paths_combined_with_obsidian() { + let temp_dir = tempdir().unwrap(); + let vault = temp_dir.path().join("vault-combo"); + std::fs::create_dir_all(vault.join(".obsidian")).unwrap(); + std::fs::create_dir_all(vault.join("Attachments")).unwrap(); + std::fs::create_dir_all(vault.join("extra-assets")).unwrap(); + std::fs::create_dir_all(vault.join("notes")).unwrap(); + + std::fs::write( + vault.join(".obsidian/app.json"), + r#"{"attachmentFolderPath": "Attachments"}"#, + ) + .unwrap(); + std::fs::write(vault.join("Attachments/photo.png"), "fake").unwrap(); + std::fs::write(vault.join("extra-assets/diagram.svg"), "fake").unwrap(); + + let notes_dir = vault.join("notes"); + let source_file = notes_dir.join("test.md"); + std::fs::write(&source_file, "placeholder").unwrap(); + + let extra_assets_dir = vault.join("extra-assets"); + let config = MD057Config { + search_paths: vec![extra_assets_dir.to_string_lossy().into_owned()], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(¬es_dir); + + // Both links should resolve: photo.png via Obsidian, diagram.svg via search-paths + let content = "# Test\n\n\n\n\n"; + let ctx = + crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file)); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Both Obsidian attachment and search-paths should resolve. Got: {result:?}" + ); + } + + #[test] + fn test_obsidian_attachment_subfolder_under_file() { + let temp_dir = tempdir().unwrap(); + let vault = temp_dir.path().join("vault-sub"); + std::fs::create_dir_all(vault.join(".obsidian")).unwrap(); + std::fs::create_dir_all(vault.join("notes/assets")).unwrap(); + + std::fs::write( + vault.join(".obsidian/app.json"), + r#"{"attachmentFolderPath": "./assets"}"#, + ) + .unwrap(); + std::fs::write(vault.join("notes/assets/photo.png"), "fake").unwrap(); + + let notes_dir = vault.join("notes"); + let source_file = notes_dir.join("test.md"); + std::fs::write(&source_file, "placeholder").unwrap(); + + let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir); + + let content = "# Test\n\n\n"; + let ctx = + crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file)); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Obsidian './assets' mode should find photo.png in <file-dir>/assets/. Got: {result:?}" + ); + } + + #[test] + fn test_obsidian_attachment_vault_root() { + let temp_dir = tempdir().unwrap(); + let vault = temp_dir.path().join("vault-root"); + std::fs::create_dir_all(vault.join(".obsidian")).unwrap(); + std::fs::create_dir_all(vault.join("notes")).unwrap(); + + // Empty string = vault root + std::fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap(); + std::fs::write(vault.join("photo.png"), "fake").unwrap(); + + let notes_dir = vault.join("notes"); + let source_file = notes_dir.join("test.md"); + std::fs::write(&source_file, "placeholder").unwrap(); + + let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()).with_path(¬es_dir); + + let content = "# Test\n\n\n"; + let ctx = + crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Obsidian, Some(source_file)); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Obsidian vault-root mode should find photo.png at vault root. Got: {result:?}" + ); + } + + #[test] + fn test_search_paths_multiple_directories() { + let temp_dir = tempdir().unwrap(); + let base_path = temp_dir.path(); + + let dir_a = base_path.join("dir-a"); + let dir_b = base_path.join("dir-b"); + std::fs::create_dir_all(&dir_a).unwrap(); + std::fs::create_dir_all(&dir_b).unwrap(); + std::fs::write(dir_a.join("alpha.png"), "fake").unwrap(); + std::fs::write(dir_b.join("beta.png"), "fake").unwrap(); + + let config = MD057Config { + search_paths: vec![ + dir_a.to_string_lossy().into_owned(), + dir_b.to_string_lossy().into_owned(), + ], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path); + + let content = "# Test\n\n\n\n\n"; + let ctx = crate::lint_context::LintContext::new(content, crate::config::MarkdownFlavor::Standard, None); + let result = rule.check(&ctx).unwrap(); + + assert!( + result.is_empty(), + "Should find files across multiple search paths. Got: {result:?}" + ); + } + + #[test] + fn test_cross_file_check_with_search_paths() { + use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex}; + + let temp_dir = tempdir().unwrap(); + let base_path = temp_dir.path(); + + // Create docs directory with a markdown target in a search path + let docs_dir = base_path.join("docs"); + std::fs::create_dir_all(&docs_dir).unwrap(); + std::fs::write(docs_dir.join("guide.md"), "# Guide\n").unwrap(); + + let config = MD057Config { + search_paths: vec![docs_dir.to_string_lossy().into_owned()], + ..Default::default() + }; + let rule = MD057ExistingRelativeLinks::from_config_struct(config).with_path(base_path); + + let file_path = base_path.join("README.md"); + std::fs::write(&file_path, "# Readme\n").unwrap(); + + let mut file_index = FileIndex::default(); + file_index.cross_file_links.push(CrossFileLinkIndex { + target_path: "guide.md".to_string(), + fragment: String::new(), + line: 3, + column: 1, + }); + + let workspace_index = WorkspaceIndex::new(); + + let result = rule + .cross_file_check(&file_path, &file_index, &workspace_index) + .unwrap(); + + assert!( + result.is_empty(), + "cross_file_check should find guide.md via search-paths. Got: {result:?}" + ); + } + + #[test] + fn test_cross_file_check_with_obsidian_flavor() { + use crate::workspace_index::{CrossFileLinkIndex, FileIndex, WorkspaceIndex}; + + let temp_dir = tempdir().unwrap(); + let vault = temp_dir.path().join("vault-xf"); + std::fs::create_dir_all(vault.join(".obsidian")).unwrap(); + std::fs::create_dir_all(vault.join("Attachments")).unwrap(); + std::fs::create_dir_all(vault.join("notes")).unwrap(); + + std::fs::write( + vault.join(".obsidian/app.json"), + r#"{"attachmentFolderPath": "Attachments"}"#, + ) + .unwrap(); + std::fs::write(vault.join("Attachments/ref.md"), "# Reference\n").unwrap(); + + let notes_dir = vault.join("notes"); + let file_path = notes_dir.join("test.md"); + std::fs::write(&file_path, "placeholder").unwrap(); + + let rule = MD057ExistingRelativeLinks::from_config_struct(MD057Config::default()) + .with_path(¬es_dir) + .with_flavor(crate::config::MarkdownFlavor::Obsidian); + + let mut file_index = FileIndex::default(); + file_index.cross_file_links.push(CrossFileLinkIndex { + target_path: "ref.md".to_string(), + fragment: String::new(), + line: 3, + column: 1, + }); + + let workspace_index = WorkspaceIndex::new(); + + let result = rule + .cross_file_check(&file_path, &file_index, &workspace_index) + .unwrap(); + + assert!( + result.is_empty(), + "cross_file_check should find ref.md via Obsidian attachment folder. Got: {result:?}" + ); + } } diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/utils/code_block_utils.rs new/rumdl-0.1.57/src/utils/code_block_utils.rs --- old/rumdl-0.1.54/src/utils/code_block_utils.rs 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/src/utils/code_block_utils.rs 2026-03-20 13:26:48.000000000 +0100 @@ -8,7 +8,9 @@ //! - Mixed fence types (tilde fence contains backticks as content) //! - Indented code blocks with proper list context handling -use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; +use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; + +use super::parser_options::rumdl_parser_options; /// Type alias for code block and span ranges: (code_blocks, code_spans) pub type CodeRanges = (Vec<(usize, usize)>, Vec<(usize, usize)>); @@ -108,8 +110,7 @@ let byte_to_line = |byte_offset: usize| -> usize { line_starts.partition_point(|&start| start <= byte_offset) }; - // Use pulldown-cmark with all extensions for maximum compatibility - let options = Options::all(); + let options = rumdl_parser_options(); let parser = Parser::new_ext(content, options).into_offset_iter(); for (event, range) in parser { @@ -325,12 +326,12 @@ /// Only detects fenced code blocks (``` or ~~~), not indented code blocks, /// since indented blocks don't have a language tag. pub fn detect_markdown_code_blocks(content: &str) -> Vec<MarkdownCodeBlock> { - use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd}; + use pulldown_cmark::{CodeBlockKind, Event, Parser, Tag, TagEnd}; let mut blocks = Vec::new(); let mut current_block: Option<MarkdownCodeBlockBuilder> = None; - let options = Options::all(); + let options = rumdl_parser_options(); let parser = Parser::new_ext(content, options).into_offset_iter(); for (event, range) in parser { diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/utils/mod.rs new/rumdl-0.1.57/src/utils/mod.rs --- old/rumdl-0.1.54/src/utils/mod.rs 2026-03-18 17:23:42.000000000 +0100 +++ new/rumdl-0.1.57/src/utils/mod.rs 2026-03-20 13:26:48.000000000 +0100 @@ -29,6 +29,8 @@ pub mod mkdocs_tabs; pub mod mkdocs_test_utils; pub mod mkdocstrings_refs; +pub mod obsidian_config; +pub mod parser_options; pub mod pymdown_blocks; pub mod quarto_divs; pub mod range_utils; @@ -47,6 +49,7 @@ normalize_line_ending, }; pub use markdown_elements::{ElementQuality, ElementType, MarkdownElement, MarkdownElements}; +pub use parser_options::rumdl_parser_options; pub use range_utils::LineIndex; /// Calculate the visual indentation width of a string, expanding tabs to spaces. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/utils/obsidian_config.rs new/rumdl-0.1.57/src/utils/obsidian_config.rs --- old/rumdl-0.1.54/src/utils/obsidian_config.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/rumdl-0.1.57/src/utils/obsidian_config.rs 2026-03-20 13:26:48.000000000 +0100 @@ -0,0 +1,245 @@ +//! Obsidian vault configuration utilities. +//! +//! Provides discovery and parsing of `.obsidian/app.json` files, +//! with caching for efficient repeated lookups. +//! +//! Mirrors the pattern used by `mkdocs_config.rs` for MkDocs projects. + +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{LazyLock, Mutex}; + +/// Cache: canonicalized vault root -> resolved attachment folder (absolute) +static ATTACHMENT_DIR_CACHE: LazyLock<Mutex<HashMap<PathBuf, AttachmentResolution>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Result of resolving the Obsidian attachment folder configuration. +#[derive(Debug, Clone)] +pub enum AttachmentResolution { + /// Absolute path to a fixed attachment folder (vault root or named folder) + Fixed(PathBuf), + /// Relative to each file's directory (`./ ` prefix in config) + RelativeToFile(String), +} + +/// Minimal `.obsidian/app.json` structure for extracting attachment settings. +#[derive(Debug, Deserialize)] +struct ObsidianAppConfig { + #[serde(default, rename = "attachmentFolderPath")] + attachment_folder_path: String, +} + +/// Find an Obsidian vault root by walking up from `start_path`. +/// +/// Returns the vault root directory (parent of `.obsidian/`), or None if not found. +pub fn find_obsidian_vault(start_path: &Path) -> Option<PathBuf> { + let mut current = if start_path.is_file() { + start_path.parent()?.to_path_buf() + } else { + start_path.to_path_buf() + }; + + loop { + let obsidian_dir = current.join(".obsidian"); + if obsidian_dir.is_dir() { + return current.canonicalize().ok(); + } + + if !current.pop() { + break; + } + } + + None +} + +/// Resolve the attachment folder for a given file in an Obsidian vault. +/// +/// Reads `.obsidian/app.json` to determine the `attachmentFolderPath` setting: +/// - `""` (empty/absent): vault root +/// - `"FolderName"`: `<vault-root>/FolderName/` +/// - `"./"`: same folder as current file +/// - `"./subfolder"`: `<file-dir>/subfolder/` +/// +/// Results are cached by vault root path. +/// +/// `start_path` should be the markdown file being checked or its parent directory. +/// `file_dir` is the directory containing the file being checked (for `./` resolution). +/// +/// Returns the absolute path to the attachment folder, or None if no vault is found. +pub fn resolve_attachment_folder(start_path: &Path, file_dir: &Path) -> Option<PathBuf> { + let vault_root = find_obsidian_vault(start_path)?; + + // Check cache first + if let Ok(cache) = ATTACHMENT_DIR_CACHE.lock() + && let Some(resolution) = cache.get(&vault_root) + { + return Some(match resolution { + AttachmentResolution::Fixed(path) => path.clone(), + AttachmentResolution::RelativeToFile(subfolder) => { + if subfolder.is_empty() { + file_dir.to_path_buf() + } else { + file_dir.join(subfolder) + } + } + }); + } + + // Parse .obsidian/app.json + let app_json_path = vault_root.join(".obsidian").join("app.json"); + let attachment_folder_path = if app_json_path.exists() { + std::fs::read_to_string(&app_json_path) + .ok() + .and_then(|content| serde_json::from_str::<ObsidianAppConfig>(&content).ok()) + .map(|config| config.attachment_folder_path) + .unwrap_or_default() + } else { + String::new() + }; + + // Resolve and cache + let resolution = if attachment_folder_path.is_empty() { + AttachmentResolution::Fixed(vault_root.clone()) + } else if let Some(relative) = attachment_folder_path.strip_prefix("./") { + AttachmentResolution::RelativeToFile(relative.to_string()) + } else { + AttachmentResolution::Fixed(vault_root.join(&attachment_folder_path)) + }; + + let result = match &resolution { + AttachmentResolution::Fixed(path) => path.clone(), + AttachmentResolution::RelativeToFile(subfolder) => { + if subfolder.is_empty() { + file_dir.to_path_buf() + } else { + file_dir.join(subfolder) + } + } + }; + + if let Ok(mut cache) = ATTACHMENT_DIR_CACHE.lock() { + cache.insert(vault_root, resolution); + } + + Some(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_find_obsidian_vault() { + let temp = tempdir().unwrap(); + let vault = temp.path().join("my-vault"); + fs::create_dir_all(vault.join(".obsidian")).unwrap(); + fs::create_dir_all(vault.join("notes/subfolder")).unwrap(); + + // From a file in the vault root + let result = find_obsidian_vault(&vault.join("test.md")); + assert!(result.is_some()); + + // From a nested subfolder + let result = find_obsidian_vault(&vault.join("notes/subfolder/deep.md")); + assert!(result.is_some()); + + // From outside the vault + let result = find_obsidian_vault(temp.path()); + assert!(result.is_none()); + } + + #[test] + fn test_resolve_attachment_folder_vault_root() { + let temp = tempdir().unwrap(); + let vault = temp.path().join("vault"); + fs::create_dir_all(vault.join(".obsidian")).unwrap(); + fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": ""}"#).unwrap(); + + let file_dir = vault.join("notes"); + fs::create_dir_all(&file_dir).unwrap(); + + let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir); + assert!(result.is_some()); + let resolved = result.unwrap(); + assert_eq!(resolved.canonicalize().unwrap(), vault.canonicalize().unwrap()); + } + + #[test] + fn test_resolve_attachment_folder_named_folder() { + let temp = tempdir().unwrap(); + let vault = temp.path().join("vault2"); + fs::create_dir_all(vault.join(".obsidian")).unwrap(); + fs::create_dir_all(vault.join("Attachments")).unwrap(); + fs::write( + vault.join(".obsidian/app.json"), + r#"{"attachmentFolderPath": "Attachments"}"#, + ) + .unwrap(); + + let file_dir = vault.join("notes"); + fs::create_dir_all(&file_dir).unwrap(); + + let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir); + assert!(result.is_some()); + let resolved = result.unwrap(); + assert!(resolved.ends_with("Attachments")); + } + + #[test] + fn test_resolve_attachment_folder_relative_to_file() { + let temp = tempdir().unwrap(); + let vault = temp.path().join("vault3"); + fs::create_dir_all(vault.join(".obsidian")).unwrap(); + fs::write(vault.join(".obsidian/app.json"), r#"{"attachmentFolderPath": "./"}"#).unwrap(); + + let file_dir = vault.join("notes"); + fs::create_dir_all(&file_dir).unwrap(); + + let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir); + assert!(result.is_some()); + assert_eq!(result.unwrap(), file_dir); + } + + #[test] + fn test_resolve_attachment_folder_subfolder_under_file() { + let temp = tempdir().unwrap(); + let vault = temp.path().join("vault4"); + fs::create_dir_all(vault.join(".obsidian")).unwrap(); + fs::write( + vault.join(".obsidian/app.json"), + r#"{"attachmentFolderPath": "./assets"}"#, + ) + .unwrap(); + + let file_dir = vault.join("notes"); + fs::create_dir_all(&file_dir).unwrap(); + + let result = resolve_attachment_folder(&file_dir.join("test.md"), &file_dir); + assert!(result.is_some()); + assert!(result.unwrap().ends_with("assets")); + } + + #[test] + fn test_resolve_attachment_folder_no_app_json() { + let temp = tempdir().unwrap(); + let vault = temp.path().join("vault5"); + fs::create_dir_all(vault.join(".obsidian")).unwrap(); + // No app.json - should default to vault root + + let result = resolve_attachment_folder(&vault.join("test.md"), &vault); + assert!(result.is_some()); + let resolved = result.unwrap(); + assert_eq!(resolved.canonicalize().unwrap(), vault.canonicalize().unwrap()); + } + + #[test] + fn test_no_vault_returns_none() { + let temp = tempdir().unwrap(); + let result = resolve_attachment_folder(&temp.path().join("test.md"), temp.path()); + assert!(result.is_none()); + } +} diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/rumdl-0.1.54/src/utils/parser_options.rs new/rumdl-0.1.57/src/utils/parser_options.rs --- old/rumdl-0.1.54/src/utils/parser_options.rs 1970-01-01 01:00:00.000000000 +0100 +++ new/rumdl-0.1.57/src/utils/parser_options.rs 2026-03-20 13:26:48.000000000 +0100 @@ -0,0 +1,28 @@ +use pulldown_cmark::Options; + +/// Standard pulldown-cmark options for rumdl parsing. +/// +/// Uses an explicit allowlist rather than `Options::all()` to prevent +/// future pulldown-cmark releases from silently changing parse behavior. +/// +/// Notably excludes `ENABLE_YAML_STYLE_METADATA_BLOCKS` and +/// `ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS` because rumdl handles +/// front matter detection independently. These options cause pulldown-cmark +/// to misinterpret `---` horizontal rules as metadata delimiters, +/// corrupting code block detection across the entire document. +pub fn rumdl_parser_options() -> Options { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_OLD_FOOTNOTES); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TASKLISTS); + options.insert(Options::ENABLE_SMART_PUNCTUATION); + options.insert(Options::ENABLE_HEADING_ATTRIBUTES); + options.insert(Options::ENABLE_MATH); + options.insert(Options::ENABLE_GFM); + options.insert(Options::ENABLE_DEFINITION_LIST); + options.insert(Options::ENABLE_SUPERSCRIPT); + options.insert(Options::ENABLE_SUBSCRIPT); + options.insert(Options::ENABLE_WIKILINKS); + options +} ++++++ rumdl.obsinfo ++++++ --- /var/tmp/diff_new_pack.0Lz9Ue/_old 2026-03-20 21:27:32.012783592 +0100 +++ /var/tmp/diff_new_pack.0Lz9Ue/_new 2026-03-20 21:27:32.024784091 +0100 @@ -1,5 +1,5 @@ name: rumdl -version: 0.1.54 -mtime: 1773851022 -commit: 8eeed34c6f8f3bacc43f59a6a34f3c15dd594b20 +version: 0.1.57 +mtime: 1774009608 +commit: b52183a24937296947580d86711ecde803ba6e83 ++++++ vendor.tar.zst ++++++ /work/SRC/openSUSE:Factory/rumdl/vendor.tar.zst /work/SRC/openSUSE:Factory/.rumdl.new.8177/vendor.tar.zst differ: char 7, line 1
