1#![doc = include_str!("../README.md")]
2#![allow(unused_assignments)] mod toml;
5
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use anyhow::Result;
10use bpaf::{Bpaf, ShellComp};
11use lintel_diagnostics::LintelDiagnostic;
12
13#[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
37pub 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 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
115fn 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_config::NewLineKind::Auto | dprint_config::NewLineKind::System => {
132 dprint_core::configuration::NewLineKind::Auto
133 }
134 }),
135 }
136}
137
138fn 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
182pub 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
238fn 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
284fn 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"; const ADD: &str = "\x1b[32m"; 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 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 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 let _ = writeln!(out, "{}", diff_summary(added, removed, color));
320
321 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
378fn 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
386struct 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#[derive(Debug, Clone, Bpaf)]
435#[bpaf(generate(format_args_inner))]
436pub struct FormatArgs {
437 #[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
448pub fn format_args() -> impl bpaf::Parser<FormatArgs> {
450 format_args_inner()
451}
452
453pub struct FormatResult {
458 pub formatted: Vec<String>,
460 pub unchanged: usize,
462 pub skipped: usize,
464 pub errors: Vec<(String, String)>,
466}
467
468pub 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
500pub 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
527pub 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
554pub 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
611pub 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
633pub 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
659pub 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}