diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..c05b78f --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,3 @@ +default: false + +MD051: true diff --git a/README.md b/README.md index 291f240..d8b3b1b 100644 --- a/README.md +++ b/README.md @@ -211,42 +211,48 @@ QuickMark uses a sophisticated hierarchical configuration discovery system that #### Hierarchical Configuration Discovery -QuickMark automatically discovers configuration files by searching upward from the target markdown file's directory, stopping at natural project boundaries. This enables different parts of your project to have their own linting rules while maintaining a sensible inheritance hierarchy. +QuickMark automatically discovers configuration files by searching upward from the target markdown file's directory, stopping at repository boundaries. This enables different parts of your project to have their own linting rules while maintaining a sensible inheritance hierarchy. **Search Process:** - Starts from the directory containing the target markdown file - Searches upward through parent directories for `quickmark.toml` files - Uses the first configuration file found -- Stops searching when it encounters project boundary markers +- Stops searching when it encounters boundary markers -**Project Boundary Markers** (search stops at these): +**Boundary Markers** (search stops at these): -- **IDE Workspace Roots**: Configured workspace directories (LSP integration) +- **IDE Workspace Roots**: Configured workspace directories (LSP integration only) - **Git Repository Root**: Directories containing `.git` -- **Common Project Markers**: `package.json`, `Cargo.toml`, `pyproject.toml`, `go.mod`, `.vscode`, `.idea`, `.sublime-project` +- **Current Working Directory**: For CLI usage (prevents searching beyond the directory where you ran the command) **Example Hierarchical Structure:** ``` my-project/ +├── .git/ # Git repository boundary (search stops here) ├── quickmark.toml # Project-wide config (relaxed rules) -├── Cargo.toml # Project boundary marker +├── Cargo.toml # Regular project file (ignored during config search) ├── README.md # Uses project-wide config ├── src/ │ ├── quickmark.toml # Stricter rules for source code │ ├── api.md # Uses src/ config │ └── docs/ │ └── guide.md # Inherits src/ config (stricter) -└── tests/ - └── integration.md # Uses project-wide config (relaxed) +├── tests/ +│ └── integration.md # Uses project-wide config (relaxed) +└── vendor/ + └── external-lib/ + ├── .git/ # Another git boundary (search stops here) + └── README.md # Uses default config (no inheritance from parent) ``` In this example: - `src/api.md` and `src/docs/guide.md` use the stricter `src/quickmark.toml` configuration -- `README.md` and `tests/integration.md` use the relaxed project-wide `quickmark.toml` configuration -- Search stops at `Cargo.toml` level, preventing the search from going beyond the project boundary +- `README.md` and `tests/integration.md` use the relaxed project-wide `quickmark.toml` configuration +- `vendor/external-lib/README.md` uses the default configuration because the search stops at the `.git` boundary +- Only `.git` directories act as boundaries - other project markers like `Cargo.toml` are ignored #### Using QUICKMARK_CONFIG Environment Variable diff --git a/crates/quickmark-cli/src/main.rs b/crates/quickmark-cli/src/main.rs index 1ba9c8c..92cf37e 100644 --- a/crates/quickmark-cli/src/main.rs +++ b/crates/quickmark-cli/src/main.rs @@ -127,11 +127,9 @@ fn is_markdown_file(path: &Path) -> bool { } /// Print linting errors with 1-based line numbering for CLI display -fn print_cli_errors(results: &[RuleViolation], config: &QuickmarkConfig) -> (i32, i32) { - let severities = &config.linters.severity; - +fn print_cli_errors(results: &[RuleViolation]) -> (i32, i32) { let res = results.iter().fold((0, 0), |(errs, warns), v| { - let severity = severities.get(v.rule().alias).unwrap(); + let severity = v.severity(); let prefix; let mut new_err = errs; let mut new_warns = warns; @@ -224,7 +222,7 @@ fn main() -> anyhow::Result<()> { // Use optimized single config loading only when QUICKMARK_CONFIG is set // Otherwise, preserve hierarchical config discovery for correctness - let (all_violations, config) = if std::env::var("QUICKMARK_CONFIG").is_ok() { + let (all_violations, _config) = if std::env::var("QUICKMARK_CONFIG").is_ok() { // Performance optimization: Load config once when using environment config let pwd = env::current_dir()?; let config = config_from_env_path_or_default(&pwd)?; @@ -262,7 +260,7 @@ fn main() -> anyhow::Result<()> { (violations, config) }; - let (errs, _) = print_cli_errors(&all_violations, &config); + let (errs, _) = print_cli_errors(&all_violations); let exit_code = min(errs, 1); exit(exit_code); } @@ -271,8 +269,6 @@ fn main() -> anyhow::Result<()> { mod tests { use super::*; use quickmark_core::config::{HeadingStyle, LintersSettingsTable, MD003HeadingStyleTable}; - use quickmark_core::linter::{CharPosition, Range}; - use quickmark_core::rules::{md001::MD001, md003::MD003}; use quickmark_core::test_utils::test_helpers::test_config_with_settings; use std::path::{Path, PathBuf}; @@ -290,41 +286,15 @@ mod tests { ..Default::default() }, ); - let range = Range { - start: CharPosition { - line: 1, - character: 1, - }, - end: CharPosition { - line: 1, - character: 5, - }, - }; - let file = PathBuf::default(); - let results = vec![ - RuleViolation::new( - &MD001, - "all is bad".to_string(), - file.clone(), - range.clone(), - ), - RuleViolation::new( - &MD003, - "all is even worse".to_string(), - file.clone(), - range.clone(), - ), - RuleViolation::new( - &MD003, - "all is even worse2".to_string(), - file.clone(), - range, - ), - ]; - - let (errs, warns) = print_cli_errors(&results, &config); + + let file_path = PathBuf::from("test.md"); + let file_content = "# Heading 1\n\n### Heading 3\n\nHeading 1\n=========\n"; + let mut linter = MultiRuleLinter::new_for_document(file_path, config.clone(), file_content); + let results = linter.analyze(); + + let (errs, warns) = print_cli_errors(&results); assert_eq!(1, errs); - assert_eq!(2, warns); + assert_eq!(1, warns); } #[test] diff --git a/crates/quickmark-cli/tests/cli_integration_tests.rs b/crates/quickmark-cli/tests/cli_integration_tests.rs index c3ac035..e0e7005 100644 --- a/crates/quickmark-cli/tests/cli_integration_tests.rs +++ b/crates/quickmark-cli/tests/cli_integration_tests.rs @@ -448,7 +448,7 @@ fn test_cli_hierarchical_config_discovery() { /// Test that config discovery stops at git repository boundaries #[test] -fn test_cli_config_discovery_git_boundary() { +fn test_cli_config_discovery_stops_at_git_boundary() { // Create a temporary git repository structure let temp_dir = TempDir::new().unwrap(); diff --git a/crates/quickmark-core/src/config/mod.rs b/crates/quickmark-core/src/config/mod.rs index 712082c..1aeaabc 100644 --- a/crates/quickmark-core/src/config/mod.rs +++ b/crates/quickmark-core/src/config/mod.rs @@ -211,6 +211,7 @@ pub enum ConfigSearchResult { /// Hierarchical config discovery with workspace root stopping point pub struct ConfigDiscovery { workspace_roots: Vec, + current_working_dir: Option, } impl Default for ConfigDiscovery { @@ -220,10 +221,12 @@ impl Default for ConfigDiscovery { } impl ConfigDiscovery { - /// Create a new ConfigDiscovery for CLI usage (no workspace roots) + /// Create a new ConfigDiscovery for CLI usage (uses current working directory as boundary) pub fn new() -> Self { + let current_working_dir = std::env::current_dir().ok(); Self { workspace_roots: Vec::new(), + current_working_dir, } } @@ -231,6 +234,7 @@ impl ConfigDiscovery { pub fn with_workspace_roots(roots: Vec) -> Self { Self { workspace_roots: roots, + current_working_dir: None, } } @@ -291,35 +295,25 @@ impl ConfigDiscovery { /// Determine if search should stop at the current directory fn should_stop_search(&self, dir: &Path) -> bool { - // 1. IDE Workspace Root (highest priority) + // Stop at workspace roots (for LSP mode) for workspace_root in &self.workspace_roots { if dir == workspace_root.as_path() { return true; } } - // 2. Git Repository Root - if dir.join(".git").exists() { - return true; - } - - // 3. Common Project Root Markers - let project_markers = [ - "package.json", - "Cargo.toml", - "pyproject.toml", - "go.mod", - ".vscode", - ".idea", - ".sublime-project", - ]; - - for marker in &project_markers { - if dir.join(marker).exists() { + // Stop at current working directory (for CLI mode) + if let Some(cwd) = &self.current_working_dir { + if dir == cwd.as_path() { return true; } } + // Stop at git repository boundaries + if dir.join(".git").exists() { + return true; + } + false } } @@ -1351,10 +1345,10 @@ mod test { let src_dir = repo_dir.join("src"); std::fs::create_dir_all(&src_dir).unwrap(); - // Create .git directory to mark as repo root + // Create .git directory (should stop search) std::fs::create_dir(repo_dir.join(".git")).unwrap(); - // Create config outside repo (should not be found) + // Create config outside repo (should NOT be found due to .git boundary) let outer_config = temp_dir.path().join("quickmark.toml"); std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap(); @@ -1413,42 +1407,6 @@ mod test { } } - #[test] - fn test_config_discovery_stops_at_cargo_toml() { - let temp_dir = TempDir::new().unwrap(); - - // Create nested directories: temp_dir/project/src/ - let project_dir = temp_dir.path().join("project"); - let src_dir = project_dir.join("src"); - std::fs::create_dir_all(&src_dir).unwrap(); - - // Create Cargo.toml to mark as project root - std::fs::write(project_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); - - // Create config outside project (should not be found) - let outer_config = temp_dir.path().join("quickmark.toml"); - std::fs::write(&outer_config, "[linters.severity]\nheading-style = 'warn'").unwrap(); - - // Create file in src/ - let file_path = src_dir.join("test.md"); - std::fs::write(&file_path, "# Test").unwrap(); - - let discovery = ConfigDiscovery::new(); - let result = discovery.find_config(&file_path); - - match result { - ConfigSearchResult::NotFound { searched_paths } => { - // Should have searched in src/ and project/ but not in temp_dir (stopped at Cargo.toml) - let searched_dirs: Vec<_> = - searched_paths.iter().filter_map(|p| p.parent()).collect(); - assert!(searched_dirs.contains(&src_dir.as_path())); - assert!(searched_dirs.contains(&project_dir.as_path())); - assert!(!searched_dirs.contains(&temp_dir.path())); - } - _ => panic!("Expected NotFound result, got: {:?}", result), - } - } - #[test] fn test_config_discovery_error() { let temp_dir = TempDir::new().unwrap(); diff --git a/crates/quickmark-core/src/linter.rs b/crates/quickmark-core/src/linter.rs index ce9e337..8e3fa53 100644 --- a/crates/quickmark-core/src/linter.rs +++ b/crates/quickmark-core/src/linter.rs @@ -30,6 +30,7 @@ pub struct RuleViolation { location: Location, message: String, rule: &'static Rule, + pub(crate) severity: RuleSeverity, } impl RuleViolation { @@ -38,6 +39,7 @@ impl RuleViolation { rule, message, location: Location { file_path, range }, + severity: RuleSeverity::Error, // Default, will be overridden by MultiRuleLinter } } @@ -52,6 +54,10 @@ impl RuleViolation { pub fn rule(&self) -> &'static Rule { self.rule } + + pub fn severity(&self) -> &RuleSeverity { + &self.severity + } } /// Convert from tree-sitter range to library range @@ -266,6 +272,7 @@ pub trait RuleLinter { pub struct MultiRuleLinter { linters: Vec>, tree: Option, + config: QuickmarkConfig, } impl MultiRuleLinter { @@ -297,6 +304,7 @@ impl MultiRuleLinter { return Self { linters: Vec::new(), tree: None, + config, }; } @@ -308,7 +316,12 @@ impl MultiRuleLinter { let tree = parser.parse(document, None).expect("Parse failed"); // Create context with pre-initialized cache only for active rules - let context = Rc::new(Context::new(file_path, config, document, &tree.root_node())); + let context = Rc::new(Context::new( + file_path, + config.clone(), + document, + &tree.root_node(), + )); // Create rule linters for active rules only let linters = active_rules @@ -319,6 +332,7 @@ impl MultiRuleLinter { Self { linters, tree: Some(tree), + config, } } @@ -347,10 +361,21 @@ impl MultiRuleLinter { } }); - // Collect all violations from finalize + // Collect all violations from finalize and inject severity from config let mut violations = Vec::new(); for linter in &mut self.linters { - let linter_violations = linter.finalize(); + let mut linter_violations = linter.finalize(); + // Inject severity into each violation based on current config + for violation in &mut linter_violations { + let severity = self + .config + .linters + .severity + .get(violation.rule().alias) + .cloned() + .unwrap_or(RuleSeverity::Error); + violation.severity = severity; + } violations.extend(linter_violations); }