1#![doc = include_str!("../README.md")]
2#![allow(unused_assignments)] extern crate alloc;
5
6mod toml;
7
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result};
12use bpaf::{Bpaf, ShellComp};
13use miette::Diagnostic;
14use thiserror::Error;
15
16#[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
40pub 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 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
118fn 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_config::NewLineKind::Auto | dprint_config::NewLineKind::System => {
135 dprint_core::configuration::NewLineKind::Auto
136 }
137 }),
138 }
139}
140
141fn 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
185pub 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#[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 file_path: String,
255 path: String,
256 diff: String,
257}
258
259impl FormatDiagnostic {
260 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
308fn 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"; const ADD: &str = "\x1b[32m"; 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 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 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 let _ = writeln!(out, "{}", diff_summary(added, removed, color));
344
345 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
402fn 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
470struct 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#[derive(Debug, Clone, Bpaf)]
519#[bpaf(generate(format_args_inner))]
520pub struct FormatArgs {
521 #[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"), complete_shell(ShellComp::File { mask: None }))]
529 pub globs: Vec<String>,
530}
531
532pub fn format_args() -> impl bpaf::Parser<FormatArgs> {
534 format_args_inner()
535}
536
537pub struct FormatResult {
542 pub formatted: Vec<String>,
544 pub unchanged: usize,
546 pub skipped: usize,
548 pub errors: Vec<(String, String)>,
550}
551
552pub 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
583pub 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
610pub 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}