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