Skip to main content

snapper_fmt/
config.rs

1use std::collections::HashMap;
2use std::path::Path;
3
4use anyhow::Result;
5#[cfg(any(feature = "cli", feature = "watch"))]
6use glob::Pattern;
7use serde::Deserialize;
8
9/// Per-format overrides in .snapperrc.toml.
10#[derive(Debug, Default, Deserialize)]
11#[serde(default)]
12pub struct FormatOverrides {
13    pub extra_abbreviations: Vec<String>,
14    pub max_width: Option<usize>,
15}
16
17/// Per-language entry under the `[code]` table.
18///
19/// Each field is independent and optional:
20/// - `line_comment`: marker that introduces a single-line comment (e.g. `//`).
21/// - `block_comment`: opening and closing markers for a multi-line comment
22///   (e.g. `["/*", "*/"]`). Stored as a fixed-arity pair.
23/// - `formatter`: argv for an external formatter invoked via `--format-code`;
24///   `formatter[0]` is the binary, the rest are its arguments. Stdin/stdout
25///   carries block body.
26#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
27#[serde(default)]
28pub struct CodeLang {
29    pub line_comment: Option<String>,
30    pub block_comment: Option<[String; 2]>,
31    pub formatter: Option<Vec<String>>,
32}
33
34/// Per-project configuration loaded from `.snapperrc.toml`.
35#[derive(Debug, Default, Deserialize)]
36#[serde(default)]
37pub struct ProjectConfig {
38    /// Additional abbreviations that should not trigger sentence breaks.
39    pub extra_abbreviations: Vec<String>,
40    /// File patterns to ignore (glob syntax).
41    #[serde(alias = "ignore")]
42    pub ignore_patterns: Vec<String>,
43    /// Default format override.
44    #[serde(alias = "format")]
45    pub default_format: Option<String>,
46    /// Default max width.
47    pub max_width: Option<usize>,
48    /// Prefer soft breaks after independent-clause punctuation when wrapping.
49    pub clause_breaks: Option<bool>,
50    /// Default language for abbreviation sets.
51    pub lang: Option<String>,
52
53    /// Per-format overrides.
54    pub org: Option<FormatOverrides>,
55    pub latex: Option<FormatOverrides>,
56    pub markdown: Option<FormatOverrides>,
57    pub rst: Option<FormatOverrides>,
58    pub plaintext: Option<FormatOverrides>,
59
60    /// Per-language code-block reflow and formatter configuration.
61    /// Key is the language identifier as it appears on the code fence
62    /// (e.g. `rust`, `python`, `toml`). Missing languages mean the code
63    /// block passes through unchanged.
64    pub code: HashMap<String, CodeLang>,
65}
66
67impl ProjectConfig {
68    /// Search for `.snapperrc.toml` starting from `start_dir` and walking up
69    /// to the filesystem root. Returns the default config if none found.
70    pub fn find_and_load(start_dir: &Path) -> Result<Self> {
71        let mut dir = start_dir.to_path_buf();
72        loop {
73            let candidate = dir.join(".snapperrc.toml");
74            if candidate.is_file() {
75                return Self::load(&candidate);
76            }
77            if !dir.pop() {
78                break;
79            }
80        }
81        Ok(Self::default())
82    }
83
84    /// Load config from a specific path.
85    pub fn load(path: &Path) -> Result<Self> {
86        let contents = std::fs::read_to_string(path)?;
87        Self::parse(&contents)
88    }
89
90    fn parse(toml_str: &str) -> Result<Self> {
91        let config: ProjectConfig = toml::from_str(toml_str)?;
92        Ok(config)
93    }
94
95    /// Get the config file path if explicitly provided, otherwise search.
96    pub fn resolve(explicit_path: Option<&Path>) -> Result<Self> {
97        if let Some(path) = explicit_path {
98            Self::load(path)
99        } else {
100            let cwd = std::env::current_dir()?;
101            Self::find_and_load(&cwd)
102        }
103    }
104
105    /// Get merged extra_abbreviations for a specific format, combining
106    /// top-level abbreviations with per-format overrides.
107    pub fn abbreviations_for_format(&self, format: &str) -> Vec<String> {
108        let mut abbrevs = self.extra_abbreviations.clone();
109        let overrides = match format {
110            "org" => self.org.as_ref(),
111            "latex" => self.latex.as_ref(),
112            "markdown" => self.markdown.as_ref(),
113            "rst" => self.rst.as_ref(),
114            "plaintext" => self.plaintext.as_ref(),
115            _ => None,
116        };
117        if let Some(ov) = overrides {
118            abbrevs.extend(ov.extra_abbreviations.iter().cloned());
119        }
120        abbrevs
121    }
122
123    /// Get max_width for a specific format (per-format overrides top-level).
124    pub fn max_width_for_format(&self, format: &str) -> Option<usize> {
125        let overrides = match format {
126            "org" => self.org.as_ref(),
127            "latex" => self.latex.as_ref(),
128            "markdown" => self.markdown.as_ref(),
129            "rst" => self.rst.as_ref(),
130            "plaintext" => self.plaintext.as_ref(),
131            _ => None,
132        };
133        overrides.and_then(|ov| ov.max_width).or(self.max_width)
134    }
135
136    #[cfg(any(feature = "cli", feature = "watch"))]
137    pub fn is_ignored(&self, path: &Path) -> bool {
138        self.ignore_patterns.iter().any(|pattern| {
139            Pattern::new(pattern).ok().is_some_and(|compiled| {
140                compiled.matches_path(path)
141                    || std::env::current_dir()
142                        .ok()
143                        .and_then(|cwd| path.strip_prefix(&cwd).ok())
144                        .is_some_and(|relative| compiled.matches_path(relative))
145                    || path
146                        .file_name()
147                        .is_some_and(|name| compiled.matches_path(Path::new(name)))
148            })
149        })
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn parse_empty_config() {
159        let config = ProjectConfig::parse("").unwrap();
160        assert!(config.extra_abbreviations.is_empty());
161        assert!(config.ignore_patterns.is_empty());
162        assert!(config.default_format.is_none());
163        assert!(config.max_width.is_none());
164    }
165
166    #[test]
167    fn parse_full_config() {
168        let toml = r#"
169# Project-specific snapper config
170extra_abbreviations = ["Dept", "Univ", "Corp"]
171ignore = ["*.bib", "*.cls"]
172format = "org"
173max_width = 80
174clause_breaks = true
175lang = "de"
176"#;
177        let config = ProjectConfig::parse(toml).unwrap();
178        assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
179        assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
180        assert_eq!(config.default_format, Some("org".to_string()));
181        assert_eq!(config.max_width, Some(80));
182        assert_eq!(config.clause_breaks, Some(true));
183        assert_eq!(config.lang, Some("de".to_string()));
184    }
185
186    #[test]
187    fn parse_comments_and_blanks() {
188        let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
189        let config = ProjectConfig::parse(toml).unwrap();
190        assert_eq!(config.extra_abbreviations, vec!["Fig"]);
191    }
192
193    #[test]
194    fn parse_per_format_overrides() {
195        let toml = r#"
196extra_abbreviations = ["Global"]
197max_width = 80
198
199[org]
200extra_abbreviations = ["PROPERTIES", "DEADLINE"]
201
202[latex]
203extra_abbreviations = ["Thm", "Lem"]
204max_width = 100
205"#;
206        let config = ProjectConfig::parse(toml).unwrap();
207        let org_abbrevs = config.abbreviations_for_format("org");
208        assert!(org_abbrevs.contains(&"Global".to_string()));
209        assert!(org_abbrevs.contains(&"PROPERTIES".to_string()));
210        assert_eq!(config.max_width_for_format("org"), Some(80));
211        assert_eq!(config.max_width_for_format("latex"), Some(100));
212        assert_eq!(config.max_width_for_format("plaintext"), Some(80));
213    }
214
215    #[test]
216    fn parse_code_table_seven_seed_languages() {
217        // The shape `snapper init` writes: seven languages with mixed
218        // line_comment / block_comment / formatter fields. The python
219        // triple-quoted markers need an extra `#` on the raw delimiter
220        // so the inner escaped quotes lex.
221        let toml = r##"
222[code.rust]
223line_comment = "//"
224block_comment = ["/*", "*/"]
225formatter = ["rustfmt", "--edition", "2024"]
226
227[code.python]
228line_comment = "#"
229block_comment = ["\"\"\"", "\"\"\""]
230formatter = ["ruff", "format", "-"]
231
232[code.toml]
233line_comment = "#"
234formatter = ["taplo", "format", "-"]
235
236[code.lua]
237line_comment = "--"
238block_comment = ["--[[", "]]"]
239
240[code.lisp]
241line_comment = ";"
242
243[code.html]
244block_comment = ["<!--", "-->"]
245
246[code.javascript]
247line_comment = "//"
248block_comment = ["/*", "*/"]
249formatter = ["prettier", "--stdin-filepath", "src.js"]
250"##;
251        let config = ProjectConfig::parse(toml).unwrap();
252        assert_eq!(config.code.len(), 7);
253        let rust = config.code.get("rust").expect("rust entry present");
254        assert_eq!(rust.line_comment.as_deref(), Some("//"));
255        assert_eq!(
256            rust.block_comment.as_ref(),
257            Some(&["/*".to_string(), "*/".to_string()])
258        );
259        assert_eq!(
260            rust.formatter.as_deref(),
261            Some(
262                &[
263                    "rustfmt".to_string(),
264                    "--edition".to_string(),
265                    "2024".to_string()
266                ][..]
267            )
268        );
269        // lisp has only line_comment; missing fields stay None (no panic).
270        let lisp = config.code.get("lisp").expect("lisp entry present");
271        assert_eq!(lisp.line_comment.as_deref(), Some(";"));
272        assert!(lisp.block_comment.is_none());
273        assert!(lisp.formatter.is_none());
274        // html has only block_comment.
275        let html = config.code.get("html").expect("html entry present");
276        assert!(html.line_comment.is_none());
277        assert_eq!(
278            html.block_comment.as_ref(),
279            Some(&["<!--".to_string(), "-->".to_string()])
280        );
281        assert!(html.formatter.is_none());
282    }
283
284    #[test]
285    fn parse_rst_overrides() {
286        let toml = r#"
287[rst]
288extra_abbreviations = ["Fig"]
289max_width = 72
290"#;
291        let config = ProjectConfig::parse(toml).unwrap();
292        assert_eq!(config.max_width_for_format("rst"), Some(72));
293        assert_eq!(config.abbreviations_for_format("rst"), vec!["Fig"]);
294    }
295}