fprettier/
cli.rs

1//! Command-line interface for fprettier.
2//!
3//! Defines CLI arguments using clap builder API
4
5use std::path::PathBuf;
6
7use clap::{Arg, ArgAction, Command};
8
9/// CLI arguments parsed from command line
10#[derive(Debug, Clone)]
11pub struct CliArgs {
12    /// Files or directories to format
13    pub inputs: Vec<PathBuf>,
14
15    /// Number of spaces per indent level
16    pub indent: Option<usize>,
17
18    /// Maximum line length
19    pub line_length: Option<usize>,
20
21    /// Whitespace formatting level (0-4)
22    pub whitespace: Option<u8>,
23
24    /// Fine-grained whitespace: comma/semicolon spacing
25    pub whitespace_comma: Option<bool>,
26
27    /// Fine-grained whitespace: assignment operator spacing (=, =>)
28    pub whitespace_assignment: Option<bool>,
29
30    /// Fine-grained whitespace: declaration spacing (::)
31    pub whitespace_decl: Option<bool>,
32
33    /// Fine-grained whitespace: relational operator spacing (<, >, ==, etc.)
34    pub whitespace_relational: Option<bool>,
35
36    /// Fine-grained whitespace: logical operator spacing (.and., .or., etc.)
37    pub whitespace_logical: Option<bool>,
38
39    /// Fine-grained whitespace: plus/minus spacing
40    pub whitespace_plusminus: Option<bool>,
41
42    /// Fine-grained whitespace: multiply/divide spacing
43    pub whitespace_multdiv: Option<bool>,
44
45    /// Fine-grained whitespace: print/read statement spacing
46    pub whitespace_print: Option<bool>,
47
48    /// Fine-grained whitespace: type selector (%) spacing
49    pub whitespace_type: Option<bool>,
50
51    /// Fine-grained whitespace: intrinsic function spacing
52    pub whitespace_intrinsics: Option<bool>,
53
54    /// Fine-grained whitespace: string concatenation (//) spacing
55    pub whitespace_concat: Option<bool>,
56
57    /// Disable indentation
58    pub no_indent: bool,
59
60    /// Disable whitespace formatting
61    pub no_whitespace: bool,
62
63    /// Strict indentation checking
64    pub strict_indent: bool,
65
66    /// Don't indent fypp preprocessor
67    pub no_indent_fypp: bool,
68
69    /// Don't indent module/program blocks
70    pub no_indent_mod: bool,
71
72    /// Normalize comment spacing (use consistent spacing before inline comments)
73    pub normalize_comment_spacing: bool,
74
75    /// Format declaration statements
76    pub format_decl: bool,
77
78    /// Enable relational operator replacement (.lt. <-> <, etc.)
79    pub enable_replacements: bool,
80
81    /// Use C-style relational operators (<, <=, etc.) instead of Fortran-style (.lt., .le., etc.)
82    pub c_relations: bool,
83
84    /// Number of spaces before inline comments
85    pub comment_spacing: Option<usize>,
86
87    /// Output to stdout instead of in-place
88    pub stdout: bool,
89
90    /// Show diff without modifying files
91    pub diff: bool,
92
93    /// Config file path
94    pub config: Option<PathBuf>,
95
96    /// Recursive directory processing
97    pub recursive: bool,
98
99    /// Silent mode (no output)
100    pub silent: bool,
101
102    /// Case conversion settings [keywords, procedures, operators, constants]
103    /// Each value: 0=no change, 1=lowercase, 2=uppercase
104    pub case: Option<[i32; 4]>,
105
106    /// Number of parallel jobs (0 = auto, 1 = sequential)
107    pub jobs: Option<usize>,
108
109    /// Exclude patterns for files/directories (glob patterns)
110    pub exclude: Vec<String>,
111
112    /// Custom Fortran file extensions (in addition to defaults)
113    pub fortran_extensions: Vec<String>,
114
115    /// Exclude files with more than this many lines
116    pub exclude_max_lines: Option<usize>,
117
118    /// Enable debug output
119    pub debug: bool,
120}
121
122/// Build the clap Command for parsing CLI arguments
123#[must_use]
124pub fn build_cli() -> Command {
125    Command::new("fprettier")
126        .version(env!("CARGO_PKG_VERSION"))
127        .author("Fred Jones")
128        .about("Auto-formatter for modern Fortran code (Fortran 90+)")
129        .arg(
130            Arg::new("inputs")
131                .help("Files or directories to format")
132                .value_name("FILE")
133                .num_args(1..)
134                .required(false)
135                .value_parser(clap::value_parser!(PathBuf)),
136        )
137        .arg(
138            Arg::new("indent")
139                .short('i')
140                .long("indent")
141                .help("Number of spaces per indent level [default: 3]")
142                .value_name("NUM")
143                .value_parser(clap::value_parser!(usize)),
144        )
145        .arg(
146            Arg::new("line-length")
147                .short('l')
148                .long("line-length")
149                .help("Maximum line length [default: 132]")
150                .value_name("NUM")
151                .value_parser(clap::value_parser!(usize)),
152        )
153        .arg(
154            Arg::new("whitespace")
155                .short('w')
156                .long("whitespace")
157                .help("Whitespace level: 0=minimal, 1=+operators, 2=+plusminus, 3=+multdiv, 4=all [default: 2]")
158                .value_name("NUM")
159                .value_parser(clap::value_parser!(u8)),
160        )
161        // Fine-grained whitespace options
162        .arg(
163            Arg::new("whitespace-comma")
164                .long("whitespace-comma")
165                .help("Enable/disable spacing after commas and semicolons")
166                .value_name("BOOL")
167                .num_args(0..=1)
168                .require_equals(true)
169                .default_missing_value("true")
170                .value_parser(clap::value_parser!(bool)),
171        )
172        .arg(
173            Arg::new("whitespace-assignment")
174                .long("whitespace-assignment")
175                .help("Enable/disable spacing around assignment operators (=, =>)")
176                .value_name("BOOL")
177                .num_args(0..=1)
178                .require_equals(true)
179                .default_missing_value("true")
180                .value_parser(clap::value_parser!(bool)),
181        )
182        .arg(
183            Arg::new("whitespace-decl")
184                .long("whitespace-decl")
185                .help("Enable/disable spacing around declaration operator (::)")
186                .value_name("BOOL")
187                .num_args(0..=1)
188                .require_equals(true)
189                .default_missing_value("true")
190                .value_parser(clap::value_parser!(bool)),
191        )
192        .arg(
193            Arg::new("whitespace-relational")
194                .long("whitespace-relational")
195                .help("Enable/disable spacing around relational operators (<, >, ==, /=, .eq., etc.)")
196                .value_name("BOOL")
197                .num_args(0..=1)
198                .require_equals(true)
199                .default_missing_value("true")
200                .value_parser(clap::value_parser!(bool)),
201        )
202        .arg(
203            Arg::new("whitespace-logical")
204                .long("whitespace-logical")
205                .help("Enable/disable spacing around logical operators (.and., .or., etc.)")
206                .value_name("BOOL")
207                .num_args(0..=1)
208                .require_equals(true)
209                .default_missing_value("true")
210                .value_parser(clap::value_parser!(bool)),
211        )
212        .arg(
213            Arg::new("whitespace-plusminus")
214                .long("whitespace-plusminus")
215                .help("Enable/disable spacing around plus/minus operators")
216                .value_name("BOOL")
217                .num_args(0..=1)
218                .require_equals(true)
219                .default_missing_value("true")
220                .value_parser(clap::value_parser!(bool)),
221        )
222        .arg(
223            Arg::new("whitespace-multdiv")
224                .long("whitespace-multdiv")
225                .help("Enable/disable spacing around multiply/divide operators")
226                .value_name("BOOL")
227                .num_args(0..=1)
228                .require_equals(true)
229                .default_missing_value("true")
230                .value_parser(clap::value_parser!(bool)),
231        )
232        .arg(
233            Arg::new("whitespace-print")
234                .long("whitespace-print")
235                .help("Enable/disable spacing in print/read statements")
236                .value_name("BOOL")
237                .num_args(0..=1)
238                .require_equals(true)
239                .default_missing_value("true")
240                .value_parser(clap::value_parser!(bool)),
241        )
242        .arg(
243            Arg::new("whitespace-type")
244                .long("whitespace-type")
245                .help("Enable/disable spacing around type selector (%)")
246                .value_name("BOOL")
247                .num_args(0..=1)
248                .require_equals(true)
249                .default_missing_value("true")
250                .value_parser(clap::value_parser!(bool)),
251        )
252        .arg(
253            Arg::new("whitespace-intrinsics")
254                .long("whitespace-intrinsics")
255                .help("Enable/disable spacing before intrinsic function parentheses")
256                .value_name("BOOL")
257                .num_args(0..=1)
258                .require_equals(true)
259                .default_missing_value("true")
260                .value_parser(clap::value_parser!(bool)),
261        )
262        .arg(
263            Arg::new("whitespace-concat")
264                .long("whitespace-concat")
265                .help("Enable/disable spacing around string concatenation operator (//)")
266                .value_name("BOOL")
267                .num_args(0..=1)
268                .require_equals(true)
269                .default_missing_value("true")
270                .value_parser(clap::value_parser!(bool)),
271        )
272        .arg(
273            Arg::new("no-indent")
274                .long("no-indent")
275                .help("Disable indentation")
276                .action(ArgAction::SetTrue),
277        )
278        .arg(
279            Arg::new("no-whitespace")
280                .long("no-whitespace")
281                .help("Disable whitespace formatting")
282                .action(ArgAction::SetTrue),
283        )
284        .arg(
285            Arg::new("strict-indent")
286                .long("strict-indent")
287                .help("Strict indentation checking")
288                .action(ArgAction::SetTrue),
289        )
290        .arg(
291            Arg::new("no-indent-fypp")
292                .long("no-indent-fypp")
293                .help("Don't indent fypp preprocessor directives")
294                .action(ArgAction::SetTrue),
295        )
296        .arg(
297            Arg::new("no-indent-mod")
298                .long("no-indent-mod")
299                .help("Don't indent module/program/submodule blocks")
300                .action(ArgAction::SetTrue),
301        )
302        .arg(
303            Arg::new("normalize-comment-spacing")
304                .long("normalize-comment-spacing")
305                .help("Normalize spacing before inline comments")
306                .action(ArgAction::SetTrue),
307        )
308        .arg(
309            Arg::new("format-decl")
310                .long("format-decl")
311                .help("Format declaration statements")
312                .action(ArgAction::SetTrue),
313        )
314        .arg(
315            Arg::new("enable-replacements")
316                .long("enable-replacements")
317                .help("Replace relational operators between Fortran (.lt., .eq., etc.) and C-style (<, ==, etc.)")
318                .action(ArgAction::SetTrue),
319        )
320        .arg(
321            Arg::new("c-relations")
322                .long("c-relations")
323                .help("Use C-style relational operators (<, <=, >, >=, ==, /=) when --enable-replacements is set")
324                .action(ArgAction::SetTrue),
325        )
326        .arg(
327            Arg::new("comment-spacing")
328                .long("comment-spacing")
329                .help("Number of spaces before inline comments [default: 1]")
330                .value_name("NUM")
331                .value_parser(clap::value_parser!(usize)),
332        )
333        .arg(
334            Arg::new("stdout")
335                .short('s')
336                .long("stdout")
337                .help("Output to stdout instead of modifying files in-place")
338                .action(ArgAction::SetTrue),
339        )
340        .arg(
341            Arg::new("diff")
342                .short('d')
343                .long("diff")
344                .help("Show diff without modifying files")
345                .action(ArgAction::SetTrue),
346        )
347        .arg(
348            Arg::new("config")
349                .short('c')
350                .long("config")
351                .help("Path to configuration file (overrides auto-discovery)")
352                .value_name("FILE")
353                .value_parser(clap::value_parser!(PathBuf)),
354        )
355        .arg(
356            Arg::new("recursive")
357                .short('r')
358                .long("recursive")
359                .help("Recursively format directories")
360                .action(ArgAction::SetTrue),
361        )
362        .arg(
363            Arg::new("exclude")
364                .short('e')
365                .long("exclude")
366                .help("Exclude files/directories matching pattern (glob syntax, can be repeated)")
367                .value_name("PATTERN")
368                .action(ArgAction::Append),
369        )
370        .arg(
371            Arg::new("fortran")
372                .short('f')
373                .long("fortran")
374                .help("Additional Fortran file extension (can be repeated, e.g., -f f03 -f F03)")
375                .value_name("EXT")
376                .action(ArgAction::Append),
377        )
378        .arg(
379            Arg::new("exclude-max-lines")
380                .short('m')
381                .long("exclude-max-lines")
382                .help("Exclude files with more than this many lines")
383                .value_name("NUM")
384                .value_parser(clap::value_parser!(usize)),
385        )
386        .arg(
387            Arg::new("debug")
388                .short('D')
389                .long("debug")
390                .help("Enable debug output (shows config, scope changes, warnings)")
391                .action(ArgAction::SetTrue),
392        )
393        .arg(
394            Arg::new("silent")
395                .short('S')
396                .long("silent")
397                .help("Silent mode (no output, for editor integration)")
398                .action(ArgAction::SetTrue),
399        )
400        .arg(
401            Arg::new("case")
402                .long("case")
403                .help("Enable case formatting: 4 values for keywords, procedures, operators, constants (0=none, 1=lower, 2=upper)")
404                .value_name("NUM")
405                .num_args(4)
406                .value_parser(clap::value_parser!(i32)),
407        )
408        .arg(
409            Arg::new("jobs")
410                .short('j')
411                .long("jobs")
412                .help("Number of parallel jobs (0=auto, 1=sequential)")
413                .value_name("NUM")
414                .value_parser(clap::value_parser!(usize)),
415        )
416}
417
418/// Parse CLI arguments from command line
419#[must_use]
420pub fn parse_args() -> CliArgs {
421    args_from_matches(&build_cli().get_matches())
422}
423
424/// Parse CLI arguments from an iterator (for testing)
425#[must_use]
426pub fn parse_args_from<I, T>(args: I) -> CliArgs
427where
428    I: IntoIterator<Item = T>,
429    T: Into<std::ffi::OsString> + Clone,
430{
431    args_from_matches(&build_cli().get_matches_from(args))
432}
433
434/// Convert clap `ArgMatches` to `CliArgs`
435fn args_from_matches(matches: &clap::ArgMatches) -> CliArgs {
436    let case = matches.get_many::<i32>("case").map(|vals| {
437        let v: Vec<i32> = vals.copied().collect();
438        [v[0], v[1], v[2], v[3]]
439    });
440
441    CliArgs {
442        inputs: matches
443            .get_many::<PathBuf>("inputs")
444            .map(|vals| vals.cloned().collect())
445            .unwrap_or_default(),
446        indent: matches.get_one::<usize>("indent").copied(),
447        line_length: matches.get_one::<usize>("line-length").copied(),
448        whitespace: matches.get_one::<u8>("whitespace").copied(),
449        whitespace_comma: matches.get_one::<bool>("whitespace-comma").copied(),
450        whitespace_assignment: matches.get_one::<bool>("whitespace-assignment").copied(),
451        whitespace_decl: matches.get_one::<bool>("whitespace-decl").copied(),
452        whitespace_relational: matches.get_one::<bool>("whitespace-relational").copied(),
453        whitespace_logical: matches.get_one::<bool>("whitespace-logical").copied(),
454        whitespace_plusminus: matches.get_one::<bool>("whitespace-plusminus").copied(),
455        whitespace_multdiv: matches.get_one::<bool>("whitespace-multdiv").copied(),
456        whitespace_print: matches.get_one::<bool>("whitespace-print").copied(),
457        whitespace_type: matches.get_one::<bool>("whitespace-type").copied(),
458        whitespace_intrinsics: matches.get_one::<bool>("whitespace-intrinsics").copied(),
459        whitespace_concat: matches.get_one::<bool>("whitespace-concat").copied(),
460        no_indent: matches.get_flag("no-indent"),
461        no_whitespace: matches.get_flag("no-whitespace"),
462        strict_indent: matches.get_flag("strict-indent"),
463        no_indent_fypp: matches.get_flag("no-indent-fypp"),
464        no_indent_mod: matches.get_flag("no-indent-mod"),
465        normalize_comment_spacing: matches.get_flag("normalize-comment-spacing"),
466        format_decl: matches.get_flag("format-decl"),
467        enable_replacements: matches.get_flag("enable-replacements"),
468        c_relations: matches.get_flag("c-relations"),
469        comment_spacing: matches.get_one::<usize>("comment-spacing").copied(),
470        stdout: matches.get_flag("stdout"),
471        diff: matches.get_flag("diff"),
472        config: matches.get_one::<PathBuf>("config").cloned(),
473        recursive: matches.get_flag("recursive"),
474        exclude: matches
475            .get_many::<String>("exclude")
476            .map(|vals| vals.cloned().collect())
477            .unwrap_or_default(),
478        fortran_extensions: matches
479            .get_many::<String>("fortran")
480            .map(|vals| vals.cloned().collect())
481            .unwrap_or_default(),
482        exclude_max_lines: matches.get_one::<usize>("exclude-max-lines").copied(),
483        debug: matches.get_flag("debug"),
484        silent: matches.get_flag("silent"),
485        case,
486        jobs: matches.get_one::<usize>("jobs").copied(),
487    }
488}
489
490#[cfg(test)]
491mod tests {
492    use super::*;
493
494    #[test]
495    fn test_cli_builds() {
496        let cmd = build_cli();
497        // Just verify it builds without panic
498        assert_eq!(cmd.get_name(), "fprettier");
499    }
500
501    #[test]
502    fn test_cli_defaults() {
503        let cmd = build_cli();
504        let matches = cmd.try_get_matches_from(vec!["fprettier"]).unwrap();
505
506        assert!(matches.get_many::<PathBuf>("inputs").is_none());
507        assert!(!matches.get_flag("no-indent"));
508        assert!(!matches.get_flag("stdout"));
509    }
510
511    #[test]
512    fn test_whitespace_comma_flag() {
513        // Test --whitespace-comma (no value = true)
514        let args = parse_args_from(vec!["fprettier", "--whitespace-comma", "file.f90"]);
515        assert_eq!(args.whitespace_comma, Some(true));
516    }
517
518    #[test]
519    fn test_whitespace_comma_explicit_true() {
520        // Test --whitespace-comma=true
521        let args = parse_args_from(vec!["fprettier", "--whitespace-comma=true", "file.f90"]);
522        assert_eq!(args.whitespace_comma, Some(true));
523    }
524
525    #[test]
526    fn test_whitespace_comma_explicit_false() {
527        // Test --whitespace-comma=false
528        let args = parse_args_from(vec!["fprettier", "--whitespace-comma=false", "file.f90"]);
529        assert_eq!(args.whitespace_comma, Some(false));
530    }
531
532    #[test]
533    fn test_whitespace_options_not_set() {
534        // Test that options are None when not specified
535        let args = parse_args_from(vec!["fprettier", "file.f90"]);
536        assert_eq!(args.whitespace_comma, None);
537        assert_eq!(args.whitespace_assignment, None);
538        assert_eq!(args.whitespace_decl, None);
539        assert_eq!(args.whitespace_relational, None);
540        assert_eq!(args.whitespace_logical, None);
541        assert_eq!(args.whitespace_plusminus, None);
542        assert_eq!(args.whitespace_multdiv, None);
543        assert_eq!(args.whitespace_print, None);
544        assert_eq!(args.whitespace_type, None);
545        assert_eq!(args.whitespace_intrinsics, None);
546        assert_eq!(args.whitespace_concat, None);
547    }
548
549    #[test]
550    fn test_multiple_whitespace_options() {
551        // Test multiple options together
552        let args = parse_args_from(vec![
553            "fprettier",
554            "--whitespace-comma",
555            "--whitespace-concat=false",
556            "--whitespace-type=true",
557            "file.f90",
558        ]);
559        assert_eq!(args.whitespace_comma, Some(true));
560        assert_eq!(args.whitespace_concat, Some(false));
561        assert_eq!(args.whitespace_type, Some(true));
562        // Others should be None
563        assert_eq!(args.whitespace_assignment, None);
564    }
565
566    #[test]
567    fn test_all_whitespace_options() {
568        // Test all 11 options
569        let args = parse_args_from(vec![
570            "fprettier",
571            "--whitespace-comma=true",
572            "--whitespace-assignment=false",
573            "--whitespace-decl=true",
574            "--whitespace-relational=false",
575            "--whitespace-logical=true",
576            "--whitespace-plusminus=false",
577            "--whitespace-multdiv=true",
578            "--whitespace-print=false",
579            "--whitespace-type=true",
580            "--whitespace-intrinsics=false",
581            "--whitespace-concat=true",
582            "file.f90",
583        ]);
584        assert_eq!(args.whitespace_comma, Some(true));
585        assert_eq!(args.whitespace_assignment, Some(false));
586        assert_eq!(args.whitespace_decl, Some(true));
587        assert_eq!(args.whitespace_relational, Some(false));
588        assert_eq!(args.whitespace_logical, Some(true));
589        assert_eq!(args.whitespace_plusminus, Some(false));
590        assert_eq!(args.whitespace_multdiv, Some(true));
591        assert_eq!(args.whitespace_print, Some(false));
592        assert_eq!(args.whitespace_type, Some(true));
593        assert_eq!(args.whitespace_intrinsics, Some(false));
594        assert_eq!(args.whitespace_concat, Some(true));
595    }
596
597    #[test]
598    fn test_comment_spacing() {
599        let args = parse_args_from(vec!["fprettier", "--comment-spacing", "3", "file.f90"]);
600        assert_eq!(args.comment_spacing, Some(3));
601    }
602
603    #[test]
604    fn test_comment_spacing_not_set() {
605        let args = parse_args_from(vec!["fprettier", "file.f90"]);
606        assert_eq!(args.comment_spacing, None);
607    }
608
609    #[test]
610    fn test_exclude_single() {
611        let args = parse_args_from(vec!["fprettier", "-r", "-e", "*.mod", "src/"]);
612        assert_eq!(args.exclude, vec!["*.mod"]);
613    }
614
615    #[test]
616    fn test_exclude_multiple() {
617        let args = parse_args_from(vec![
618            "fprettier",
619            "-r",
620            "-e",
621            "*.mod",
622            "--exclude",
623            "build*",
624            "-e",
625            "test_*",
626            "src/",
627        ]);
628        assert_eq!(args.exclude, vec!["*.mod", "build*", "test_*"]);
629    }
630
631    #[test]
632    fn test_exclude_empty() {
633        let args = parse_args_from(vec!["fprettier", "file.f90"]);
634        assert!(args.exclude.is_empty());
635    }
636
637    #[test]
638    fn test_fortran_single_extension() {
639        let args = parse_args_from(vec!["fprettier", "-r", "-f", "f2003", "src/"]);
640        assert_eq!(args.fortran_extensions, vec!["f2003"]);
641    }
642
643    #[test]
644    fn test_fortran_multiple_extensions() {
645        let args = parse_args_from(vec![
646            "fprettier",
647            "-r",
648            "-f",
649            "f2003",
650            "--fortran",
651            "F2003",
652            "-f",
653            "f2008",
654            "src/",
655        ]);
656        assert_eq!(args.fortran_extensions, vec!["f2003", "F2003", "f2008"]);
657    }
658
659    #[test]
660    fn test_fortran_extensions_empty() {
661        let args = parse_args_from(vec!["fprettier", "file.f90"]);
662        assert!(args.fortran_extensions.is_empty());
663    }
664
665    #[test]
666    fn test_exclude_max_lines() {
667        let args = parse_args_from(vec!["fprettier", "--exclude-max-lines", "1000", "file.f90"]);
668        assert_eq!(args.exclude_max_lines, Some(1000));
669    }
670
671    #[test]
672    fn test_exclude_max_lines_short_flag() {
673        let args = parse_args_from(vec!["fprettier", "-m", "500", "file.f90"]);
674        assert_eq!(args.exclude_max_lines, Some(500));
675    }
676
677    #[test]
678    fn test_exclude_max_lines_not_set() {
679        let args = parse_args_from(vec!["fprettier", "file.f90"]);
680        assert_eq!(args.exclude_max_lines, None);
681    }
682
683    #[test]
684    fn test_debug_flag() {
685        let args = parse_args_from(vec!["fprettier", "-D", "file.f90"]);
686        assert!(args.debug);
687    }
688
689    #[test]
690    fn test_debug_long_flag() {
691        let args = parse_args_from(vec!["fprettier", "--debug", "file.f90"]);
692        assert!(args.debug);
693    }
694
695    #[test]
696    fn test_debug_not_set() {
697        let args = parse_args_from(vec!["fprettier", "file.f90"]);
698        assert!(!args.debug);
699    }
700}