Skip to main content

lintel_format/
lib.rs

1#![doc = include_str!("../README.md")]
2#![allow(unused_assignments)] // thiserror/miette derive macros trigger false positives
3
4mod toml;
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use anyhow::{Context, Result};
10use bpaf::{Bpaf, ShellComp};
11use miette::Diagnostic;
12use thiserror::Error;
13
14// ---------------------------------------------------------------------------
15// Format detection
16// ---------------------------------------------------------------------------
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum FormatKind {
20    Json,
21    Jsonc,
22    Toml,
23    Yaml,
24    Markdown,
25}
26
27fn detect_format(path: &Path) -> Option<FormatKind> {
28    match path.extension().and_then(|e| e.to_str()) {
29        Some("json") => Some(FormatKind::Json),
30        Some("jsonc") => Some(FormatKind::Jsonc),
31        Some("yaml" | "yml") => Some(FormatKind::Yaml),
32        Some("toml") => Some(FormatKind::Toml),
33        Some("md" | "mdx") => Some(FormatKind::Markdown),
34        _ => None,
35    }
36}
37
38// ---------------------------------------------------------------------------
39// dprint configuration (constructed once, reused)
40// ---------------------------------------------------------------------------
41
42/// Pre-built native configs for all formatters.
43pub struct FormatConfig {
44    json: dprint_plugin_json::configuration::Configuration,
45    toml: dprint_plugin_toml::configuration::Configuration,
46    markdown: dprint_plugin_markdown::configuration::Configuration,
47    yaml: pretty_yaml::config::FormatOptions,
48}
49
50impl Default for FormatConfig {
51    fn default() -> Self {
52        Self {
53            json: dprint_plugin_json::configuration::ConfigurationBuilder::new().build(),
54            toml: dprint_plugin_toml::configuration::ConfigurationBuilder::new().build(),
55            markdown: dprint_plugin_markdown::configuration::ConfigurationBuilder::new().build(),
56            yaml: pretty_yaml::config::FormatOptions::default(),
57        }
58    }
59}
60
61impl FormatConfig {
62    /// Build formatter configs from a `DprintConfig`.
63    fn from_dprint(dprint: &dprint_config::DprintConfig) -> Self {
64        let global = build_global_config(dprint);
65
66        let json = {
67            let map = dprint
68                .json
69                .as_ref()
70                .and_then(|j| serde_json::to_value(j).ok())
71                .map(|v| json_value_to_config_key_map(&v))
72                .unwrap_or_default();
73            dprint_plugin_json::configuration::resolve_config(map, &global).config
74        };
75
76        let toml = {
77            let map = dprint
78                .toml
79                .as_ref()
80                .and_then(|t| serde_json::to_value(t).ok())
81                .map(|v| json_value_to_config_key_map(&v))
82                .unwrap_or_default();
83            dprint_plugin_toml::configuration::resolve_config(map, &global).config
84        };
85
86        let markdown = {
87            let map = dprint
88                .markdown
89                .as_ref()
90                .and_then(|m| serde_json::to_value(m).ok())
91                .map(|v| json_value_to_config_key_map(&v))
92                .unwrap_or_default();
93            dprint_plugin_markdown::configuration::resolve_config(map, &global).config
94        };
95
96        let yaml = {
97            let mut opts = pretty_yaml::config::FormatOptions::default();
98            if let Some(w) = dprint.line_width {
99                opts.layout.print_width = w as usize;
100            }
101            if let Some(w) = dprint.indent_width {
102                opts.layout.indent_width = w as usize;
103            }
104            opts
105        };
106
107        Self {
108            json,
109            toml,
110            markdown,
111            yaml,
112        }
113    }
114}
115
116/// Build a `GlobalConfiguration` from `DprintConfig` global fields.
117fn build_global_config(
118    dprint: &dprint_config::DprintConfig,
119) -> dprint_core::configuration::GlobalConfiguration {
120    use dprint_core::configuration::GlobalConfiguration;
121
122    GlobalConfiguration {
123        line_width: dprint.line_width,
124        use_tabs: dprint.use_tabs,
125        indent_width: dprint.indent_width.and_then(|v| u8::try_from(v).ok()),
126        new_line_kind: dprint.new_line_kind.map(|nk| match nk {
127            dprint_config::NewLineKind::Crlf => {
128                dprint_core::configuration::NewLineKind::CarriageReturnLineFeed
129            }
130            dprint_config::NewLineKind::Lf => dprint_core::configuration::NewLineKind::LineFeed,
131            // dprint-core doesn't have a System variant; map to Auto
132            dprint_config::NewLineKind::Auto | dprint_config::NewLineKind::System => {
133                dprint_core::configuration::NewLineKind::Auto
134            }
135        }),
136    }
137}
138
139/// Convert a `serde_json::Value` object into dprint's `ConfigKeyMap`.
140///
141/// Only top-level string, number (i32), and bool values are converted;
142/// nested objects and arrays are skipped since dprint plugins don't use them
143/// for their config keys (except `jsonTrailingCommaFiles` which we handle
144/// specially).
145fn json_value_to_config_key_map(
146    value: &serde_json::Value,
147) -> dprint_core::configuration::ConfigKeyMap {
148    use dprint_core::configuration::{ConfigKeyMap, ConfigKeyValue};
149
150    let Some(obj) = value.as_object() else {
151        return ConfigKeyMap::new();
152    };
153
154    let mut map = ConfigKeyMap::new();
155    for (key, val) in obj {
156        let ckv = match val {
157            serde_json::Value::String(s) => ConfigKeyValue::from_str(s),
158            serde_json::Value::Number(n) => {
159                if let Some(i) = n.as_i64() {
160                    ConfigKeyValue::from_i32(i32::try_from(i).unwrap_or(i32::MAX))
161                } else {
162                    continue;
163                }
164            }
165            serde_json::Value::Bool(b) => ConfigKeyValue::from_bool(*b),
166            serde_json::Value::Array(arr) => {
167                let items: Vec<ConfigKeyValue> = arr
168                    .iter()
169                    .filter_map(|v| match v {
170                        serde_json::Value::String(s) => Some(ConfigKeyValue::from_str(s)),
171                        _ => None,
172                    })
173                    .collect();
174                ConfigKeyValue::Array(items)
175            }
176            _ => continue,
177        };
178        map.insert(key.clone(), ckv);
179    }
180    map
181}
182
183// ---------------------------------------------------------------------------
184// Core formatting
185// ---------------------------------------------------------------------------
186
187/// Format a single file's content. Returns `Ok(Some(formatted))` if the content
188/// changed, `Ok(None)` if already formatted, or `Err` on parse failure.
189///
190/// # Errors
191///
192/// Returns an error if the file content cannot be parsed.
193pub fn format_content(path: &Path, content: &str, cfg: &FormatConfig) -> Result<Option<String>> {
194    let Some(kind) = detect_format(path) else {
195        return Ok(None);
196    };
197
198    match kind {
199        FormatKind::Json | FormatKind::Jsonc => {
200            dprint_plugin_json::format_text(path, content, &cfg.json)
201                .map_err(|e| anyhow::anyhow!("{e}"))
202        }
203        FormatKind::Toml => toml::format_text(path, content, &cfg.toml),
204        FormatKind::Yaml => match pretty_yaml::format_text(content, &cfg.yaml) {
205            Ok(formatted) => {
206                if formatted == content {
207                    Ok(None)
208                } else {
209                    Ok(Some(formatted))
210                }
211            }
212            Err(e) => Err(anyhow::anyhow!("YAML syntax error: {e}")),
213        },
214        FormatKind::Markdown => {
215            dprint_plugin_markdown::format_text(content, &cfg.markdown, |tag, text, _line_width| {
216                match tag {
217                    "json" => {
218                        dprint_plugin_json::format_text(Path::new("code.json"), text, &cfg.json)
219                    }
220                    "jsonc" => {
221                        dprint_plugin_json::format_text(Path::new("code.jsonc"), text, &cfg.json)
222                    }
223                    "toml" => {
224                        dprint_plugin_toml::format_text(Path::new("code.toml"), text, &cfg.toml)
225                    }
226                    "yaml" | "yml" => match pretty_yaml::format_text(text, &cfg.yaml) {
227                        Ok(formatted) if formatted == text => Ok(None),
228                        Ok(formatted) => Ok(Some(formatted)),
229                        Err(_) => Ok(None),
230                    },
231                    _ => Ok(None),
232                }
233            })
234            .map_err(|e| anyhow::anyhow!("{e}"))
235        }
236    }
237}
238
239// ---------------------------------------------------------------------------
240// Diagnostics
241// ---------------------------------------------------------------------------
242
243/// A formatting diagnostic: a file that is not properly formatted.
244#[derive(Debug, Error, Diagnostic)]
245#[error("Formatter would have printed the following content:\n\n{path}\n\n{diff}")]
246#[diagnostic(
247    code(lintel::format),
248    help("run `lintel check --fix` or `lintel format` to fix formatting")
249)]
250pub struct FormatDiagnostic {
251    /// Raw file path (no ANSI styling).
252    file_path: String,
253    path: String,
254    diff: String,
255}
256
257impl FormatDiagnostic {
258    /// The raw file path (without ANSI styling).
259    pub fn file_path(&self) -> &str {
260        &self.file_path
261    }
262}
263
264fn plural(n: usize) -> &'static str {
265    if n == 1 { "line" } else { "lines" }
266}
267
268fn diff_summary(added: usize, removed: usize, color: bool) -> String {
269    use ansi_term_styles::{BOLD, DIM, RESET};
270
271    if added == 0 && removed == 0 {
272        return String::new();
273    }
274
275    let n = |count: usize| {
276        if color {
277            format!("{BOLD}{count}{RESET}{DIM}")
278        } else {
279            count.to_string()
280        }
281    };
282
283    let text = if added == removed {
284        format!("Changed {} {}", n(added), plural(added))
285    } else if added > 0 && removed > 0 {
286        format!(
287            "Added {} {}, removed {} {}",
288            n(added),
289            plural(added),
290            n(removed),
291            plural(removed)
292        )
293    } else if added > 0 {
294        format!("Added {} {}", n(added), plural(added))
295    } else {
296        format!("Removed {} {}", n(removed), plural(removed))
297    };
298
299    if color {
300        format!("{DIM}{text}{RESET}")
301    } else {
302        text
303    }
304}
305
306/// Generate a diff between original and formatted content with line numbers.
307///
308/// When `color` is true, applies delta-inspired ANSI coloring with
309/// dark backgrounds for changed lines. Includes a summary header
310/// ("Added N lines, removed M lines") and per-line numbers.
311fn generate_diff(original: &str, formatted: &str, color: bool) -> String {
312    use core::fmt::Write;
313
314    use similar::ChangeTag;
315
316    const DEL: &str = "\x1b[31m"; // red foreground
317    const ADD: &str = "\x1b[32m"; // green foreground
318    const DIM: &str = ansi_term_styles::DIM;
319    const RESET: &str = ansi_term_styles::RESET;
320
321    let diff = similar::TextDiff::from_lines(original, formatted);
322
323    // Count additions/deletions across all changes
324    let mut added = 0usize;
325    let mut removed = 0usize;
326    for change in diff.iter_all_changes() {
327        match change.tag() {
328            ChangeTag::Insert => added += 1,
329            ChangeTag::Delete => removed += 1,
330            ChangeTag::Equal => {}
331        }
332    }
333
334    // Max line number for column width
335    let max_line = original.lines().count().max(formatted.lines().count());
336    let width = max_line.to_string().len();
337
338    let mut out = String::with_capacity(original.len() + formatted.len());
339
340    // Summary header
341    let _ = writeln!(out, "{}", diff_summary(added, removed, color));
342
343    // Use grouped ops (3 lines of context) to show only relevant hunks
344    let mut first_group = true;
345    for group in diff.grouped_ops(3) {
346        if !first_group {
347            if color {
348                let _ = writeln!(out, "{DIM}  ...{RESET}");
349            } else {
350                let _ = writeln!(out, "  ...");
351            }
352        }
353        first_group = false;
354
355        for op in &group {
356            for change in diff.iter_changes(op) {
357                let value = change.value().trim_end_matches('\n');
358                match change.tag() {
359                    ChangeTag::Delete => {
360                        let lineno = change.old_index().map_or(0, |n| n + 1);
361                        if color {
362                            let _ = writeln!(out, "{DEL}{lineno:>width$} - {value}{RESET}");
363                        } else {
364                            let _ = writeln!(out, "{lineno:>width$} - {value}");
365                        }
366                    }
367                    ChangeTag::Insert => {
368                        let lineno = change.new_index().map_or(0, |n| n + 1);
369                        if color {
370                            let _ = writeln!(out, "{ADD}{lineno:>width$} + {value}{RESET}");
371                        } else {
372                            let _ = writeln!(out, "{lineno:>width$} + {value}");
373                        }
374                    }
375                    ChangeTag::Equal => {
376                        let lineno = change.old_index().map_or(0, |n| n + 1);
377                        let _ = writeln!(out, "{lineno:>width$}   {value}");
378                    }
379                }
380            }
381        }
382    }
383    out
384}
385
386fn make_diagnostic(path_str: String, content: &str, formatted: &str) -> FormatDiagnostic {
387    let color = std::io::IsTerminal::is_terminal(&std::io::stderr());
388    let styled_path = if color {
389        format!("\x1b[1;4;36m{path_str}\x1b[0m")
390    } else {
391        path_str.clone()
392    };
393    FormatDiagnostic {
394        file_path: path_str,
395        diff: generate_diff(content, formatted, color),
396        path: styled_path,
397    }
398}
399
400// ---------------------------------------------------------------------------
401// File discovery (lightweight, independent of lintel-validate)
402// ---------------------------------------------------------------------------
403
404fn discover_files(root: &str, excludes: &[String]) -> Result<Vec<PathBuf>> {
405    let walker = ignore::WalkBuilder::new(root)
406        .hidden(false)
407        .git_ignore(true)
408        .git_global(true)
409        .git_exclude(true)
410        .build();
411
412    let mut files = Vec::new();
413    for entry in walker {
414        let entry = entry?;
415        let path = entry.path();
416        if !path.is_file() {
417            continue;
418        }
419        if detect_format(path).is_none() {
420            continue;
421        }
422        if is_excluded(path, excludes) {
423            continue;
424        }
425        files.push(path.to_path_buf());
426    }
427
428    files.sort();
429    Ok(files)
430}
431
432fn collect_files(globs: &[String], exclude: &[String]) -> Result<Vec<PathBuf>> {
433    if globs.is_empty() {
434        return discover_files(".", exclude);
435    }
436
437    let mut result = Vec::new();
438    for pattern in globs {
439        let path = Path::new(pattern);
440        if path.is_dir() {
441            result.extend(discover_files(pattern, exclude)?);
442        } else {
443            for entry in
444                glob::glob(pattern).with_context(|| format!("invalid glob pattern: {pattern}"))?
445            {
446                let path = entry?;
447                if path.is_file() && !is_excluded(&path, exclude) {
448                    result.push(path);
449                }
450            }
451        }
452    }
453    result.sort();
454    result.dedup();
455    Ok(result)
456}
457
458fn is_excluded(path: &Path, excludes: &[String]) -> bool {
459    let path_str = match path.to_str() {
460        Some(s) => s.strip_prefix("./").unwrap_or(s),
461        None => return false,
462    };
463    excludes
464        .iter()
465        .any(|pattern| glob_match::glob_match(pattern, path_str))
466}
467
468// ---------------------------------------------------------------------------
469// Config loading
470// ---------------------------------------------------------------------------
471
472struct LoadedConfig {
473    excludes: Vec<String>,
474    format: FormatConfig,
475}
476
477fn load_config(globs: &[String], user_excludes: &[String]) -> LoadedConfig {
478    let search_dir = globs
479        .iter()
480        .find(|g| Path::new(g).is_dir())
481        .map(PathBuf::from);
482
483    let cfg_result = match &search_dir {
484        Some(dir) => lintel_config::find_and_load(dir).map(Option::unwrap_or_default),
485        None => lintel_config::load(),
486    };
487
488    match cfg_result {
489        Ok(cfg) => {
490            let format = cfg
491                .format
492                .as_ref()
493                .and_then(|f| f.dprint.as_ref())
494                .map(FormatConfig::from_dprint)
495                .unwrap_or_default();
496
497            let mut excludes = cfg.exclude;
498            excludes.extend(user_excludes.iter().cloned());
499
500            LoadedConfig { excludes, format }
501        }
502        Err(e) => {
503            eprintln!("warning: failed to load lintel.toml: {e}");
504            LoadedConfig {
505                excludes: user_excludes.to_vec(),
506                format: FormatConfig::default(),
507            }
508        }
509    }
510}
511
512// ---------------------------------------------------------------------------
513// CLI args
514// ---------------------------------------------------------------------------
515
516#[derive(Debug, Clone, Bpaf)]
517#[bpaf(generate(format_args_inner))]
518pub struct FormatArgs {
519    /// Check formatting without writing changes
520    #[bpaf(long("check"), switch)]
521    pub check: bool,
522
523    #[bpaf(long("exclude"), argument("PATTERN"))]
524    pub exclude: Vec<String>,
525
526    #[bpaf(positional("PATH"), complete_shell(ShellComp::File { mask: None }))]
527    pub globs: Vec<String>,
528}
529
530/// Construct the bpaf parser for `FormatArgs`.
531pub fn format_args() -> impl bpaf::Parser<FormatArgs> {
532    format_args_inner()
533}
534
535// ---------------------------------------------------------------------------
536// Result types
537// ---------------------------------------------------------------------------
538
539pub struct FormatResult {
540    /// Files that were formatted (written in place).
541    pub formatted: Vec<String>,
542    /// Files that were already formatted.
543    pub unchanged: usize,
544    /// Files skipped (unsupported format).
545    pub skipped: usize,
546    /// Errors encountered during formatting.
547    pub errors: Vec<(String, String)>,
548}
549
550// ---------------------------------------------------------------------------
551// Public API
552// ---------------------------------------------------------------------------
553
554/// Check formatting of files, returning diagnostics for unformatted files.
555///
556/// Loads `lintel.toml` and merges exclude patterns. Files that fail to parse
557/// are silently skipped (they will be caught by schema validation).
558///
559/// # Errors
560///
561/// Returns an error if file discovery fails (e.g. invalid glob pattern or I/O error).
562pub fn check_format(globs: &[String], user_excludes: &[String]) -> Result<Vec<FormatDiagnostic>> {
563    let loaded = load_config(globs, user_excludes);
564    let files = collect_files(globs, &loaded.excludes)?;
565
566    let mut diagnostics = Vec::new();
567    for file_path in &files {
568        let Ok(content) = fs::read_to_string(file_path) else {
569            continue;
570        };
571
572        if let Ok(Some(formatted)) = format_content(file_path, &content, &loaded.format) {
573            let path_str = file_path.display().to_string();
574            diagnostics.push(make_diagnostic(path_str, &content, &formatted));
575        }
576    }
577
578    Ok(diagnostics)
579}
580
581/// Fix formatting of files in place.
582///
583/// Loads `lintel.toml` and merges exclude patterns. Returns the number of
584/// files that were reformatted.
585///
586/// # Errors
587///
588/// Returns an error if file discovery fails (e.g. invalid glob pattern or I/O error).
589pub fn fix_format(globs: &[String], user_excludes: &[String]) -> Result<usize> {
590    let loaded = load_config(globs, user_excludes);
591    let files = collect_files(globs, &loaded.excludes)?;
592
593    let mut fixed = 0;
594    for file_path in &files {
595        let Ok(content) = fs::read_to_string(file_path) else {
596            continue;
597        };
598
599        if let Ok(Some(formatted)) = format_content(file_path, &content, &loaded.format) {
600            fs::write(file_path, formatted)?;
601            fixed += 1;
602        }
603    }
604
605    Ok(fixed)
606}
607
608/// Run the format command: format files in place, or check with `--check`.
609///
610/// Returns `Ok(FormatResult)` on success. In `--check` mode, unformatted
611/// files are reported as errors (diffs printed to stderr by the caller).
612///
613/// # Errors
614///
615/// Returns an error if file discovery fails (e.g. invalid glob pattern or I/O error).
616pub fn run(args: &FormatArgs) -> Result<FormatResult> {
617    let loaded = load_config(&args.globs, &args.exclude);
618    let files = collect_files(&args.globs, &loaded.excludes)?;
619
620    let mut result = FormatResult {
621        formatted: Vec::new(),
622        unchanged: 0,
623        skipped: 0,
624        errors: Vec::new(),
625    };
626
627    for file_path in &files {
628        let path_str = file_path.display().to_string();
629
630        let content = match fs::read_to_string(file_path) {
631            Ok(c) => c,
632            Err(e) => {
633                result
634                    .errors
635                    .push((path_str, format!("failed to read: {e}")));
636                continue;
637            }
638        };
639
640        match format_content(file_path, &content, &loaded.format) {
641            Ok(Some(formatted)) => {
642                if args.check {
643                    let diag = make_diagnostic(path_str.clone(), &content, &formatted);
644                    eprintln!("{:?}", miette::Report::new(diag));
645                    result.errors.push((path_str, "not formatted".to_string()));
646                } else {
647                    match fs::write(file_path, &formatted) {
648                        Ok(()) => result.formatted.push(path_str),
649                        Err(e) => {
650                            result
651                                .errors
652                                .push((path_str, format!("failed to write: {e}")));
653                        }
654                    }
655                }
656            }
657            Ok(None) => result.unchanged += 1,
658            Err(_) => result.skipped += 1,
659        }
660    }
661
662    Ok(result)
663}