1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5
6fn detect_formats(dir: &Path) -> Vec<&'static str> {
8 let mut formats = Vec::new();
9 let check = |ext: &str| -> bool {
10 walkdir(dir)
11 .into_iter()
12 .any(|e| e.path().extension().and_then(|e| e.to_str()) == Some(ext))
13 };
14 fn walkdir(dir: &Path) -> Vec<fs::DirEntry> {
16 let mut entries = Vec::new();
17 if let Ok(rd) = fs::read_dir(dir) {
18 for entry in rd.flatten() {
19 let path = entry.path();
20 if path.is_dir()
21 && !path
22 .file_name()
23 .is_some_and(|n| n.to_string_lossy().starts_with('.'))
24 {
25 entries.extend(walkdir(&path));
26 } else {
27 entries.push(entry);
28 }
29 }
30 }
31 entries
32 }
33
34 if check("org") {
35 formats.push("org");
36 }
37 if check("tex") || check("latex") {
38 formats.push("latex");
39 }
40 if check("md") || check("markdown") {
41 formats.push("markdown");
42 }
43 formats
44}
45
46fn generate_config(formats: &[&str]) -> String {
48 let default_format = formats.first().copied().unwrap_or("plaintext");
49 format!(
50 r##"# snapper project configuration
51# https://snapper.turtletech.us/docs/reference/config/
52
53# Extra abbreviations (merged with built-in list)
54# extra_abbreviations = ["GROMACS", "LAMMPS", "DFT"]
55
56# File patterns to ignore
57# ignore = ["*.bib", "*.cls", "*.sty"]
58
59# Default format (auto-detected from extension if omitted)
60format = "{default_format}"
61
62# Maximum line width (0 = unlimited)
63max_width = 0
64
65# Prefer breaks after independent-clause punctuation (, ; : em dash).
66# With max_width = 0 this still inserts a newline after each such mark
67# that is already followed by whitespace.
68# clause_breaks = false
69
70# Extra LaTeX environments / commands (added to the built-in lists).
71# [latex]
72# verbatim_envs = ["Verbatim"]
73# structure_envs = ["algorithm", "comment"]
74# verbatim_commands = ["Verb"]
75
76# Advisory long-line threshold when max_width is 0 (default 120)
77# long_threshold = 120
78
79# Per-language code-block reflow and formatter delegation.
80# Each language entry may set any combination of:
81# line_comment -- marker for single-line comments
82# block_comment -- ["open", "close"] markers for multi-line comments
83# formatter -- argv passed to std::process::Command for --format-code
84# Missing fields are no-ops for that language.
85
86[code.rust]
87line_comment = "//"
88block_comment = ["/*", "*/"]
89formatter = ["rustfmt", "--edition", "2024"]
90
91[code.python]
92line_comment = "#"
93block_comment = ["\"\"\"", "\"\"\""]
94formatter = ["ruff", "format", "-"]
95
96[code.toml]
97line_comment = "#"
98formatter = ["taplo", "format", "-"]
99
100[code.lua]
101line_comment = "--"
102block_comment = ["--[[", "]]"]
103
104[code.lisp]
105line_comment = ";"
106
107[code.html]
108block_comment = ["<!--", "-->"]
109
110[code.javascript]
111line_comment = "//"
112block_comment = ["/*", "*/"]
113formatter = ["prettier", "--stdin-filepath", "src.js"]
114"##
115 )
116}
117
118fn generate_gitattributes(formats: &[&str]) -> String {
120 let mut lines = String::from("# snapper semantic line break filter\n");
121 for fmt in formats {
122 let ext = match *fmt {
123 "org" => "*.org",
124 "latex" => "*.tex",
125 "markdown" => "*.md",
126 _ => continue,
127 };
128 lines.push_str(&format!("{ext} filter=snapper\n"));
129 }
130 lines
131}
132
133fn generate_precommit() -> String {
135 format!(
136 r#"# Add to .pre-commit-config.yaml:
137- repo: https://github.com/TurtleTech-ehf/snapper
138 rev: v{}
139 hooks:
140 - id: snapper
141"#,
142 env!("CARGO_PKG_VERSION")
143 )
144}
145
146fn generate_apheleia(formats: &[&str]) -> String {
148 let mut s = String::from(";; Add to your Emacs config:\n(with-eval-after-load 'apheleia\n");
149 s.push_str(" (push '(snapper . (\"snapper\")) apheleia-formatters)\n");
150 for fmt in formats {
151 let mode = match *fmt {
152 "org" => "org-mode",
153 "latex" => "latex-mode",
154 "markdown" => "markdown-mode",
155 _ => continue,
156 };
157 s.push_str(&format!(
158 " (push '({mode} . snapper) apheleia-mode-alist)\n"
159 ));
160 }
161 s.push_str(")\n");
162 s
163}
164
165pub fn run_init(dry_run: bool) -> Result<()> {
167 let cwd = std::env::current_dir()?;
168 let formats = detect_formats(&cwd);
169
170 eprintln!(
171 "Detected formats: {}",
172 if formats.is_empty() {
173 "none (will use plaintext defaults)".to_string()
174 } else {
175 formats.join(", ")
176 }
177 );
178
179 let config_content = generate_config(&formats);
181 let config_path = cwd.join(".snapperrc.toml");
182 if config_path.exists() {
183 eprintln!(" .snapperrc.toml already exists, skipping");
184 } else if dry_run {
185 eprintln!("\n--- .snapperrc.toml ---");
186 eprint!("{config_content}");
187 } else {
188 fs::write(&config_path, &config_content).context("failed to write .snapperrc.toml")?;
189 eprintln!(" Created .snapperrc.toml");
190 }
191
192 if !formats.is_empty() {
194 let ga_content = generate_gitattributes(&formats);
195 let ga_path = cwd.join(".gitattributes");
196 if dry_run {
197 eprintln!("\n--- .gitattributes (append) ---");
198 eprint!("{ga_content}");
199 } else if ga_path.exists() {
200 let existing = fs::read_to_string(&ga_path)?;
201 if !existing.contains("filter=snapper") {
202 fs::write(&ga_path, format!("{existing}\n{ga_content}"))
203 .context("failed to append .gitattributes")?;
204 eprintln!(" Appended to .gitattributes");
205 } else {
206 eprintln!(" .gitattributes already has snapper filter, skipping");
207 }
208 } else {
209 fs::write(&ga_path, &ga_content).context("failed to write .gitattributes")?;
210 eprintln!(" Created .gitattributes");
211 }
212 }
213
214 eprintln!("\n{}", generate_precommit());
216 eprintln!("{}", generate_apheleia(&formats));
217
218 eprintln!("To enable the git smudge/clean filter, run:");
220 eprintln!(" git config filter.snapper.clean \"snapper\"");
221 eprintln!(" git config filter.snapper.smudge cat");
222
223 Ok(())
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn generate_config_with_org() {
232 let config = generate_config(&["org"]);
233 assert!(config.contains("format = \"org\""));
234 assert!(config.contains("max_width = 0"));
235 assert!(config.contains("# clause_breaks = false"));
236 }
237
238 #[test]
239 fn generate_config_empty_defaults_to_plaintext() {
240 let config = generate_config(&[]);
241 assert!(config.contains("format = \"plaintext\""));
242 }
243
244 #[test]
245 fn generate_config_includes_seven_code_languages() {
246 let config = generate_config(&["markdown"]);
247 for lang in [
249 "rust",
250 "python",
251 "toml",
252 "lua",
253 "lisp",
254 "html",
255 "javascript",
256 ] {
257 assert!(
258 config.contains(&format!("[code.{lang}]")),
259 "missing [code.{lang}] entry in init template",
260 );
261 }
262 assert!(config.contains(r#"line_comment = "//""#));
264 assert!(config.contains(r#"formatter = ["rustfmt", "--edition", "2024"]"#));
265 }
266
267 #[test]
268 fn generate_gitattributes_multiple_formats() {
269 let ga = generate_gitattributes(&["org", "latex", "markdown"]);
270 assert!(ga.contains("*.org filter=snapper"));
271 assert!(ga.contains("*.tex filter=snapper"));
272 assert!(ga.contains("*.md filter=snapper"));
273 }
274
275 #[test]
276 fn generate_gitattributes_empty() {
277 let ga = generate_gitattributes(&[]);
278 assert!(ga.contains("# snapper"));
279 assert!(!ga.contains("filter=snapper"));
280 }
281}