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