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#[derive(Debug, Default, Deserialize)]
11#[serde(default)]
12pub struct FormatOverrides {
13 pub extra_abbreviations: Vec<String>,
14 pub max_width: Option<usize>,
15 pub verbatim_envs: Vec<String>,
19 pub structure_envs: Vec<String>,
23 pub verbatim_commands: Vec<String>,
26}
27
28#[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 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 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#[derive(Debug, Default, Deserialize)]
73#[serde(default)]
74pub struct ProjectConfig {
75 pub extra_abbreviations: Vec<String>,
77 #[serde(alias = "ignore")]
79 pub ignore_patterns: Vec<String>,
80 #[serde(alias = "format")]
82 pub default_format: Option<String>,
83 pub max_width: Option<usize>,
85 pub long_threshold: Option<usize>,
88 pub clause_breaks: Option<bool>,
92 pub lang: Option<String>,
94
95 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 pub code: HashMap<String, CodeLang>,
107}
108
109impl ProjectConfig {
110 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 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 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 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 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 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 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 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 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 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 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 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}