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    /// Default language for abbreviation sets.
49    pub lang: Option<String>,
50
51    /// Per-format overrides.
52    pub org: Option<FormatOverrides>,
53    pub latex: Option<FormatOverrides>,
54    pub markdown: Option<FormatOverrides>,
55    pub rst: Option<FormatOverrides>,
56    pub plaintext: Option<FormatOverrides>,
57
58    /// Per-language code-block reflow and formatter configuration.
59    /// Key is the language identifier as it appears on the code fence
60    /// (e.g. `rust`, `python`, `toml`). Missing languages mean the code
61    /// block passes through unchanged.
62    pub code: HashMap<String, CodeLang>,
63}
64
65impl ProjectConfig {
66    /// Search for `.snapperrc.toml` starting from `start_dir` and walking up
67    /// to the filesystem root. Returns the default config if none found.
68    pub fn find_and_load(start_dir: &Path) -> Result<Self> {
69        let mut dir = start_dir.to_path_buf();
70        loop {
71            let candidate = dir.join(".snapperrc.toml");
72            if candidate.is_file() {
73                return Self::load(&candidate);
74            }
75            if !dir.pop() {
76                break;
77            }
78        }
79        Ok(Self::default())
80    }
81
82    /// Load config from a specific path.
83    pub fn load(path: &Path) -> Result<Self> {
84        let contents = std::fs::read_to_string(path)?;
85        Self::parse(&contents)
86    }
87
88    fn parse(toml_str: &str) -> Result<Self> {
89        let config: ProjectConfig = toml::from_str(toml_str)?;
90        Ok(config)
91    }
92
93    /// Get the config file path if explicitly provided, otherwise search.
94    pub fn resolve(explicit_path: Option<&Path>) -> Result<Self> {
95        if let Some(path) = explicit_path {
96            Self::load(path)
97        } else {
98            let cwd = std::env::current_dir()?;
99            Self::find_and_load(&cwd)
100        }
101    }
102
103    /// Get merged extra_abbreviations for a specific format, combining
104    /// top-level abbreviations with per-format overrides.
105    pub fn abbreviations_for_format(&self, format: &str) -> Vec<String> {
106        let mut abbrevs = self.extra_abbreviations.clone();
107        let overrides = match format {
108            "org" => self.org.as_ref(),
109            "latex" => self.latex.as_ref(),
110            "markdown" => self.markdown.as_ref(),
111            "rst" => self.rst.as_ref(),
112            "plaintext" => self.plaintext.as_ref(),
113            _ => None,
114        };
115        if let Some(ov) = overrides {
116            abbrevs.extend(ov.extra_abbreviations.iter().cloned());
117        }
118        abbrevs
119    }
120
121    /// Get max_width for a specific format (per-format overrides top-level).
122    pub fn max_width_for_format(&self, format: &str) -> Option<usize> {
123        let overrides = match format {
124            "org" => self.org.as_ref(),
125            "latex" => self.latex.as_ref(),
126            "markdown" => self.markdown.as_ref(),
127            "rst" => self.rst.as_ref(),
128            "plaintext" => self.plaintext.as_ref(),
129            _ => None,
130        };
131        overrides.and_then(|ov| ov.max_width).or(self.max_width)
132    }
133
134    #[cfg(any(feature = "cli", feature = "watch"))]
135    pub fn is_ignored(&self, path: &Path) -> bool {
136        self.ignore_patterns.iter().any(|pattern| {
137            Pattern::new(pattern).ok().is_some_and(|compiled| {
138                compiled.matches_path(path)
139                    || std::env::current_dir()
140                        .ok()
141                        .and_then(|cwd| path.strip_prefix(&cwd).ok())
142                        .is_some_and(|relative| compiled.matches_path(relative))
143                    || path
144                        .file_name()
145                        .is_some_and(|name| compiled.matches_path(Path::new(name)))
146            })
147        })
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn parse_empty_config() {
157        let config = ProjectConfig::parse("").unwrap();
158        assert!(config.extra_abbreviations.is_empty());
159        assert!(config.ignore_patterns.is_empty());
160        assert!(config.default_format.is_none());
161        assert!(config.max_width.is_none());
162    }
163
164    #[test]
165    fn parse_full_config() {
166        let toml = r#"
167# Project-specific snapper config
168extra_abbreviations = ["Dept", "Univ", "Corp"]
169ignore = ["*.bib", "*.cls"]
170format = "org"
171max_width = 80
172lang = "de"
173"#;
174        let config = ProjectConfig::parse(toml).unwrap();
175        assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
176        assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
177        assert_eq!(config.default_format, Some("org".to_string()));
178        assert_eq!(config.max_width, Some(80));
179        assert_eq!(config.lang, Some("de".to_string()));
180    }
181
182    #[test]
183    fn parse_comments_and_blanks() {
184        let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
185        let config = ProjectConfig::parse(toml).unwrap();
186        assert_eq!(config.extra_abbreviations, vec!["Fig"]);
187    }
188
189    #[test]
190    fn parse_per_format_overrides() {
191        let toml = r#"
192extra_abbreviations = ["Global"]
193max_width = 80
194
195[org]
196extra_abbreviations = ["PROPERTIES", "DEADLINE"]
197
198[latex]
199extra_abbreviations = ["Thm", "Lem"]
200max_width = 100
201"#;
202        let config = ProjectConfig::parse(toml).unwrap();
203        let org_abbrevs = config.abbreviations_for_format("org");
204        assert!(org_abbrevs.contains(&"Global".to_string()));
205        assert!(org_abbrevs.contains(&"PROPERTIES".to_string()));
206        assert_eq!(config.max_width_for_format("org"), Some(80));
207        assert_eq!(config.max_width_for_format("latex"), Some(100));
208        assert_eq!(config.max_width_for_format("plaintext"), Some(80));
209    }
210
211    #[test]
212    fn parse_code_table_seven_seed_languages() {
213        // The shape `snapper init` writes: seven languages with mixed
214        // line_comment / block_comment / formatter fields. The python
215        // triple-quoted markers need an extra `#` on the raw delimiter
216        // so the inner escaped quotes lex.
217        let toml = r##"
218[code.rust]
219line_comment = "//"
220block_comment = ["/*", "*/"]
221formatter = ["rustfmt", "--edition", "2024"]
222
223[code.python]
224line_comment = "#"
225block_comment = ["\"\"\"", "\"\"\""]
226formatter = ["ruff", "format", "-"]
227
228[code.toml]
229line_comment = "#"
230formatter = ["taplo", "format", "-"]
231
232[code.lua]
233line_comment = "--"
234block_comment = ["--[[", "]]"]
235
236[code.lisp]
237line_comment = ";"
238
239[code.html]
240block_comment = ["<!--", "-->"]
241
242[code.javascript]
243line_comment = "//"
244block_comment = ["/*", "*/"]
245formatter = ["prettier", "--stdin-filepath", "src.js"]
246"##;
247        let config = ProjectConfig::parse(toml).unwrap();
248        assert_eq!(config.code.len(), 7);
249        let rust = config.code.get("rust").expect("rust entry present");
250        assert_eq!(rust.line_comment.as_deref(), Some("//"));
251        assert_eq!(
252            rust.block_comment.as_ref(),
253            Some(&["/*".to_string(), "*/".to_string()])
254        );
255        assert_eq!(
256            rust.formatter.as_deref(),
257            Some(&["rustfmt".to_string(), "--edition".to_string(), "2024".to_string()][..])
258        );
259        // lisp has only line_comment; missing fields stay None (no panic).
260        let lisp = config.code.get("lisp").expect("lisp entry present");
261        assert_eq!(lisp.line_comment.as_deref(), Some(";"));
262        assert!(lisp.block_comment.is_none());
263        assert!(lisp.formatter.is_none());
264        // html has only block_comment.
265        let html = config.code.get("html").expect("html entry present");
266        assert!(html.line_comment.is_none());
267        assert_eq!(
268            html.block_comment.as_ref(),
269            Some(&["<!--".to_string(), "-->".to_string()])
270        );
271        assert!(html.formatter.is_none());
272    }
273
274    #[test]
275    fn parse_rst_overrides() {
276        let toml = r#"
277[rst]
278extra_abbreviations = ["Fig"]
279max_width = 72
280"#;
281        let config = ProjectConfig::parse(toml).unwrap();
282        assert_eq!(config.max_width_for_format("rst"), Some(72));
283        assert_eq!(config.abbreviations_for_format("rst"), vec!["Fig"]);
284    }
285}