Skip to main content

ktrs_cli/ktlint/
args.rs

1//! The options of `KtlintCommandLine` and its subcommands (`Main.kt`), parsed the way Clikt does.
2
3use std::path::Path;
4use std::sync::LazyLock;
5
6use ktrs_editorconfig::EnumValue;
7use ktrs_lint::editorconfig::{CodeStyleValue, KtlintVersion};
8
9use crate::ktlint::clikt::{Arity, Invocation, OptionSpec, expand_argument_files, parse_tokens};
10use crate::ktlint::gradle;
11use crate::ktlint::logger::Level;
12use crate::ktlint::version::{KTLINT_VERSION_OPTION, release, resolve_ktlint_version};
13
14pub const HELP_MAIN: &str = include_str!("help/help-main.txt");
15const HELP_GENERATE_EDITOR_CONFIG: &str = include_str!("help/help-gen.txt");
16const HELP_PRE_COMMIT: &str = include_str!("help/help-pre.txt");
17const HELP_PRE_PUSH: &str = include_str!("help/help-push.txt");
18
19pub const USAGE_MAIN: &str = "Usage: ktlint [<options>] [<arguments>]... <command> [<args>]...";
20pub const GENERATE_EDITOR_CONFIG: &str = "generateEditorConfig";
21pub const INSTALL_GIT_PRE_COMMIT_HOOK: &str = "installGitPreCommitHook";
22pub const INSTALL_GIT_PRE_PUSH_HOOK: &str = "installGitPrePushHook";
23
24const fn spec(names: &'static [&'static str], arity: Arity) -> OptionSpec {
25    OptionSpec { names, arity, hidden: false }
26}
27
28static MAIN_OPTIONS: [OptionSpec; 21] = [
29    spec(&["--version", "-v"], Arity::Flag),
30    spec(&["--color"], Arity::Flag),
31    spec(&["--color-name"], Arity::Value),
32    spec(&["--format", "-F"], Arity::Flag),
33    spec(&["--ignore-autocorrect-failures"], Arity::Flag),
34    spec(&["--limit"], Arity::Value),
35    spec(&["--relative"], Arity::Flag),
36    spec(&["--reporter"], Arity::Value),
37    spec(&["--ruleset", "-R"], Arity::Value),
38    spec(&["--stdin"], Arity::Flag),
39    spec(&["--stdin-path"], Arity::Value),
40    spec(&["--patterns-from-stdin"], Arity::OptionalAttached),
41    spec(&["--editorconfig"], Arity::Value),
42    OptionSpec { names: &["--force-lint-after-format"], arity: Arity::Flag, hidden: true },
43    spec(&["--baseline"], Arity::Value),
44    spec(&["--log-level", "-l"], Arity::Value),
45    spec(&["--help", "-h"], Arity::Flag),
46    OptionSpec { names: &[KTLINT_VERSION_OPTION], arity: Arity::Value, hidden: true },
47    OptionSpec { names: &[gradle::EVENTS_OPTION], arity: Arity::Value, hidden: true },
48    OptionSpec { names: &[gradle::RELATIVE_TO_OPTION], arity: Arity::Value, hidden: true },
49    OptionSpec { names: &[gradle::EDITOR_CONFIG_OVERRIDE_OPTION], arity: Arity::Value, hidden: true },
50];
51
52/// 1.8 still declares `--code-style` (deprecated, an error when used) right after `--version`.
53static MAIN_OPTIONS_1_8: LazyLock<Vec<OptionSpec>> = LazyLock::new(|| {
54    let mut options = MAIN_OPTIONS.to_vec();
55    options.insert(1, spec(&[CODE_STYLE_OPTION], Arity::Value));
56    options
57});
58
59const CODE_STYLE_OPTION: &str = "--code-style";
60const CODE_STYLE_CHOICES: &str = "(choose from android_studio, intellij_idea, ktlint_official)";
61
62fn main_options(ktlint_version: KtlintVersion) -> &'static [OptionSpec] {
63    if ktlint_version.is_1_8() { &MAIN_OPTIONS_1_8 } else { &MAIN_OPTIONS }
64}
65
66/// 1.8's help: the old documentation URL and the `--code-style` entry.
67pub fn help_main(ktlint_version: KtlintVersion) -> String {
68    if !ktlint_version.is_1_8() {
69        return HELP_MAIN.to_owned();
70    }
71    HELP_MAIN.replace("(https://ktlint.github.io/ktlint/latest/)", "(https://pinterest.github.io/ktlint/latest/)").replace(
72        "  -v, --version                  Show the version and exit\n",
73        "  -v, --version                  Show the version and exit\n  \
74         --code-style=(android_studio|intellij_idea|ktlint_official)\n                                 (deprecated)\n",
75    )
76}
77
78static GENERATE_EDITOR_CONFIG_OPTIONS: [OptionSpec; 2] =
79    [spec(&["--code-style"], Arity::Value), spec(&["--help", "-h"], Arity::Flag)];
80static HELP_ONLY_OPTIONS: [OptionSpec; 1] = [spec(&["--help", "-h"], Arity::Flag)];
81
82/// `KtlintCommandLine`'s options.
83#[derive(Clone, Debug)]
84pub struct KtlintArgs {
85    pub color: bool,
86    pub color_name: String,
87    pub format: bool,
88    pub ignore_autocorrect_failures: bool,
89    pub limit: usize,
90    pub relative: bool,
91    pub reporter_configurations: Vec<String>,
92    pub ruleset_jar_paths: Vec<String>,
93    pub stdin: bool,
94    pub stdin_path: Option<String>,
95    pub patterns_from_stdin: Option<String>,
96    pub editor_config_path: Option<String>,
97    pub force_lint_after_format: bool,
98    pub baseline_path: String,
99    pub arguments: Vec<String>,
100    pub min_log_level: Level,
101    pub ktlint_version: KtlintVersion,
102    /// ktrs-only (the Gradle plugin's): [`gradle`].
103    pub gradle_events: Option<String>,
104    pub relative_to: Option<String>,
105    pub editor_config_overrides: Vec<String>,
106}
107
108impl Default for KtlintArgs {
109    fn default() -> KtlintArgs {
110        KtlintArgs {
111            color: false,
112            color_name: "DARK_GRAY".to_owned(),
113            format: false,
114            ignore_autocorrect_failures: false,
115            limit: i32::MAX as usize,
116            relative: false,
117            reporter_configurations: Vec::new(),
118            ruleset_jar_paths: Vec::new(),
119            stdin: false,
120            stdin_path: None,
121            patterns_from_stdin: None,
122            editor_config_path: None,
123            force_lint_after_format: false,
124            baseline_path: String::new(),
125            arguments: Vec::new(),
126            min_log_level: Level::Info,
127            ktlint_version: KtlintVersion::default(),
128            gradle_events: None,
129            relative_to: None,
130            editor_config_overrides: Vec::new(),
131        }
132    }
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum Subcommand {
137    GenerateEditorConfig(CodeStyleValue),
138    InstallGitPreCommitHook,
139    InstallGitPrePushHook,
140}
141
142pub enum Parsed {
143    Run(KtlintArgs, Option<Subcommand>),
144    /// Help or version text for stdout, exit 0.
145    Message(String),
146    /// A usage error: the command's usage line and the message, for stderr, exit 1.
147    UsageError { usage: String, message: String },
148    /// A plain `CliktError`: the message alone, for stderr, exit 1.
149    Error(String),
150}
151
152pub fn parse_args(tokens: &[String], working_dir: &Path) -> Parsed {
153    let tokens = match expand_argument_files(tokens, working_dir) {
154        Ok(tokens) => tokens,
155        Err(message) => return usage_error(USAGE_MAIN, message),
156    };
157    let ktlint_version = match resolve_ktlint_version(&tokens, working_dir) {
158        Ok(ktlint_version) => ktlint_version,
159        Err(message) => return usage_error(USAGE_MAIN, message),
160    };
161    let subcommands = [GENERATE_EDITOR_CONFIG, INSTALL_GIT_PRE_COMMIT_HOOK, INSTALL_GIT_PRE_PUSH_HOOK];
162    let parsed = match parse_tokens(&tokens, main_options(ktlint_version), &subcommands) {
163        Ok(parsed) => parsed,
164        // Clikt runs eager options (help, version) before it reports a usage error.
165        Err(_) if has_token(&tokens, &["--help", "-h"]) => return Parsed::Message(help_main(ktlint_version)),
166        Err(_) if has_token(&tokens, &["--version", "-v"]) => return version(ktlint_version),
167        Err(message) => return usage_error(USAGE_MAIN, with_subcommand_hint(message)),
168    };
169    let has = |name: &str| parsed.invocations.iter().any(|i| i.name == name);
170    if has("--help") {
171        return Parsed::Message(help_main(ktlint_version));
172    }
173    if has("--version") {
174        return version(ktlint_version);
175    }
176    if let Some(code_style) = parsed.invocations.iter().rev().find(|i| i.name == CODE_STYLE_OPTION).and_then(|i| i.value.as_deref()) {
177        return if code_style_value(code_style).is_some() {
178            Parsed::Error(
179                "Parameter '--code-style' is no longer valid. The code style should be defined as '.editorconfig' property \
180                 'ktlint_code_style='"
181                    .to_owned(),
182            )
183        } else {
184            usage_error(USAGE_MAIN, format!("invalid value for {CODE_STYLE_OPTION}: invalid choice: {code_style}. {CODE_STYLE_CHOICES}"))
185        };
186    }
187    let args = match to_ktlint_args(&parsed.invocations, parsed.arguments, ktlint_version) {
188        Ok(args) => args,
189        Err(message) => return usage_error(USAGE_MAIN, message),
190    };
191    let subcommand = match parsed.subcommand {
192        None => None,
193        Some((name, tokens)) => match parse_subcommand(name, &tokens) {
194            Ok(subcommand) => Some(subcommand),
195            Err(parsed) => return parsed,
196        },
197    };
198    Parsed::Run(args, subcommand)
199}
200
201fn to_ktlint_args(invocations: &[Invocation], arguments: Vec<String>, ktlint_version: KtlintVersion) -> Result<KtlintArgs, String> {
202    let mut args = KtlintArgs { arguments, ktlint_version, ..KtlintArgs::default() };
203    let last = |name: &str| invocations.iter().rev().find(|i| i.name == name).and_then(|i| i.value.clone());
204    let all = |name: &'static str| invocations.iter().filter(move |i| i.name == name).filter_map(|i| i.value.clone());
205    let has = |name: &str| invocations.iter().any(|i| i.name == name);
206    args.color = has("--color");
207    if let Some(color_name) = last("--color-name") {
208        args.color_name = color_name;
209    }
210    args.format = has("--format");
211    args.ignore_autocorrect_failures = has("--ignore-autocorrect-failures");
212    if let Some(limit) = last("--limit") {
213        let limit: i32 =
214            limit.parse().map_err(|_| format!("invalid value for --limit: {limit} is not a valid integer"))?;
215        if limit <= 0 {
216            return Err("invalid value for --limit: Value must be bigger than 0".to_owned());
217        }
218        args.limit = limit as usize;
219    }
220    args.relative = has("--relative");
221    args.reporter_configurations = all("--reporter").collect();
222    if let Some(rulesets) = last("--ruleset") {
223        args.ruleset_jar_paths = rulesets.split(',').map(str::to_owned).collect();
224    }
225    args.stdin = has("--stdin");
226    args.stdin_path = last("--stdin-path");
227    args.patterns_from_stdin = last("--patterns-from-stdin");
228    args.editor_config_path = last("--editorconfig");
229    args.force_lint_after_format = has("--force-lint-after-format");
230    if let Some(baseline) = last("--baseline") {
231        args.baseline_path = baseline;
232    }
233    if let Some(level) = last("--log-level") {
234        args.min_log_level = Level::parse(&level).map_err(|e| format!("invalid value for --log-level: {e}"))?;
235    }
236    args.gradle_events = last(gradle::EVENTS_OPTION);
237    args.relative_to = last(gradle::RELATIVE_TO_OPTION);
238    args.editor_config_overrides = all(gradle::EDITOR_CONFIG_OVERRIDE_OPTION).collect();
239    Ok(args)
240}
241
242fn parse_subcommand(name: &'static str, tokens: &[String]) -> Result<Subcommand, Parsed> {
243    let usage = format!("Usage: ktlint {name} [<options>]");
244    let (options, help): (&'static [OptionSpec], &str) = match name {
245        GENERATE_EDITOR_CONFIG => (&GENERATE_EDITOR_CONFIG_OPTIONS, HELP_GENERATE_EDITOR_CONFIG),
246        INSTALL_GIT_PRE_COMMIT_HOOK => (&HELP_ONLY_OPTIONS, HELP_PRE_COMMIT),
247        _ => (&HELP_ONLY_OPTIONS, HELP_PRE_PUSH),
248    };
249    let parsed = parse_tokens(tokens, options, &[]).map_err(|message| {
250        if has_token(tokens, &["--help", "-h"]) { Parsed::Message(help.to_owned()) } else { usage_error(&usage, message) }
251    })?;
252    if parsed.invocations.iter().any(|i| i.name == "--help") {
253        return Err(Parsed::Message(help.to_owned()));
254    }
255    if let Some(argument) = parsed.arguments.first() {
256        return Err(usage_error(&usage, format!("got unexpected extra argument ({argument})")));
257    }
258    match name {
259        GENERATE_EDITOR_CONFIG => {
260            let code_style = parsed.invocations.iter().rev().find_map(|i| i.value.clone());
261            let code_style = code_style.ok_or_else(|| usage_error(&usage, "missing option --code-style".to_owned()))?;
262            code_style_value(&code_style).map(Subcommand::GenerateEditorConfig).ok_or_else(|| {
263                usage_error(&usage, format!("invalid value for {CODE_STYLE_OPTION}: invalid choice: {code_style}. {CODE_STYLE_CHOICES}"))
264            })
265        }
266        INSTALL_GIT_PRE_COMMIT_HOOK => Ok(Subcommand::InstallGitPreCommitHook),
267        _ => Ok(Subcommand::InstallGitPrePushHook),
268    }
269}
270
271/// Clikt's hint when an unknown option without suggestions is one of a subcommand's.
272fn with_subcommand_hint(message: String) -> String {
273    match message.strip_prefix("no such option ") {
274        Some(name) if GENERATE_EDITOR_CONFIG_OPTIONS.iter().any(|o| o.names.contains(&name) && !o.names.contains(&"--help")) => {
275            format!("{message}. hint: {GENERATE_EDITOR_CONFIG} has an option {name}")
276        }
277        _ => message,
278    }
279}
280
281/// Clikt's `enum<CodeStyleValue>()`: the names, ignoring case.
282fn code_style_value(value: &str) -> Option<CodeStyleValue> {
283    CodeStyleValue::ENTRIES.iter().copied().find(|c| c.name().eq_ignore_ascii_case(value))
284}
285
286fn version(ktlint_version: KtlintVersion) -> Parsed {
287    Parsed::Message(format!("ktlint version {}\n", release(ktlint_version)))
288}
289
290fn has_token(tokens: &[String], names: &[&str]) -> bool {
291    tokens.iter().take_while(|t| *t != "--").any(|t| names.contains(&t.as_str()))
292}
293
294fn usage_error(usage: &str, message: String) -> Parsed {
295    Parsed::UsageError { usage: usage.to_owned(), message }
296}