Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .markdownlint.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
default: false

MD051: true
26 changes: 16 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
54 changes: 12 additions & 42 deletions crates/quickmark-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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);
}
Expand All @@ -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};

Expand All @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion crates/quickmark-cli/tests/cli_integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
74 changes: 16 additions & 58 deletions crates/quickmark-core/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ pub enum ConfigSearchResult {
/// Hierarchical config discovery with workspace root stopping point
pub struct ConfigDiscovery {
workspace_roots: Vec<PathBuf>,
current_working_dir: Option<PathBuf>,
}

impl Default for ConfigDiscovery {
Expand All @@ -220,17 +221,20 @@ 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,
}
}

/// Create a new ConfigDiscovery for LSP usage with workspace roots
pub fn with_workspace_roots(roots: Vec<PathBuf>) -> Self {
Self {
workspace_roots: roots,
current_working_dir: None,
}
}

Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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();
Expand Down
31 changes: 28 additions & 3 deletions crates/quickmark-core/src/linter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ pub struct RuleViolation {
location: Location,
message: String,
rule: &'static Rule,
pub(crate) severity: RuleSeverity,
}

impl RuleViolation {
Expand All @@ -38,6 +39,7 @@ impl RuleViolation {
rule,
message,
location: Location { file_path, range },
severity: RuleSeverity::Error, // Default, will be overridden by MultiRuleLinter
}
}

Expand All @@ -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
Expand Down Expand Up @@ -266,6 +272,7 @@ pub trait RuleLinter {
pub struct MultiRuleLinter {
linters: Vec<Box<dyn RuleLinter>>,
tree: Option<tree_sitter::Tree>,
config: QuickmarkConfig,
}

impl MultiRuleLinter {
Expand Down Expand Up @@ -297,6 +304,7 @@ impl MultiRuleLinter {
return Self {
linters: Vec::new(),
tree: None,
config,
};
}

Expand All @@ -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
Expand All @@ -319,6 +332,7 @@ impl MultiRuleLinter {
Self {
linters,
tree: Some(tree),
config,
}
}

Expand Down Expand Up @@ -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);
}

Expand Down