use std::collections::HashMap;
use std::path::Path;
use anyhow::Result;
#[cfg(any(feature = "cli", feature = "watch"))]
use glob::Pattern;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct FormatOverrides {
pub extra_abbreviations: Vec<String>,
pub max_width: Option<usize>,
pub verbatim_envs: Vec<String>,
pub structure_envs: Vec<String>,
pub verbatim_commands: Vec<String>,
}
#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
#[serde(default)]
pub struct CodeLang {
pub line_comment: Option<String>,
pub block_comment: Option<[String; 2]>,
pub formatter: Option<Vec<String>>,
pub string_delims: Option<Vec<String>>,
pub escape: Option<String>,
}
impl CodeLang {
pub(crate) fn quote_chars(&self) -> Vec<char> {
match self.string_delims {
Some(ref delims) => delims.iter().filter_map(|d| d.chars().next()).collect(),
None => vec!['"', '\''],
}
}
pub(crate) fn escape_char(&self) -> char {
self.escape
.as_ref()
.and_then(|e| e.chars().next())
.unwrap_or('\\')
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct ProjectConfig {
pub extra_abbreviations: Vec<String>,
#[serde(alias = "ignore")]
pub ignore_patterns: Vec<String>,
#[serde(alias = "format")]
pub default_format: Option<String>,
pub max_width: Option<usize>,
pub long_threshold: Option<usize>,
pub clause_breaks: Option<bool>,
pub lang: Option<String>,
pub org: Option<FormatOverrides>,
pub latex: Option<FormatOverrides>,
pub markdown: Option<FormatOverrides>,
pub rst: Option<FormatOverrides>,
pub plaintext: Option<FormatOverrides>,
pub code: HashMap<String, CodeLang>,
}
impl ProjectConfig {
pub fn find_and_load(start_dir: &Path) -> Result<Self> {
let mut dir = start_dir.to_path_buf();
loop {
let candidate = dir.join(".snapperrc.toml");
if candidate.is_file() {
return Self::load(&candidate);
}
if !dir.pop() {
break;
}
}
Ok(Self::default())
}
pub fn load(path: &Path) -> Result<Self> {
let contents = std::fs::read_to_string(path)?;
Self::parse(&contents)
}
fn parse(toml_str: &str) -> Result<Self> {
let config: ProjectConfig = toml::from_str(toml_str)?;
Ok(config)
}
pub fn resolve(explicit_path: Option<&Path>) -> Result<Self> {
if let Some(path) = explicit_path {
Self::load(path)
} else {
let cwd = std::env::current_dir()?;
Self::find_and_load(&cwd)
}
}
pub fn abbreviations_for_format(&self, format: &str) -> Vec<String> {
let mut abbrevs = self.extra_abbreviations.clone();
let overrides = match format {
"org" => self.org.as_ref(),
"latex" => self.latex.as_ref(),
"markdown" => self.markdown.as_ref(),
"rst" => self.rst.as_ref(),
"plaintext" => self.plaintext.as_ref(),
_ => None,
};
if let Some(ov) = overrides {
abbrevs.extend(ov.extra_abbreviations.iter().cloned());
}
abbrevs
}
pub fn max_width_for_format(&self, format: &str) -> Option<usize> {
let overrides = match format {
"org" => self.org.as_ref(),
"latex" => self.latex.as_ref(),
"markdown" => self.markdown.as_ref(),
"rst" => self.rst.as_ref(),
"plaintext" => self.plaintext.as_ref(),
_ => None,
};
overrides.and_then(|ov| ov.max_width).or(self.max_width)
}
pub fn latex_verbatim_envs(&self) -> Vec<String> {
self.latex
.as_ref()
.map(|ov| ov.verbatim_envs.clone())
.unwrap_or_default()
}
pub fn latex_structure_envs(&self) -> Vec<String> {
self.latex
.as_ref()
.map(|ov| ov.structure_envs.clone())
.unwrap_or_default()
}
pub fn latex_verbatim_commands(&self) -> Vec<String> {
self.latex
.as_ref()
.map(|ov| ov.verbatim_commands.clone())
.unwrap_or_default()
}
#[cfg(any(feature = "cli", feature = "watch"))]
pub fn is_ignored(&self, path: &Path) -> bool {
self.ignore_patterns.iter().any(|pattern| {
Pattern::new(pattern).ok().is_some_and(|compiled| {
compiled.matches_path(path)
|| std::env::current_dir()
.ok()
.and_then(|cwd| path.strip_prefix(&cwd).ok())
.is_some_and(|relative| compiled.matches_path(relative))
|| path
.file_name()
.is_some_and(|name| compiled.matches_path(Path::new(name)))
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_empty_config() {
let config = ProjectConfig::parse("").unwrap();
assert!(config.extra_abbreviations.is_empty());
assert!(config.ignore_patterns.is_empty());
assert!(config.default_format.is_none());
assert!(config.max_width.is_none());
}
#[test]
fn parse_full_config() {
let toml = r#"
# Project-specific snapper config
extra_abbreviations = ["Dept", "Univ", "Corp"]
ignore = ["*.bib", "*.cls"]
format = "org"
max_width = 80
clause_breaks = true
lang = "de"
"#;
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
assert_eq!(config.default_format, Some("org".to_string()));
assert_eq!(config.max_width, Some(80));
assert_eq!(config.long_threshold, None);
assert_eq!(config.clause_breaks, Some(true));
assert_eq!(config.lang, Some("de".to_string()));
}
#[test]
fn parse_long_threshold() {
let config = ProjectConfig::parse("long_threshold = 100\n").unwrap();
assert_eq!(config.long_threshold, Some(100));
}
#[test]
fn parse_comments_and_blanks() {
let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.extra_abbreviations, vec!["Fig"]);
}
#[test]
fn parse_per_format_overrides() {
let toml = r#"
extra_abbreviations = ["Global"]
max_width = 80
[org]
extra_abbreviations = ["PROPERTIES", "DEADLINE"]
[latex]
extra_abbreviations = ["Thm", "Lem"]
max_width = 100
"#;
let config = ProjectConfig::parse(toml).unwrap();
let org_abbrevs = config.abbreviations_for_format("org");
assert!(org_abbrevs.contains(&"Global".to_string()));
assert!(org_abbrevs.contains(&"PROPERTIES".to_string()));
assert_eq!(config.max_width_for_format("org"), Some(80));
assert_eq!(config.max_width_for_format("latex"), Some(100));
assert_eq!(config.max_width_for_format("plaintext"), Some(80));
}
#[test]
fn parse_code_table_seven_seed_languages() {
let toml = r##"
[code.rust]
line_comment = "//"
block_comment = ["/*", "*/"]
formatter = ["rustfmt", "--edition", "2024"]
[code.python]
line_comment = "#"
block_comment = ["\"\"\"", "\"\"\""]
formatter = ["ruff", "format", "-"]
[code.toml]
line_comment = "#"
formatter = ["taplo", "format", "-"]
[code.lua]
line_comment = "--"
block_comment = ["--[[", "]]"]
[code.lisp]
line_comment = ";"
[code.html]
block_comment = ["<!--", "-->"]
[code.javascript]
line_comment = "//"
block_comment = ["/*", "*/"]
formatter = ["prettier", "--stdin-filepath", "src.js"]
"##;
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.code.len(), 7);
let rust = config.code.get("rust").expect("rust entry present");
assert_eq!(rust.line_comment.as_deref(), Some("//"));
assert_eq!(
rust.block_comment.as_ref(),
Some(&["/*".to_string(), "*/".to_string()])
);
assert_eq!(
rust.formatter.as_deref(),
Some(
&[
"rustfmt".to_string(),
"--edition".to_string(),
"2024".to_string()
][..]
)
);
let lisp = config.code.get("lisp").expect("lisp entry present");
assert_eq!(lisp.line_comment.as_deref(), Some(";"));
assert!(lisp.block_comment.is_none());
assert!(lisp.formatter.is_none());
let html = config.code.get("html").expect("html entry present");
assert!(html.line_comment.is_none());
assert_eq!(
html.block_comment.as_ref(),
Some(&["<!--".to_string(), "-->".to_string()])
);
assert!(html.formatter.is_none());
}
#[test]
fn parse_rst_overrides() {
let toml = r#"
[rst]
extra_abbreviations = ["Fig"]
max_width = 72
"#;
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.max_width_for_format("rst"), Some(72));
assert_eq!(config.abbreviations_for_format("rst"), vec!["Fig"]);
}
#[test]
fn parse_latex_env_and_command_lists() {
let toml = r#"
[latex]
extra_abbreviations = ["Thm"]
verbatim_envs = ["Verbatim"]
structure_envs = ["algorithm", "comment"]
verbatim_commands = ["Verb"]
"#;
let config = ProjectConfig::parse(toml).unwrap();
assert_eq!(config.latex_verbatim_envs(), vec!["Verbatim"]);
assert_eq!(config.latex_structure_envs(), vec!["algorithm", "comment"]);
assert_eq!(config.latex_verbatim_commands(), vec!["Verb"]);
assert_eq!(config.abbreviations_for_format("latex"), vec!["Thm"]);
}
#[test]
fn missing_latex_env_keys_are_empty_extras() {
let config = ProjectConfig::parse("[latex]\nextra_abbreviations = [\"Thm\"]\n").unwrap();
assert!(config.latex_verbatim_envs().is_empty());
assert!(config.latex_structure_envs().is_empty());
assert!(config.latex_verbatim_commands().is_empty());
}
#[test]
fn latex_other_regex_key_is_ignored() {
let config = ProjectConfig::parse("[latex]\nother = \".*code\"\n").unwrap();
assert!(config.latex_verbatim_envs().is_empty());
assert!(config.latex_structure_envs().is_empty());
assert!(config.latex_verbatim_commands().is_empty());
}
}