1use std::{borrow::Borrow, env, ffi::OsString};
4
5use atty::Stream;
6use clap::{crate_authors, crate_description, crate_version, Arg, Command};
7use const_format::formatcp;
8
9pub use crate::{display::style::BackgroundColor, parse::guess_language};
10
11pub const DEFAULT_BYTE_LIMIT: usize = 1_000_000;
12pub const DEFAULT_GRAPH_LIMIT: usize = 3_000_000;
16pub const DEFAULT_TAB_WIDTH: usize = 8;
17
18const USAGE: &str = concat!(env!("CARGO_CRATE_NAME"), " [OPTIONS] OLD-PATH NEW-PATH");
19
20#[derive(Debug, Clone, Copy)]
21pub enum ColorOutput {
22 Always,
23 Auto,
24 Never,
25}
26
27#[derive(Debug, Clone)]
28pub struct DisplayOptions {
29 pub background_color: BackgroundColor,
30 pub use_color: bool,
31 pub display_mode: DisplayMode,
32 pub print_unchanged: bool,
33 pub tab_width: usize,
34 pub display_width: usize,
35 pub in_vcs: bool,
36 pub syntax_highlight: bool,
37}
38
39fn app() -> clap::Command<'static> {
40 Command::new("Difftastic")
41 .override_usage(USAGE)
42 .version(crate_version!())
43 .about(crate_description!())
44 .author(crate_authors!())
45 .after_long_help(concat!(
46 "You can compare two files with difftastic by specifying them as arguments.\n\n",
47 "$ ",
48 env!("CARGO_CRATE_NAME"),
49 " old.js new.js\n\n",
50 "You can also use directories as arguments. Difftastic will walk both directories and compare files with matching names.\n\n",
51 "$ ",
52 env!("CARGO_CRATE_NAME"),
53 " old/ new/\n\n",
54 "Difftastic can also be invoked with 7 arguments in the format that GIT_EXTERNAL_DIFF expects.\n\n",
55 "See the full manual at: https://difftastic.wilfred.me.uk/")
56 )
57 .arg(
58 Arg::new("dump-syntax")
59 .long("dump-syntax")
60 .takes_value(true)
61 .value_name("PATH")
62 .long_help(
63 "Parse a single file with tree-sitter and display the difftastic syntax tree.",
64 ).help_heading("DEBUG OPTIONS"),
65 )
66 .arg(
67 Arg::new("dump-ts")
68 .long("dump-ts")
69 .takes_value(true)
70 .value_name("PATH")
71 .long_help(
72 "Parse a single file with tree-sitter and display the tree-sitter parse tree.",
73 ).help_heading("DEBUG OPTIONS"),
74 )
75 .arg(
76 Arg::new("width")
77 .long("width")
78 .takes_value(true)
79 .value_name("COLUMNS")
80 .long_help("Use this many columns when calculating line wrapping. If not specified, difftastic will detect the terminal width.")
81 .env("DFT_WIDTH")
82 .validator(|s| s.parse::<usize>())
83 .required(false),
84 )
85 .arg(
86 Arg::new("tab-width")
87 .long("tab-width")
88 .takes_value(true)
89 .value_name("NUM_SPACES")
90 .long_help("Treat a tab as this many spaces.")
91 .env("DFT_TAB_WIDTH")
92 .default_value(formatcp!("{}", DEFAULT_TAB_WIDTH))
93 .validator(|s| s.parse::<usize>())
94 .required(false),
95 )
96 .arg(
97 Arg::new("display").long("display")
98 .possible_values(["side-by-side", "side-by-side-show-both", "inline", ])
99 .value_name("MODE")
100 .env("DFT_DISPLAY")
101 .help("Display mode for showing results.")
102 )
103 .arg(
104 Arg::new("color").long("color")
105 .possible_values(["always", "auto", "never"])
106 .value_name("WHEN")
107 .help("When to use color output.")
108 )
109 .arg(
110 Arg::new("background").long("background")
111 .value_name("BACKGROUND")
112 .env("DFT_BACKGROUND")
113 .possible_values(["dark", "light"])
114 .default_value("dark")
115 .help("Set the background brightness. Difftastic will prefer brighter colours on dark backgrounds.")
116 )
117 .arg(
118 Arg::new("syntax-highlight").long("syntax-highlight")
119 .value_name("on/off")
120 .env("DFT_SYNTAX_HIGHLIGHT")
121 .possible_values(["on", "off"])
122 .default_value("on")
123 .help("Enable or disable syntax highlighting.")
124 )
125 .arg(
126 Arg::new("skip-unchanged").long("skip-unchanged")
127 .help("Don't display anything if a file is unchanged.")
128 )
129 .arg(
130 Arg::new("missing-as-empty").long("missing-as-empty")
131 .help("Treat paths that don't exist as equivalent to an empty file. Only applies when diffing files, not directories.")
132 )
133 .arg(
134 Arg::new("language").long("language")
135 .value_name("EXT")
136 .allow_invalid_utf8(true)
137 .help("Override language detection. Inputs are assumed to have this file extension. When diffing directories, applies to all files.")
138 )
140 .arg(
141 Arg::new("list-languages").long("list-languages")
142 .help("Print the all the languages supported by difftastic, along with their extensions.")
143 )
144 .arg(
145 Arg::new("byte-limit").long("byte-limit")
146 .takes_value(true)
147 .value_name("LIMIT")
148 .help(concat!("Use a text diff if either input file exceeds this size."))
149 .default_value(formatcp!("{}", DEFAULT_BYTE_LIMIT))
150 .env("DFT_BYTE_LIMIT")
151 .validator(|s| s.parse::<usize>())
152 .required(false),
153 )
154 .arg(
155 Arg::new("graph-limit").long("graph-limit")
156 .takes_value(true)
157 .value_name("LIMIT")
158 .help(concat!("Use a text diff if the structural graph exceed this number of nodes in memory."))
159 .default_value(formatcp!("{}", DEFAULT_GRAPH_LIMIT))
160 .env("DFT_GRAPH_LIMIT")
161 .validator(|s| s.parse::<usize>())
162 .required(false),
163 )
164 .arg(
165 Arg::new("paths")
166 .value_name("PATHS")
167 .multiple_values(true)
168 .hide(true)
169 .allow_invalid_utf8(true),
170 )
171 .arg_required_else_help(true)
172}
173
174#[derive(Debug, Copy, Clone)]
175pub enum DisplayMode {
176 Inline,
177 SideBySide,
178 SideBySideShowBoth,
179}
180
181pub enum Mode {
182 Diff {
183 graph_limit: usize,
184 byte_limit: usize,
185 display_options: DisplayOptions,
186 missing_as_empty: bool,
187 language_override: Option<guess_language::Language>,
188 lhs_path: OsString,
191 rhs_path: OsString,
194 lhs_display_path: String,
196 rhs_display_path: String,
198 },
199 ListLanguages,
200 DumpTreeSitter {
201 path: String,
202 language_override: Option<guess_language::Language>,
203 },
204 DumpSyntax {
205 path: String,
206 language_override: Option<guess_language::Language>,
207 },
208}
209
210pub fn parse_args() -> Mode {
212 let matches = app().get_matches();
213
214 let language_override = match matches.value_of_os("language") {
215 Some(lang_str) => {
216 if let Some(lang) = guess_language::from_extension(lang_str) {
217 Some(lang)
218 } else {
219 eprintln!(
220 "No language is associated with extension: {}",
221 lang_str.to_string_lossy()
222 );
223 None
224 }
225 }
226 None => None,
227 };
228
229 if matches.is_present("list-languages") {
230 return Mode::ListLanguages;
231 }
232
233 if let Some(path) = matches.value_of("dump-syntax") {
234 return Mode::DumpSyntax {
235 path: path.to_string(),
236 language_override,
237 };
238 }
239
240 if let Some(path) = matches.value_of("dump-ts") {
241 return Mode::DumpTreeSitter {
242 path: path.to_string(),
243 language_override,
244 };
245 }
246
247 let args: Vec<_> = matches.values_of_os("paths").unwrap_or_default().collect();
248 info!("CLI arguments: {:?}", args);
249
250 let (lhs_display_path, rhs_display_path, lhs_path, rhs_path, in_vcs) = match &args[..] {
252 [lhs_path, rhs_path] => (
253 lhs_path.to_owned(),
254 rhs_path.to_owned(),
255 lhs_path.to_owned(),
256 rhs_path.to_owned(),
257 false,
258 ),
259 [display_path, lhs_tmp_file, _lhs_hash, _lhs_mode, rhs_tmp_file, _rhs_hash, _rhs_mode] => {
260 (
262 display_path.to_owned(),
263 display_path.to_owned(),
264 lhs_tmp_file.to_owned(),
265 rhs_tmp_file.to_owned(),
266 true,
267 )
268 }
269 [old_name, lhs_tmp_file, _lhs_hash, _lhs_mode, rhs_tmp_file, _rhs_hash, _rhs_mode, new_name, _similarity] =>
270 {
271 (
274 old_name.to_owned(),
275 new_name.to_owned(),
276 lhs_tmp_file.to_owned(),
277 rhs_tmp_file.to_owned(),
278 true,
279 )
280 }
281 _ => {
282 if !args.is_empty() {
283 eprintln!(
284 "error: Difftastic does not support being called with {} argument{}.\n",
285 args.len(),
286 if args.len() == 1 { "" } else { "s" }
287 );
288 }
289 eprintln!("USAGE:\n\n {}\n", USAGE);
290 eprintln!("For more information try --help");
291 std::process::exit(1);
292 }
293 };
294
295 let display_width = if let Some(arg_width) = matches.value_of("width") {
296 arg_width
297 .parse::<usize>()
298 .expect("Already validated by clap")
299 } else {
300 detect_display_width()
301 };
302
303 let display_mode = if let Some(display_mode_str) = matches.value_of("display") {
304 match display_mode_str.borrow() {
305 "side-by-side" => DisplayMode::SideBySide,
306 "side-by-side-show-both" => DisplayMode::SideBySideShowBoth,
307 "inline" => DisplayMode::Inline,
308 _ => {
309 unreachable!("clap has already validated display")
310 }
311 }
312 } else {
313 DisplayMode::SideBySide
314 };
315
316 let color_output = if let Some(color_when) = matches.value_of("color") {
317 if color_when == "always" {
318 ColorOutput::Always
319 } else if color_when == "never" {
320 ColorOutput::Never
321 } else {
322 ColorOutput::Auto
323 }
324 } else {
325 ColorOutput::Auto
326 };
327
328 let background_color = match matches
329 .value_of("background")
330 .expect("Always present as we've given clap a default")
331 {
332 "dark" => BackgroundColor::Dark,
333 "light" => BackgroundColor::Light,
334 _ => unreachable!("clap has already validated the values"),
335 };
336
337 let syntax_highlight = matches.value_of("syntax-highlight") == Some("on");
338
339 let graph_limit = matches
340 .value_of("graph-limit")
341 .expect("Always present as we've given clap a default")
342 .parse::<usize>()
343 .expect("Value already validated by clap");
344
345 let byte_limit = matches
346 .value_of("byte-limit")
347 .expect("Always present as we've given clap a default")
348 .parse::<usize>()
349 .expect("Value already validated by clap");
350
351 let tab_width = matches
352 .value_of("tab-width")
353 .expect("Always present as we've given clap a default")
354 .parse::<usize>()
355 .expect("Value already validated by clap");
356
357 let print_unchanged = !matches.is_present("skip-unchanged");
358 let missing_as_empty = matches.is_present("missing-as-empty");
359
360 let use_color = should_use_color(color_output);
361
362 let display_options = DisplayOptions {
363 background_color,
364 use_color,
365 print_unchanged,
366 tab_width,
367 display_mode,
368 display_width,
369 syntax_highlight,
370 in_vcs,
371 };
372
373 Mode::Diff {
374 graph_limit,
375 byte_limit,
376 display_options,
377 missing_as_empty,
378 language_override,
379 lhs_path: lhs_path.to_owned(),
380 rhs_path: rhs_path.to_owned(),
381 lhs_display_path: lhs_display_path.to_string_lossy().to_string(),
382 rhs_display_path: rhs_display_path.to_string_lossy().to_string(),
383 }
384}
385
386pub fn detect_display_width() -> usize {
389 if let Some(width) = terminal_size::terminal_size().map(|(w, _)| w.0 as usize) {
395 return width;
396 }
397
398 if let Some(width) = term_size::dimensions().map(|(w, _)| w) {
401 return width;
402 }
403
404 80
405}
406
407pub fn should_use_color(color_output: ColorOutput) -> bool {
408 match color_output {
409 ColorOutput::Always => true,
410 ColorOutput::Auto => {
411 atty::is(Stream::Stdout) || env::var("GIT_PAGER_IN_USE").is_ok()
415 }
416 ColorOutput::Never => false,
417 }
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn test_app() {
426 app().debug_assert();
427 }
428
429 #[test]
430 fn test_detect_display_width() {
431 assert!(detect_display_width() > 10);
433 }
434}