Skip to main content

snapper_fmt/
init.rs

1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5
6/// Detect which prose formats exist in the current directory tree.
7fn 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    // Simple recursive check using std::fs
15    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
46/// Generate .snapperrc.toml content.
47fn 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# Per-language code-block reflow and formatter delegation.
66# Each language entry may set any combination of:
67#   line_comment   -- marker for single-line comments
68#   block_comment  -- ["open", "close"] markers for multi-line comments
69#   formatter      -- argv passed to std::process::Command for --format-code
70# Missing fields are no-ops for that language.
71
72[code.rust]
73line_comment = "//"
74block_comment = ["/*", "*/"]
75formatter = ["rustfmt", "--edition", "2024"]
76
77[code.python]
78line_comment = "#"
79block_comment = ["\"\"\"", "\"\"\""]
80formatter = ["ruff", "format", "-"]
81
82[code.toml]
83line_comment = "#"
84formatter = ["taplo", "format", "-"]
85
86[code.lua]
87line_comment = "--"
88block_comment = ["--[[", "]]"]
89
90[code.lisp]
91line_comment = ";"
92
93[code.html]
94block_comment = ["<!--", "-->"]
95
96[code.javascript]
97line_comment = "//"
98block_comment = ["/*", "*/"]
99formatter = ["prettier", "--stdin-filepath", "src.js"]
100"##
101    )
102}
103
104/// Generate .gitattributes entries.
105fn generate_gitattributes(formats: &[&str]) -> String {
106    let mut lines = String::from("# snapper semantic line break filter\n");
107    for fmt in formats {
108        let ext = match *fmt {
109            "org" => "*.org",
110            "latex" => "*.tex",
111            "markdown" => "*.md",
112            _ => continue,
113        };
114        lines.push_str(&format!("{ext} filter=snapper\n"));
115    }
116    lines
117}
118
119/// Generate pre-commit config snippet.
120fn generate_precommit() -> String {
121    format!(
122        r#"# Add to .pre-commit-config.yaml:
123- repo: https://github.com/TurtleTech-ehf/snapper
124  rev: v{}
125  hooks:
126    - id: snapper
127"#,
128        env!("CARGO_PKG_VERSION")
129    )
130}
131
132/// Generate Apheleia elisp snippet.
133fn generate_apheleia(formats: &[&str]) -> String {
134    let mut s = String::from(";; Add to your Emacs config:\n(with-eval-after-load 'apheleia\n");
135    s.push_str("  (push '(snapper . (\"snapper\")) apheleia-formatters)\n");
136    for fmt in formats {
137        let mode = match *fmt {
138            "org" => "org-mode",
139            "latex" => "latex-mode",
140            "markdown" => "markdown-mode",
141            _ => continue,
142        };
143        s.push_str(&format!(
144            "  (push '({mode} . snapper) apheleia-mode-alist)\n"
145        ));
146    }
147    s.push_str(")\n");
148    s
149}
150
151/// Run the init command.
152pub fn run_init(dry_run: bool) -> Result<()> {
153    let cwd = std::env::current_dir()?;
154    let formats = detect_formats(&cwd);
155
156    eprintln!(
157        "Detected formats: {}",
158        if formats.is_empty() {
159            "none (will use plaintext defaults)".to_string()
160        } else {
161            formats.join(", ")
162        }
163    );
164
165    // .snapperrc.toml
166    let config_content = generate_config(&formats);
167    let config_path = cwd.join(".snapperrc.toml");
168    if config_path.exists() {
169        eprintln!("  .snapperrc.toml already exists, skipping");
170    } else if dry_run {
171        eprintln!("\n--- .snapperrc.toml ---");
172        eprint!("{config_content}");
173    } else {
174        fs::write(&config_path, &config_content).context("failed to write .snapperrc.toml")?;
175        eprintln!("  Created .snapperrc.toml");
176    }
177
178    // .gitattributes
179    if !formats.is_empty() {
180        let ga_content = generate_gitattributes(&formats);
181        let ga_path = cwd.join(".gitattributes");
182        if dry_run {
183            eprintln!("\n--- .gitattributes (append) ---");
184            eprint!("{ga_content}");
185        } else if ga_path.exists() {
186            let existing = fs::read_to_string(&ga_path)?;
187            if !existing.contains("filter=snapper") {
188                fs::write(&ga_path, format!("{existing}\n{ga_content}"))
189                    .context("failed to append .gitattributes")?;
190                eprintln!("  Appended to .gitattributes");
191            } else {
192                eprintln!("  .gitattributes already has snapper filter, skipping");
193            }
194        } else {
195            fs::write(&ga_path, &ga_content).context("failed to write .gitattributes")?;
196            eprintln!("  Created .gitattributes");
197        }
198    }
199
200    // Print pre-commit and Apheleia snippets
201    eprintln!("\n{}", generate_precommit());
202    eprintln!("{}", generate_apheleia(&formats));
203
204    // Git filter setup reminder
205    eprintln!("To enable the git smudge/clean filter, run:");
206    eprintln!("  git config filter.snapper.clean \"snapper\"");
207    eprintln!("  git config filter.snapper.smudge cat");
208
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn generate_config_with_org() {
218        let config = generate_config(&["org"]);
219        assert!(config.contains("format = \"org\""));
220        assert!(config.contains("max_width = 0"));
221    }
222
223    #[test]
224    fn generate_config_empty_defaults_to_plaintext() {
225        let config = generate_config(&[]);
226        assert!(config.contains("format = \"plaintext\""));
227    }
228
229    #[test]
230    fn generate_config_includes_seven_code_languages() {
231        let config = generate_config(&["markdown"]);
232        // The seven seed languages required by the [code] table.
233        for lang in ["rust", "python", "toml", "lua", "lisp", "html", "javascript"] {
234            assert!(
235                config.contains(&format!("[code.{lang}]")),
236                "missing [code.{lang}] entry in init template",
237            );
238        }
239        // Verify shape of one entry with all three fields.
240        assert!(config.contains(r#"line_comment = "//""#));
241        assert!(config.contains(r#"formatter = ["rustfmt", "--edition", "2024"]"#));
242    }
243
244    #[test]
245    fn generate_gitattributes_multiple_formats() {
246        let ga = generate_gitattributes(&["org", "latex", "markdown"]);
247        assert!(ga.contains("*.org filter=snapper"));
248        assert!(ga.contains("*.tex filter=snapper"));
249        assert!(ga.contains("*.md filter=snapper"));
250    }
251
252    #[test]
253    fn generate_gitattributes_empty() {
254        let ga = generate_gitattributes(&[]);
255        assert!(ga.contains("# snapper"));
256        assert!(!ga.contains("filter=snapper"));
257    }
258}