Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

87 changes: 82 additions & 5 deletions crates/codebook-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod helpers;
mod settings;
mod watched_file;
use crate::settings::ConfigSettings;
pub use crate::settings::CustomDictionariesEntry;
use crate::watched_file::WatchedFile;
use log::debug;
use log::info;
Expand All @@ -14,16 +15,17 @@ use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};

static CACHE_DIR: &str = "codebook";
static GLOBAL_CONFIG_FILE: &str = "codebook.toml";
static USER_CONFIG_FILES: [&str; 2] = ["codebook.toml", ".codebook.toml"];
const CACHE_DIR: &str = "codebook";
const GLOBAL_CONFIG_FILE: &str = "codebook.toml";
const USER_CONFIG_FILES: [&str; 2] = ["codebook.toml", ".codebook.toml"];

/// The main trait for Codebook configuration.
pub trait CodebookConfig: Sync + Send + Debug {
fn add_word(&self, word: &str) -> Result<bool, io::Error>;
fn add_word_global(&self, word: &str) -> Result<bool, io::Error>;
fn add_ignore(&self, file: &str) -> Result<bool, io::Error>;
fn get_dictionary_ids(&self) -> Vec<String>;
fn get_custom_dictionaries_definitions(&self) -> Vec<CustomDictionariesEntry>;
fn should_ignore_path(&self, path: &Path) -> bool;
fn is_allowed_word(&self, word: &str) -> bool;
fn should_flag_word(&self, word: &str) -> bool;
Expand Down Expand Up @@ -198,8 +200,11 @@ impl CodebookConfigFile {
let path = path.as_ref();
let content = fs::read_to_string(path)?;

match toml::from_str(&content) {
Ok(settings) => Ok(settings),
match toml::from_str::<ConfigSettings>(&content) {
Ok(mut settings) => {
settings.set_config_file_paths(path);
Ok(settings)
}
Err(e) => {
let err = io::Error::new(
ErrorKind::InvalidData,
Expand All @@ -223,6 +228,7 @@ impl CodebookConfigFile {
if project.use_global {
if let Some(global) = global_config.content() {
let mut effective = global.clone();

effective.merge(project);
effective
} else {
Expand Down Expand Up @@ -496,6 +502,11 @@ impl CodebookConfig for CodebookConfigFile {
fn cache_dir(&self) -> &Path {
&self.cache_dir
}

fn get_custom_dictionaries_definitions(&self) -> Vec<CustomDictionariesEntry> {
let snapshot = self.snapshot();
snapshot.custom_dictionaries_definitions.clone()
}
}

#[derive(Debug)]
Expand All @@ -520,6 +531,18 @@ impl CodebookConfigMemory {
cache_dir: env::temp_dir().join(CACHE_DIR),
}
}

pub fn add_dict_id(&self, id: &str) {
let mut settings = self.settings.write().unwrap();
settings.dictionaries.push(id.into());
settings.sort_and_dedup();
}

pub fn add_custom_dict(&self, custom_dict: CustomDictionariesEntry) {
let mut settings = self.settings.write().unwrap();
settings.custom_dictionaries_definitions.push(custom_dict);
settings.sort_and_dedup();
}
}

impl CodebookConfigMemory {
Expand Down Expand Up @@ -576,6 +599,11 @@ impl CodebookConfig for CodebookConfigMemory {
fn cache_dir(&self) -> &Path {
&self.cache_dir
}

fn get_custom_dictionaries_definitions(&self) -> Vec<CustomDictionariesEntry> {
let snapshot = self.snapshot();
snapshot.custom_dictionaries_definitions.clone()
}
}

#[cfg(test)]
Expand Down Expand Up @@ -1066,4 +1094,53 @@ mod tests {

Ok(())
}

#[test]
fn test_normalization_of_custom_dict_paths() -> Result<(), io::Error> {
let temp_dir = TempDir::new().unwrap();
let config_path = Arc::from(temp_dir.path().join("codebook.toml").as_path());
let relative_custom_dict_path = temp_dir.path().join("custom_rel.txt");
let absolute_custom_dict_path = temp_dir.path().join("custom_abs.txt");
let mut file = File::create(&config_path)?;
File::create(&relative_custom_dict_path)?;
File::create(&absolute_custom_dict_path)?;

let expected = vec![
CustomDictionariesEntry {
name: "absolute".to_owned(),
path: absolute_custom_dict_path.to_str().unwrap().to_string(),
allow_add_words: true,
config_file_path: Some(config_path.clone()),
},
CustomDictionariesEntry {
name: "relative".to_owned(),
path: relative_custom_dict_path.to_str().unwrap().to_string(),
allow_add_words: false,
config_file_path: Some(config_path.clone()),
},
];

let a = format!(
r#"
[[custom_dictionaries_definitions]]
name = "absolute"
path = "{}"
allow_add_words = true
[[custom_dictionaries_definitions]]
name = "relative"
path = "{}"
allow_add_words = false
"#,
absolute_custom_dict_path.display(),
relative_custom_dict_path.display(),
);
file.write_all(a.as_bytes())?;

let config = load_from_file(ConfigType::Project, &config_path)?;
let custom_dicts = config.snapshot().custom_dictionaries_definitions.clone();
assert_eq!(expected, custom_dicts);

Ok(())
}
}
125 changes: 124 additions & 1 deletion crates/codebook-config/src/settings.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,55 @@
use std::{
io,
path::{self, Path, PathBuf},
sync::Arc,
};

use serde::{Deserialize, Serialize};

#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq)]
pub struct CustomDictionariesEntry {
/// The name of the custom dictionary
#[serde(default)]
pub name: String,

/// An absolute or relative path to the custom dictionary
#[serde(default)]
pub path: String,

/// Allow adding words to this dictionary
#[serde(default)]
pub allow_add_words: bool,

/// For internal use to track the coodbook.toml that originated this entry
#[serde(skip)]
pub config_file_path: Option<Arc<Path>>,
}

impl CustomDictionariesEntry {
pub fn resolve_full_path(&self) -> Result<PathBuf, io::Error> {
let full_path = if let Some(config_file_path) = &self.config_file_path {
config_file_path
.parent()
.ok_or(io::Error::from(io::ErrorKind::NotFound))?
.join(Path::new(&self.path))
} else {
PathBuf::from(&self.path)
};

path::absolute(&full_path)
}
}

#[derive(Debug, Serialize, Clone, PartialEq)]
pub struct ConfigSettings {
/// List of dictionaries to use for spell checking
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub dictionaries: Vec<String>,

/// List of custom dictionaries to use for spell checking
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub custom_dictionaries_definitions: Vec<CustomDictionariesEntry>,

/// Custom allowlist of words
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub words: Vec<String>,
Expand Down Expand Up @@ -56,6 +101,7 @@ impl Default for ConfigSettings {
fn default() -> Self {
Self {
dictionaries: vec![],
custom_dictionaries_definitions: vec![],
words: Vec::new(),
flag_words: Vec::new(),
ignore_paths: Vec::new(),
Expand All @@ -79,6 +125,8 @@ impl<'de> Deserialize<'de> for ConfigSettings {
#[serde(default)]
dictionaries: Vec<String>,
#[serde(default)]
custom_dictionaries_definitions: Vec<CustomDictionariesEntry>,
#[serde(default)]
words: Vec<String>,
#[serde(default)]
flag_words: Vec<String>,
Expand All @@ -95,6 +143,14 @@ impl<'de> Deserialize<'de> for ConfigSettings {
let helper = Helper::deserialize(deserializer)?;
Ok(ConfigSettings {
dictionaries: to_lowercase_vec(helper.dictionaries),
custom_dictionaries_definitions: helper
.custom_dictionaries_definitions
.into_iter()
.map(|mut c| {
c.name.make_ascii_lowercase();
c
})
.collect(),
words: to_lowercase_vec(helper.words),
flag_words: to_lowercase_vec(helper.flag_words),
ignore_paths: helper.ignore_paths,
Expand All @@ -106,10 +162,12 @@ impl<'de> Deserialize<'de> for ConfigSettings {
}

impl ConfigSettings {
/// Merge another config settings into this one, sorting and deduplicating all collections
/// Merge another config settings into this one, sorting and deduplicating all collections, prioritizing self when possible
pub fn merge(&mut self, other: ConfigSettings) {
// Add items from the other config
self.dictionaries.extend(other.dictionaries);
self.custom_dictionaries_definitions
.extend(other.custom_dictionaries_definitions);
self.words.extend(other.words);
self.flag_words.extend(other.flag_words);
self.ignore_paths.extend(other.ignore_paths);
Expand All @@ -131,11 +189,21 @@ impl ConfigSettings {
pub fn sort_and_dedup(&mut self) {
// Sort and deduplicate each Vec
sort_and_dedup(&mut self.dictionaries);
sort_and_dedup_by(&mut self.custom_dictionaries_definitions, |d1, d2| {
d1.name.cmp(&d2.name)
});
sort_and_dedup(&mut self.words);
sort_and_dedup(&mut self.flag_words);
sort_and_dedup(&mut self.ignore_paths);
sort_and_dedup(&mut self.ignore_patterns);
}

pub fn set_config_file_paths(&mut self, config_path: &Path) {
let config_path: Arc<Path> = Arc::from(config_path);
for custom_directory in &mut self.custom_dictionaries_definitions {
custom_directory.config_file_path = Some(config_path.clone());
}
}
}

/// Helper function to sort and deduplicate a Vec of strings
Expand All @@ -144,10 +212,26 @@ fn sort_and_dedup(vec: &mut Vec<String>) {
vec.dedup();
}

pub fn sort_and_dedup_by<T, F>(vec: &mut Vec<T>, f: F)
where
F: Fn(&T, &T) -> std::cmp::Ordering,
{
vec.sort_by(&f);
vec.dedup_by(|d1, d2| f(d1, d2) == std::cmp::Ordering::Equal);
}

#[cfg(test)]
mod tests {
use super::*;

fn build_fake_custom_dict(name: &str) -> CustomDictionariesEntry {
CustomDictionariesEntry {
name: name.into(),
path: name.into(),
..Default::default()
}
}

#[test]
fn test_default() {
let config = ConfigSettings::default();
Expand Down Expand Up @@ -221,8 +305,14 @@ mod tests {

#[test]
fn test_merge() {
let mut duplicate_custom_dict = build_fake_custom_dict("duplicate");

let mut base = ConfigSettings {
dictionaries: vec!["en_us".to_string()],
custom_dictionaries_definitions: vec![
build_fake_custom_dict("base_unique"),
duplicate_custom_dict.clone(),
],
words: vec!["codebook".to_string()],
flag_words: vec!["todo".to_string()],
ignore_paths: vec!["**/*.md".to_string()],
Expand All @@ -231,8 +321,15 @@ mod tests {
min_word_length: 3,
};

// flip allow_add_words to true, to create a disparity between the dictionaries
duplicate_custom_dict.allow_add_words = !duplicate_custom_dict.allow_add_words;

let other = ConfigSettings {
dictionaries: vec!["en_gb".to_string(), "en_us".to_string()],
custom_dictionaries_definitions: vec![
duplicate_custom_dict.clone(),
build_fake_custom_dict("other_unique"),
],
words: vec!["rust".to_string()],
flag_words: vec!["fixme".to_string()],
ignore_paths: vec!["target/".to_string()],
Expand All @@ -245,6 +342,13 @@ mod tests {

// After merging and deduplicating, we should have combined items
assert_eq!(base.dictionaries, vec!["en_gb", "en_us"]);
assert_eq!(
base.custom_dictionaries_definitions
.iter()
.map(|d| d.name.clone())
.collect::<Vec<String>>(),
vec!["base_unique", "duplicate", "other_unique"]
);
assert_eq!(base.words, vec!["codebook", "rust"]);
assert_eq!(base.flag_words, vec!["fixme", "todo"]);
assert_eq!(base.ignore_paths, vec!["**/*.md", "target/"]);
Expand All @@ -258,6 +362,12 @@ mod tests {
assert!(base.use_global);
// min_word_length from other should override base (since it's non-default)
assert_eq!(base.min_word_length, 2);

// Assert that base custom_dictionaries_definitions took priority
assert_ne!(
base.custom_dictionaries_definitions.iter().find(|d| d.name == "duplicate").expect("custom_dictionaries_definitions duplicate must be present if set in ether of the merged dictionaries").allow_add_words
,duplicate_custom_dict.allow_add_words
);
}

#[test]
Expand Down Expand Up @@ -288,6 +398,11 @@ mod tests {
"en_us".to_string(),
"en_gb".to_string(),
],
custom_dictionaries_definitions: vec![
build_fake_custom_dict("custom_1"),
build_fake_custom_dict("custom_2"),
build_fake_custom_dict("custom_1"),
],
words: vec![
"rust".to_string(),
"codebook".to_string(),
Expand All @@ -311,6 +426,14 @@ mod tests {
config.sort_and_dedup();

assert_eq!(config.dictionaries, vec!["en_gb", "en_us"]);
assert_eq!(
config
.custom_dictionaries_definitions
.iter()
.map(|d| d.name.clone())
.collect::<Vec<String>>(),
vec!["custom_1", "custom_2"]
);
assert_eq!(config.words, vec!["codebook", "rust"]);
assert_eq!(config.flag_words, vec!["fixme", "todo"]);
assert_eq!(config.ignore_paths, vec!["**/*.md", "target/"]);
Expand Down
Loading