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    /// Extra LaTeX environments treated as code (no reflow).
16    /// Meaningful under `[latex]` only. Missing or empty keeps the built-in
17    /// minted/lstlisting/verbatim list; entries are added to it.
18    pub verbatim_envs: Vec<String>,
19    /// Extra LaTeX environments treated as structure (no reflow).
20    /// Meaningful under `[latex]` only. Missing or empty keeps
21    /// `NON_PROSE_ENVS`; entries are added to that list.
22    pub structure_envs: Vec<String>,
23    /// Extra LaTeX command names tokenized like `\verb` before split.
24    /// Meaningful under `[latex]` only. Missing or empty keeps verb/lstinline.
25    pub verbatim_commands: Vec<String>,
26}
27
28/// Per-language entry under the `[code]` table.
29///
30/// Each field is independent and optional:
31/// - `line_comment`: marker that introduces a single-line comment (e.g. `//`).
32/// - `block_comment`: opening and closing markers for a multi-line comment
33///   (e.g. `["/*", "*/"]`). Stored as a fixed-arity pair.
34/// - `formatter`: argv for an external formatter invoked via `--format-code`;
35///   `formatter[0]` is the binary, the rest are its arguments. Stdin/stdout
36///   carries block body.
37/// - `string_delims`: quote characters that open and close a string, used to
38///   tell a comment marker from the same characters inside a literal when no
39///   grammar is available. Defaults to `"` and `'`.
40/// - `escape`: character that escapes the next one inside a string. Defaults
41///   to a backslash.
42#[derive(Debug, Default, Clone, Deserialize, PartialEq, Eq)]
43#[serde(default)]
44pub struct CodeLang {
45    pub line_comment: Option<String>,
46    pub block_comment: Option<[String; 2]>,
47    pub formatter: Option<Vec<String>>,
48    pub string_delims: Option<Vec<String>>,
49    pub escape: Option<String>,
50}
51
52impl CodeLang {
53    /// Quote characters for this language, falling back to the pair almost
54    /// every language shares.
55    pub(crate) fn quote_chars(&self) -> Vec<char> {
56        match self.string_delims {
57            Some(ref delims) => delims.iter().filter_map(|d| d.chars().next()).collect(),
58            None => vec!['"', '\''],
59        }
60    }
61
62    /// Escape character for this language.
63    pub(crate) fn escape_char(&self) -> char {
64        self.escape
65            .as_ref()
66            .and_then(|e| e.chars().next())
67            .unwrap_or('\\')
68    }
69}
70
71/// Per-project configuration loaded from `.snapperrc.toml`.
72#[derive(Debug, Default, Deserialize)]
73#[serde(default)]
74pub struct ProjectConfig {
75    /// Additional abbreviations that should not trigger sentence breaks.
76    pub extra_abbreviations: Vec<String>,
77    /// File patterns to ignore (glob syntax).
78    #[serde(alias = "ignore")]
79    pub ignore_patterns: Vec<String>,
80    /// Default format override.
81    #[serde(alias = "format")]
82    pub default_format: Option<String>,
83    /// Default max width.
84    pub max_width: Option<usize>,
85    /// Character threshold for advisory `long` diagnostics when `max_width` is
86    /// unset. Defaults to 120 when omitted.
87    pub long_threshold: Option<usize>,
88    /// Prefer soft breaks after independent-clause punctuation. With
89    /// `max_width = 0` this still inserts a newline after each mark that
90    /// is already followed by whitespace.
91    pub clause_breaks: Option<bool>,
92    /// Default language for abbreviation sets.
93    pub lang: Option<String>,
94
95    /// Per-format overrides.
96    pub org: Option<FormatOverrides>,
97    pub latex: Option<FormatOverrides>,
98    pub markdown: Option<FormatOverrides>,
99    pub rst: Option<FormatOverrides>,
100    pub plaintext: Option<FormatOverrides>,
101
102    /// Per-language code-block reflow and formatter configuration.
103    /// Key is the language identifier as it appears on the code fence
104    /// (e.g. `rust`, `python`, `toml`). Missing languages mean the code
105    /// block passes through unchanged.
106    pub code: HashMap<String, CodeLang>,
107}
108
109impl ProjectConfig {
110    /// Search for `.snapperrc.toml` starting from `start_dir` and walking up
111    /// to the filesystem root. Returns the default config if none found.
112    pub fn find_and_load(start_dir: &Path) -> Result<Self> {
113        let mut dir = start_dir.to_path_buf();
114        loop {
115            let candidate = dir.join(".snapperrc.toml");
116            if candidate.is_file() {
117                return Self::load(&candidate);
118            }
119            if !dir.pop() {
120                break;
121            }
122        }
123        Ok(Self::default())
124    }
125
126    /// Load config from a specific path.
127    pub fn load(path: &Path) -> Result<Self> {
128        let contents = std::fs::read_to_string(path)?;
129        Self::parse(&contents)
130    }
131
132    fn parse(toml_str: &str) -> Result<Self> {
133        let config: ProjectConfig = toml::from_str(toml_str)?;
134        Ok(config)
135    }
136
137    /// Get the config file path if explicitly provided, otherwise search.
138    pub fn resolve(explicit_path: Option<&Path>) -> Result<Self> {
139        if let Some(path) = explicit_path {
140            Self::load(path)
141        } else {
142            let cwd = std::env::current_dir()?;
143            Self::find_and_load(&cwd)
144        }
145    }
146
147    /// Get merged extra_abbreviations for a specific format, combining
148    /// top-level abbreviations with per-format overrides.
149    pub fn abbreviations_for_format(&self, format: &str) -> Vec<String> {
150        let mut abbrevs = self.extra_abbreviations.clone();
151        let overrides = match format {
152            "org" => self.org.as_ref(),
153            "latex" => self.latex.as_ref(),
154            "markdown" => self.markdown.as_ref(),
155            "rst" => self.rst.as_ref(),
156            "plaintext" => self.plaintext.as_ref(),
157            _ => None,
158        };
159        if let Some(ov) = overrides {
160            abbrevs.extend(ov.extra_abbreviations.iter().cloned());
161        }
162        abbrevs
163    }
164
165    /// Get max_width for a specific format (per-format overrides top-level).
166    pub fn max_width_for_format(&self, format: &str) -> Option<usize> {
167        let overrides = match format {
168            "org" => self.org.as_ref(),
169            "latex" => self.latex.as_ref(),
170            "markdown" => self.markdown.as_ref(),
171            "rst" => self.rst.as_ref(),
172            "plaintext" => self.plaintext.as_ref(),
173            _ => None,
174        };
175        overrides.and_then(|ov| ov.max_width).or(self.max_width)
176    }
177
178    /// Extra `[latex].verbatim_envs` names. Empty when the key is missing.
179    pub fn latex_verbatim_envs(&self) -> Vec<String> {
180        self.latex
181            .as_ref()
182            .map(|ov| ov.verbatim_envs.clone())
183            .unwrap_or_default()
184    }
185
186    /// Extra `[latex].structure_envs` names. Empty when the key is missing.
187    pub fn latex_structure_envs(&self) -> Vec<String> {
188        self.latex
189            .as_ref()
190            .map(|ov| ov.structure_envs.clone())
191            .unwrap_or_default()
192    }
193
194    /// Extra `[latex].verbatim_commands` names. Empty when the key is missing.
195    pub fn latex_verbatim_commands(&self) -> Vec<String> {
196        self.latex
197            .as_ref()
198            .map(|ov| ov.verbatim_commands.clone())
199            .unwrap_or_default()
200    }
201
202    #[cfg(any(feature = "cli", feature = "watch"))]
203    pub fn is_ignored(&self, path: &Path) -> bool {
204        self.ignore_patterns.iter().any(|pattern| {
205            Pattern::new(pattern).ok().is_some_and(|compiled| {
206                compiled.matches_path(path)
207                    || std::env::current_dir()
208                        .ok()
209                        .and_then(|cwd| path.strip_prefix(&cwd).ok())
210                        .is_some_and(|relative| compiled.matches_path(relative))
211                    || path
212                        .file_name()
213                        .is_some_and(|name| compiled.matches_path(Path::new(name)))
214            })
215        })
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn parse_empty_config() {
225        let config = ProjectConfig::parse("").unwrap();
226        assert!(config.extra_abbreviations.is_empty());
227        assert!(config.ignore_patterns.is_empty());
228        assert!(config.default_format.is_none());
229        assert!(config.max_width.is_none());
230    }
231
232    #[test]
233    fn parse_full_config() {
234        let toml = r#"
235# Project-specific snapper config
236extra_abbreviations = ["Dept", "Univ", "Corp"]
237ignore = ["*.bib", "*.cls"]
238format = "org"
239max_width = 80
240clause_breaks = true
241lang = "de"
242"#;
243        let config = ProjectConfig::parse(toml).unwrap();
244        assert_eq!(config.extra_abbreviations, vec!["Dept", "Univ", "Corp"]);
245        assert_eq!(config.ignore_patterns, vec!["*.bib", "*.cls"]);
246        assert_eq!(config.default_format, Some("org".to_string()));
247        assert_eq!(config.max_width, Some(80));
248        assert_eq!(config.long_threshold, None);
249        assert_eq!(config.clause_breaks, Some(true));
250        assert_eq!(config.lang, Some("de".to_string()));
251    }
252
253    #[test]
254    fn parse_long_threshold() {
255        let config = ProjectConfig::parse("long_threshold = 100\n").unwrap();
256        assert_eq!(config.long_threshold, Some(100));
257    }
258
259    #[test]
260    fn parse_comments_and_blanks() {
261        let toml = "# comment\n\nextra_abbreviations = [\"Fig\"]\n";
262        let config = ProjectConfig::parse(toml).unwrap();
263        assert_eq!(config.extra_abbreviations, vec!["Fig"]);
264    }
265
266    #[test]
267    fn parse_per_format_overrides() {
268        let toml = r#"
269extra_abbreviations = ["Global"]
270max_width = 80
271
272[org]
273extra_abbreviations = ["PROPERTIES", "DEADLINE"]
274
275[latex]
276extra_abbreviations = ["Thm", "Lem"]
277max_width = 100
278"#;
279        let config = ProjectConfig::parse(toml).unwrap();
280        let org_abbrevs = config.abbreviations_for_format("org");
281        assert!(org_abbrevs.contains(&"Global".to_string()));
282        assert!(org_abbrevs.contains(&"PROPERTIES".to_string()));
283        assert_eq!(config.max_width_for_format("org"), Some(80));
284        assert_eq!(config.max_width_for_format("latex"), Some(100));
285        assert_eq!(config.max_width_for_format("plaintext"), Some(80));
286    }
287
288    #[test]
289    fn parse_code_table_seven_seed_languages() {
290        // The shape `snapper init` writes: seven languages with mixed
291        // line_comment / block_comment / formatter fields. The python
292        // triple-quoted markers need an extra `#` on the raw delimiter
293        // so the inner escaped quotes lex.
294        let toml = r##"
295[code.rust]
296line_comment = "//"
297block_comment = ["/*", "*/"]
298formatter = ["rustfmt", "--edition", "2024"]
299
300[code.python]
301line_comment = "#"
302block_comment = ["\"\"\"", "\"\"\""]
303formatter = ["ruff", "format", "-"]
304
305[code.toml]
306line_comment = "#"
307formatter = ["taplo", "format", "-"]
308
309[code.lua]
310line_comment = "--"
311block_comment = ["--[[", "]]"]
312
313[code.lisp]
314line_comment = ";"
315
316[code.html]
317block_comment = ["<!--", "-->"]
318
319[code.javascript]
320line_comment = "//"
321block_comment = ["/*", "*/"]
322formatter = ["prettier", "--stdin-filepath", "src.js"]
323"##;
324        let config = ProjectConfig::parse(toml).unwrap();
325        assert_eq!(config.code.len(), 7);
326        let rust = config.code.get("rust").expect("rust entry present");
327        assert_eq!(rust.line_comment.as_deref(), Some("//"));
328        assert_eq!(
329            rust.block_comment.as_ref(),
330            Some(&["/*".to_string(), "*/".to_string()])
331        );
332        assert_eq!(
333            rust.formatter.as_deref(),
334            Some(
335                &[
336                    "rustfmt".to_string(),
337                    "--edition".to_string(),
338                    "2024".to_string()
339                ][..]
340            )
341        );
342        // lisp has only line_comment; missing fields stay None (no panic).
343        let lisp = config.code.get("lisp").expect("lisp entry present");
344        assert_eq!(lisp.line_comment.as_deref(), Some(";"));
345        assert!(lisp.block_comment.is_none());
346        assert!(lisp.formatter.is_none());
347        // html has only block_comment.
348        let html = config.code.get("html").expect("html entry present");
349        assert!(html.line_comment.is_none());
350        assert_eq!(
351            html.block_comment.as_ref(),
352            Some(&["<!--".to_string(), "-->".to_string()])
353        );
354        assert!(html.formatter.is_none());
355    }
356
357    #[test]
358    fn parse_rst_overrides() {
359        let toml = r#"
360[rst]
361extra_abbreviations = ["Fig"]
362max_width = 72
363"#;
364        let config = ProjectConfig::parse(toml).unwrap();
365        assert_eq!(config.max_width_for_format("rst"), Some(72));
366        assert_eq!(config.abbreviations_for_format("rst"), vec!["Fig"]);
367    }
368
369    #[test]
370    fn parse_latex_env_and_command_lists() {
371        let toml = r#"
372[latex]
373extra_abbreviations = ["Thm"]
374verbatim_envs = ["Verbatim"]
375structure_envs = ["algorithm", "comment"]
376verbatim_commands = ["Verb"]
377"#;
378        let config = ProjectConfig::parse(toml).unwrap();
379        assert_eq!(config.latex_verbatim_envs(), vec!["Verbatim"]);
380        assert_eq!(config.latex_structure_envs(), vec!["algorithm", "comment"]);
381        assert_eq!(config.latex_verbatim_commands(), vec!["Verb"]);
382        assert_eq!(config.abbreviations_for_format("latex"), vec!["Thm"]);
383    }
384
385    #[test]
386    fn missing_latex_env_keys_are_empty_extras() {
387        let config = ProjectConfig::parse("[latex]\nextra_abbreviations = [\"Thm\"]\n").unwrap();
388        assert!(config.latex_verbatim_envs().is_empty());
389        assert!(config.latex_structure_envs().is_empty());
390        assert!(config.latex_verbatim_commands().is_empty());
391    }
392
393    #[test]
394    fn latex_other_regex_key_is_ignored() {
395        // latexindent's `other:` regex matching is not supported.
396        let config = ProjectConfig::parse("[latex]\nother = \".*code\"\n").unwrap();
397        assert!(config.latex_verbatim_envs().is_empty());
398        assert!(config.latex_structure_envs().is_empty());
399        assert!(config.latex_verbatim_commands().is_empty());
400    }
401}