difft_lib/
options.rs

1//! CLI option parsing.
2
3use 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;
12// Chosen experimentally: this is sufficiently many for all the sample
13// files (the highest is slow_before/after.rs at 1.3M nodes), but
14// small enough to terminate in ~5 seconds like the test file in #306.
15pub 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                // TODO: support DFT_LANGUAGE for consistency
139        )
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        /// The path where we can read the LHS file. This is often a
189        /// temporary file generated by source control.
190        lhs_path: OsString,
191        /// The path where we can read the RHS file. This is often a
192        /// temporary file generated by source control.
193        rhs_path: OsString,
194        /// The path that we should display for the LHS file.
195        lhs_display_path: String,
196        /// The path that we should display for the RHS file.
197        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
210/// Parse CLI arguments passed to the binary.
211pub 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    // TODO: document these different ways of calling difftastic.
251    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            // https://git-scm.com/docs/git#Documentation/git.txt-codeGITEXTERNALDIFFcode
261            (
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            // Rename file.
272            // TODO: where does git document these 9 arguments?
273            (
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
386/// Choose the display width: try to autodetect, or fall back to a
387/// sensible default.
388pub fn detect_display_width() -> usize {
389    // terminal_size is actively maintained, but only considers
390    // stdout. This is a problem inside git, where stderr is a TTY
391    // with a size but stdout is piped to less.
392    //
393    // https://github.com/eminence/terminal-size/issues/23
394    if let Some(width) = terminal_size::terminal_size().map(|(w, _)| w.0 as usize) {
395        return width;
396    }
397
398    // term_size is no longer maintained, but it checks all of stdin,
399    // stdout and stderr, so gives better results in may cases.
400    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            // Always enable colour if stdout is a TTY or if the git pager is active.
412            // TODO: consider following the env parsing logic in git_config_bool
413            // in config.c.
414            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        // Basic smoke test.
432        assert!(detect_display_width() > 10);
433    }
434}