1#![doc = include_str!("../README.md")]
2#![allow(unused_assignments)] mod 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#[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
38pub 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 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
116fn 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_config::NewLineKind::Auto | dprint_config::NewLineKind::System => {
133 dprint_core::configuration::NewLineKind::Auto
134 }
135 }),
136 }
137}
138
139fn 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
183pub 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#[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 file_path: String,
253 path: String,
254 diff: String,
255}
256
257impl FormatDiagnostic {
258 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
306fn 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"; const ADD: &str = "\x1b[32m"; 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 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 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 let _ = writeln!(out, "{}", diff_summary(added, removed, color));
342
343 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
400fn 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
468struct 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#[derive(Debug, Clone, Bpaf)]
517#[bpaf(generate(format_args_inner))]
518pub struct FormatArgs {
519 #[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
530pub fn format_args() -> impl bpaf::Parser<FormatArgs> {
532 format_args_inner()
533}
534
535pub struct FormatResult {
540 pub formatted: Vec<String>,
542 pub unchanged: usize,
544 pub skipped: usize,
546 pub errors: Vec<(String, String)>,
548}
549
550pub 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
581pub 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
608pub 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}