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
4 changes: 0 additions & 4 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@
"Bash(gh repo view:*)",
"Bash(cargo build:*)",
"Bash(cargo search:*)",
"WebFetch(domain:docs.rs)",
"WebFetch(domain:github.com)",
"WebFetch(domain:raw.githubusercontent.com)",
"WebFetch(domain:index.crates.io)"
]
}
}
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ use_global = true
2. If `use_global = false` in project config, global settings are ignored entirely
3. If no project config exists, global config is used
4. If neither exists, default settings are used
5. Any matching `[[overrides]]` blocks are then layered on top (global first, then project) — see [Scoped Overrides](#scoped-overrides)

### Working with Configurations

Expand Down Expand Up @@ -418,6 +419,55 @@ exclude_tags = ["string.heredoc"]

For the full list of available tags, see the [query tag reference](crates/codebook/src/queries/README.md).

### Scoped Overrides

Use `[[overrides]]` blocks to tailor settings to specific files. Each block matches files by glob pattern (relative to the project root) and can replace or append to the base config.

```toml
# Base config applies everywhere
dictionaries = ["en_us"]
words = ["codebook"]
flag_words = ["todo"]

# Markdown files: add British English and allow a few prose-specific words
[[overrides]]
paths = ["**/*.md", "**/*.mdx"]
extra_dictionaries = ["en_gb"]
extra_words = ["frontmatter", "callout"]

# Rust files: flag a few extra words
[[overrides]]
paths = ["**/*.rs"]
extra_flag_words = ["xxx", "hack"]

# German docs: swap out the dictionary entirely
[[overrides]]
paths = ["docs/de/**/*"]
dictionaries = ["de"]
```

**Available fields**

| Field | Behavior |
| ----------------------- | -------- |
| `paths` | Required. Glob patterns matched against the file path relative to the project root. A file matches the block if it matches *any* pattern. |
| `dictionaries` | Replaces the resolved `dictionaries` list. |
| `words` | Replaces the resolved `words` list. |
| `flag_words` | Replaces the resolved `flag_words` list. |
| `ignore_patterns` | Replaces the resolved `ignore_patterns` list. |
| `extra_dictionaries` | Appends to the resolved `dictionaries` list. |
| `extra_words` | Appends to the resolved `words` list. |
| `extra_flag_words` | Appends to the resolved `flag_words` list. |
| `extra_ignore_patterns` | Appends to the resolved `ignore_patterns` list. |

Glob syntax matches `ignore_paths`: `*` (no separator), `**` (any directories), `?` (any single char), and `{a,b}` alternation.

**Resolution order:** all matching overrides are applied in declaration order, so later blocks win on the same field. Global overrides are applied before project overrides, so project settings always have the final say. If both a replace field (e.g., `words`) and its append sibling (`extra_words`) appear in the same block, replace runs first and then append is layered on top.

**Interaction with `ignore_paths`:** `ignore_paths` is evaluated *before* overrides — an ignored file is skipped entirely and no overrides apply to it.

**Skipped silently:** an `[[overrides]]` block is dropped (with a warning) if `paths` is missing or empty, every glob is invalid, or no other field is set.

### LSP Initialization Options

Editors can pass `initializationOptions` when starting the Codebook LSP for LSP-specific options. Refer to your editor's documentation for how to apply these options. All values are optional, omit them for the default behavior:
Expand Down
2 changes: 1 addition & 1 deletion crates/codebook-config/src/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub(crate) fn unix_cache_dir() -> PathBuf {

/// Compile user-provided ignore regex patterns, dropping invalid entries.
/// Patterns are compiled with multiline mode so `^` and `$` match line boundaries.
pub(crate) fn build_ignore_regexes(patterns: &[String]) -> Vec<Regex> {
pub fn build_ignore_regexes(patterns: &[String]) -> Vec<Regex> {
patterns
.iter()
.filter_map(
Expand Down
286 changes: 283 additions & 3 deletions crates/codebook-config/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
mod helpers;
mod settings;
pub mod helpers;
pub mod settings;
mod watched_file;
use crate::helpers::expand_tilde;
pub use crate::settings::ConfigSettings;

use crate::watched_file::WatchedFile;
use log::debug;
use log::info;
Expand Down Expand Up @@ -35,6 +34,12 @@ pub trait CodebookConfig: Sync + Send + Debug {
fn get_min_word_length(&self) -> usize;
fn should_check_tag(&self, tag: &str) -> bool;
fn cache_dir(&self) -> &Path;

/// Resolve settings with overrides applied for a specific file path.
/// Returns None if no overrides match (callers should use base config methods).
fn resolve_for_file(&self, _relative_path: &Path) -> Option<Arc<ConfigSettings>> {
None
}
}

/// Internal mutable state
Expand Down Expand Up @@ -546,6 +551,22 @@ impl CodebookConfig for CodebookConfigFile {
fn cache_dir(&self) -> &Path {
&self.cache_dir
}

/// Resolve settings with overrides applied for a specific file path.
fn resolve_for_file(&self, relative_path: &Path) -> Option<Arc<ConfigSettings>> {
let snapshot = self.snapshot();
if snapshot.overrides.is_empty() {
return None;
}
if !snapshot
.overrides
.iter()
.any(|o| o.matches_path(relative_path))
{
return None;
}
Some(Arc::new(snapshot.resolve_for_path(relative_path)))
}
}

#[derive(Debug)]
Expand Down Expand Up @@ -1188,4 +1209,263 @@ mod tests {

Ok(())
}

// --- Override integration tests ---

#[test]
fn test_resolve_for_file_no_overrides() {
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
let settings = ConfigSettings {
words: vec!["base".to_string()],
..Default::default()
};
inner.project_config = inner.project_config.clone().with_content_value(settings);
CodebookConfigFile::rebuild_snapshot(&mut inner);
}

// No overrides, should return None
assert!(config
.resolve_for_file(Path::new("src/main.rs"))
.is_none());
}

#[test]
fn test_resolve_for_file_with_matching_override() -> Result<(), io::Error> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
let mut file = File::create(&config_path)?;
write!(
file,
r#"
words = ["base"]

[[overrides]]
paths = ["**/*.md"]
extra_words = ["markdown"]
"#
)?;

let config = load_from_file(ConfigType::Project, &config_path)?;

// .md file should get override
let resolved = config.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
let settings = resolved.unwrap();
assert!(settings.is_allowed_word("base"));
assert!(settings.is_allowed_word("markdown"));

// .rs file should not match
assert!(config
.resolve_for_file(Path::new("src/main.rs"))
.is_none());

Ok(())
}

#[test]
fn test_resolve_for_file_global_and_project_overrides() -> Result<(), io::Error> {
let global_temp = TempDir::new().unwrap();
let project_temp = TempDir::new().unwrap();

// Global config with an override
let global_config_path = global_temp.path().join("codebook.toml");
fs::write(
&global_config_path,
r#"
words = ["globalbase"]

[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromglobal"]
"#,
)?;

// Project config with an override
let project_config_path = project_temp.path().join("codebook.toml");
fs::write(
&project_config_path,
r#"
words = ["projectbase"]

[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromproject"]
"#,
)?;

// Load both configs
let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
if let Ok(global_settings) =
CodebookConfigFile::load_settings_from_file(&global_config_path)
{
inner.global_config = WatchedFile::new(Some(global_config_path))
.with_content_value(global_settings);
}
if let Ok(project_settings) =
CodebookConfigFile::load_settings_from_file(&project_config_path)
{
inner.project_config = WatchedFile::new(Some(project_config_path))
.with_content_value(project_settings);
}
let effective = CodebookConfigFile::calculate_effective_settings(
&inner.project_config,
&inner.global_config,
);
inner.snapshot = Arc::new(effective);
}

// Resolve for a .md file — both overrides should apply
let resolved = config.resolve_for_file(Path::new("docs/guide.md"));
assert!(resolved.is_some());
let settings = resolved.unwrap();
assert!(settings.is_allowed_word("globalbase"));
assert!(settings.is_allowed_word("projectbase"));
assert!(settings.is_allowed_word("fromglobal"));
assert!(settings.is_allowed_word("fromproject"));

Ok(())
}

#[test]
fn test_resolve_for_file_use_global_false_ignores_global_overrides() -> Result<(), io::Error> {
let global_temp = TempDir::new().unwrap();
let project_temp = TempDir::new().unwrap();

let global_config_path = global_temp.path().join("codebook.toml");
fs::write(
&global_config_path,
r#"
words = ["globalbase"]

[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromglobal"]
"#,
)?;

let project_config_path = project_temp.path().join("codebook.toml");
fs::write(
&project_config_path,
r#"
words = ["projectbase"]
use_global = false

[[overrides]]
paths = ["**/*.md"]
extra_words = ["fromproject"]
"#,
)?;

let config = CodebookConfigFile::default();
{
let mut inner = config.inner.write().unwrap();
if let Ok(global_settings) =
CodebookConfigFile::load_settings_from_file(&global_config_path)
{
inner.global_config = WatchedFile::new(Some(global_config_path))
.with_content_value(global_settings);
}
if let Ok(project_settings) =
CodebookConfigFile::load_settings_from_file(&project_config_path)
{
inner.project_config = WatchedFile::new(Some(project_config_path))
.with_content_value(project_settings);
}
let effective = CodebookConfigFile::calculate_effective_settings(
&inner.project_config,
&inner.global_config,
);
inner.snapshot = Arc::new(effective);
}

// With use_global = false, global overrides should be ignored
let resolved = config.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
let settings = resolved.unwrap();
assert!(settings.is_allowed_word("projectbase"));
assert!(settings.is_allowed_word("fromproject"));
// Global words and overrides should NOT be present
assert!(!settings.is_allowed_word("globalbase"));
assert!(!settings.is_allowed_word("fromglobal"));

Ok(())
}

#[test]
fn test_save_preserves_overrides() -> Result<(), io::Error> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(
&config_path,
r#"
words = ["base"]

[[overrides]]
paths = ["**/*.md"]
extra_words = ["markdown"]
"#,
)?;

let config = load_from_file(ConfigType::Project, &config_path)?;

// Add a word and save
config.add_word("newword")?;
config.save()?;

// Reload and verify overrides are preserved
let reloaded = load_from_file(ConfigType::Project, &config_path)?;
assert!(reloaded.is_allowed_word("base"));
assert!(reloaded.is_allowed_word("newword"));

// Override should still work
let resolved = reloaded.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
assert!(resolved.unwrap().is_allowed_word("markdown"));

Ok(())
}

#[test]
fn test_reload_picks_up_override_changes() -> Result<(), io::Error> {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("codebook.toml");
fs::write(
&config_path,
r#"
words = ["base"]
"#,
)?;

let config = load_from_file(ConfigType::Project, &config_path)?;

// No overrides initially
assert!(config
.resolve_for_file(Path::new("README.md"))
.is_none());

// Update config with overrides
fs::write(
&config_path,
r#"
words = ["base"]

[[overrides]]
paths = ["**/*.md"]
extra_words = ["markdown"]
"#,
)?;

config.reload()?;

// Now overrides should apply
let resolved = config.resolve_for_file(Path::new("README.md"));
assert!(resolved.is_some());
assert!(resolved.unwrap().is_allowed_word("markdown"));

Ok(())
}
}
Loading
Loading