diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index 8c67ca60e0..1d42c7a78a 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -1,9 +1,11 @@ use crate::logging::Verbosity; use crate::python_version::PythonVersion; +use clap::error::ErrorKind; use clap::{ArgAction, ArgMatches, Error, Parser}; use ruff_db::system::SystemPathBuf; +use ty_project::combine::Combine; use ty_project::metadata::options::{EnvironmentOptions, Options, TerminalOptions}; -use ty_project::metadata::value::{RangedValue, RelativePathBuf}; +use ty_project::metadata::value::{RangedValue, RelativePathBuf, ValueSource}; use ty_python_semantic::lint; #[derive(Debug, Parser)] @@ -14,6 +16,7 @@ pub(crate) struct Args { pub(crate) command: Command, } +#[allow(clippy::large_enum_variant)] #[derive(Debug, clap::Subcommand)] pub(crate) enum Command { /// Check a project for type errors. @@ -86,6 +89,9 @@ pub(crate) struct CheckCommand { #[clap(flatten)] pub(crate) rules: RulesArg, + #[clap(flatten)] + pub(crate) config: ConfigsArg, + /// The format to use for printing diagnostic messages. #[arg(long)] pub(crate) output_format: Option, @@ -140,7 +146,7 @@ impl CheckCommand { .no_respect_ignore_files .then_some(false) .or(self.respect_ignore_files); - Options { + let options = Options { environment: Some(EnvironmentOptions { python_version: self .python_version @@ -166,7 +172,9 @@ impl CheckCommand { rules, respect_ignore_files, ..Default::default() - } + }; + // Merge with options passed in via --config + options.combine(self.config.into_options().unwrap_or_default()) } } @@ -299,3 +307,55 @@ pub(crate) enum TerminalColor { /// Never display colors. Never, } +/// A TOML ` = ` pair +/// (such as you might find in a `ty.toml` configuration file) +/// overriding a specific configuration option. +/// Overrides of individual settings using this option always take precedence +/// over all configuration files. +#[derive(Debug, Clone)] +pub(crate) struct ConfigsArg(Option); + +impl clap::FromArgMatches for ConfigsArg { + fn from_arg_matches(matches: &ArgMatches) -> Result { + let combined = matches + .get_many::("config") + .into_iter() + .flatten() + .map(|s| { + Options::from_toml_str(s, ValueSource::Cli) + .map_err(|err| Error::raw(ErrorKind::InvalidValue, err.to_string())) + }) + .collect::, _>>()? + .into_iter() + .reduce(|acc, item| item.combine(acc)); + Ok(Self(combined)) + } + + fn update_from_arg_matches(&mut self, matches: &ArgMatches) -> Result<(), Error> { + self.0 = Self::from_arg_matches(matches)?.0; + Ok(()) + } +} + +impl clap::Args for ConfigsArg { + fn augment_args(cmd: clap::Command) -> clap::Command { + cmd.arg( + clap::Arg::new("config") + .short('c') + .long("config") + .value_name("CONFIG_OPTION") + .help("A TOML ` = ` pair") + .action(ArgAction::Append), + ) + } + + fn augment_args_for_update(cmd: clap::Command) -> clap::Command { + Self::augment_args(cmd) + } +} + +impl ConfigsArg { + pub(crate) fn into_options(self) -> Option { + self.0 + } +} diff --git a/crates/ty/tests/cli.rs b/crates/ty/tests/cli.rs index 394ceac4fa..c1714ffa60 100644 --- a/crates/ty/tests/cli.rs +++ b/crates/ty/tests/cli.rs @@ -1338,6 +1338,128 @@ fn defaults_to_a_new_python_version() -> anyhow::Result<()> { Ok(()) } +#[test] +fn cli_config_args_toml_string_basic() -> anyhow::Result<()> { + let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?; + + // Long flag + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=true"), @r" + success: false + exit_code: 1 + ----- stdout ----- + warning: lint:unresolved-reference: Name `x` used when not defined + --> test.py:1:7 + | + 1 | print(x) # [unresolved-reference] + | ^ + | + info: `lint:unresolved-reference` was selected on the command line + + Found 1 diagnostic + + ----- stderr ----- + "); + + // Short flag + assert_cmd_snapshot!(case.command().arg("-c").arg("terminal.error-on-warning=true"), @r" + success: false + exit_code: 1 + ----- stdout ----- + error: lint:unresolved-reference: Name `x` used when not defined + --> test.py:1:7 + | + 1 | print(x) # [unresolved-reference] + | ^ + | + info: `lint:unresolved-reference` is enabled by default + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn cli_config_args_overrides_knot_toml() -> anyhow::Result<()> { + let case = TestCase::with_files(vec![ + ( + "knot.toml", + r#" + [terminal] + error-on-warning = true + "#, + ), + ("test.py", r"print(x) # [unresolved-reference]"), + ])?; + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=false"), @r" + success: true + exit_code: 0 + ----- stdout ----- + warning: lint:unresolved-reference: Name `x` used when not defined + --> test.py:1:7 + | + 1 | print(x) # [unresolved-reference] + | ^ + | + info: `lint:unresolved-reference` was selected on the command line + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn cli_config_args_later_overrides_earlier() -> anyhow::Result<()> { + let case = TestCase::with_file("test.py", r"print(x) # [unresolved-reference]")?; + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config").arg("terminal.error-on-warning=true").arg("--config").arg("terminal.error-on-warning=false"), @r" + success: true + exit_code: 0 + ----- stdout ----- + warning: lint:unresolved-reference: Name `x` used when not defined + --> test.py:1:7 + | + 1 | print(x) # [unresolved-reference] + | ^ + | + info: `lint:unresolved-reference` was selected on the command line + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn cli_config_args_invalid_option() -> anyhow::Result<()> { + let case = TestCase::with_file("test.py", r"print(1)")?; + assert_cmd_snapshot!(case.command().arg("--config").arg("bad-option=true"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: TOML parse error at line 1, column 1 + | + 1 | bad-option=true + | ^^^^^^^^^^ + unknown field `bad-option`, expected one of `environment`, `src`, `rules`, `terminal`, `respect-ignore-files` + + + Usage: ty + + For more information, try '--help'. + "); + + Ok(()) +} + struct TestCase { _temp_dir: TempDir, _settings_scope: SettingsBindDropGuard, diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 16f0ab2287..cb25b6492a 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -38,7 +38,7 @@ pub struct Options { } impl Options { - pub(crate) fn from_toml_str(content: &str, source: ValueSource) -> Result { + pub fn from_toml_str(content: &str, source: ValueSource) -> Result { let _guard = ValueSourceGuard::new(source, true); let options = toml::from_str(content)?; Ok(options)