From 8d5655a7baa1ecc84a906cbfc0e2294cf173dee7 Mon Sep 17 00:00:00 2001 From: justin Date: Mon, 26 May 2025 23:00:38 -0700 Subject: [PATCH] [ty] Add --config-file CLI arg (#18083) --- crates/ruff_benchmark/benches/ty.rs | 4 +- crates/ty/Cargo.toml | 2 +- crates/ty/docs/cli.md | 4 +- crates/ty/src/args.rs | 6 + crates/ty/src/lib.rs | 31 ++-- crates/ty/tests/cli.rs | 57 ++++++++ crates/ty/tests/file_watching.rs | 132 ++++++++++++++---- crates/ty_project/src/db/changes.rs | 32 ++++- crates/ty_project/src/lib.rs | 2 +- crates/ty_project/src/metadata.rs | 49 +++++-- .../src/metadata/configuration_file.rs | 25 ++++ crates/ty_project/src/metadata/options.rs | 19 ++- 12 files changed, 300 insertions(+), 63 deletions(-) diff --git a/crates/ruff_benchmark/benches/ty.rs b/crates/ruff_benchmark/benches/ty.rs index c36e120274..fe974de25b 100644 --- a/crates/ruff_benchmark/benches/ty.rs +++ b/crates/ruff_benchmark/benches/ty.rs @@ -78,7 +78,7 @@ fn setup_tomllib_case() -> Case { let src_root = SystemPath::new("/src"); let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap(); - metadata.apply_cli_options(Options { + metadata.apply_options(Options { environment: Some(EnvironmentOptions { python_version: Some(RangedValue::cli(PythonVersion::PY312)), ..EnvironmentOptions::default() @@ -224,7 +224,7 @@ fn setup_micro_case(code: &str) -> Case { let src_root = SystemPath::new("/src"); let mut metadata = ProjectMetadata::discover(src_root, &system).unwrap(); - metadata.apply_cli_options(Options { + metadata.apply_options(Options { environment: Some(EnvironmentOptions { python_version: Some(RangedValue::cli(PythonVersion::PY312)), ..EnvironmentOptions::default() diff --git a/crates/ty/Cargo.toml b/crates/ty/Cargo.toml index cd287501fb..cd8f7b32ea 100644 --- a/crates/ty/Cargo.toml +++ b/crates/ty/Cargo.toml @@ -22,7 +22,7 @@ ty_server = { workspace = true } anyhow = { workspace = true } argfile = { workspace = true } -clap = { workspace = true, features = ["wrap_help", "string"] } +clap = { workspace = true, features = ["wrap_help", "string", "env"] } clap_complete_command = { workspace = true } colored = { workspace = true } countme = { workspace = true, features = ["enable"] } diff --git a/crates/ty/docs/cli.md b/crates/ty/docs/cli.md index 3df1548a31..c98543117e 100644 --- a/crates/ty/docs/cli.md +++ b/crates/ty/docs/cli.md @@ -47,7 +47,9 @@ ty check [OPTIONS] [PATH]... overriding a specific configuration option.

Overrides of individual settings using this option always take precedence over all configuration files.

-
--error rule

Treat the given rule as having severity 'error'. Can be specified multiple times.

+
--config-file path

The path to a ty.toml file to use for configuration.

+

While ty configuration can be included in a pyproject.toml file, it is not allowed in this context.

+

May also be set with the TY_CONFIG_FILE environment variable.

--error rule

Treat the given rule as having severity 'error'. Can be specified multiple times.

--error-on-warning

Use exit code 1 if there are any warning-level diagnostics

--exit-zero

Always use exit code 0, even when there are error-level diagnostics

--extra-search-path path

Additional path to use as a module-resolution source (can be passed multiple times)

diff --git a/crates/ty/src/args.rs b/crates/ty/src/args.rs index 9a48b310ec..d7f49f79f8 100644 --- a/crates/ty/src/args.rs +++ b/crates/ty/src/args.rs @@ -107,6 +107,12 @@ pub(crate) struct CheckCommand { #[clap(flatten)] pub(crate) config: ConfigsArg, + /// The path to a `ty.toml` file to use for configuration. + /// + /// While ty configuration can be included in a `pyproject.toml` file, it is not allowed in this context. + #[arg(long, env = "TY_CONFIG_FILE", value_name = "PATH")] + pub(crate) config_file: Option, + /// The format to use for printing diagnostic messages. #[arg(long)] pub(crate) output_format: Option, diff --git a/crates/ty/src/lib.rs b/crates/ty/src/lib.rs index ec66b60ca5..91efddde60 100644 --- a/crates/ty/src/lib.rs +++ b/crates/ty/src/lib.rs @@ -23,7 +23,7 @@ use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, Severity}; use ruff_db::max_parallelism; use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf}; use salsa::plumbing::ZalsaDatabase; -use ty_project::metadata::options::Options; +use ty_project::metadata::options::ProjectOptionsOverrides; use ty_project::watch::ProjectWatcher; use ty_project::{Db, DummyReporter, Reporter, watch}; use ty_project::{ProjectDatabase, ProjectMetadata}; @@ -102,13 +102,21 @@ fn run_check(args: CheckCommand) -> anyhow::Result { .map(|path| SystemPath::absolute(path, &cwd)) .collect(); - let system = OsSystem::new(cwd); + let system = OsSystem::new(&cwd); let watch = args.watch; let exit_zero = args.exit_zero; + let config_file = args + .config_file + .as_ref() + .map(|path| SystemPath::absolute(path, &cwd)); - let cli_options = args.into_options(); - let mut project_metadata = ProjectMetadata::discover(&project_path, &system)?; - project_metadata.apply_cli_options(cli_options.clone()); + let mut project_metadata = match &config_file { + Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), &system)?, + None => ProjectMetadata::discover(&project_path, &system)?, + }; + + let options = args.into_options(); + project_metadata.apply_options(options.clone()); project_metadata.apply_configuration_files(&system)?; let mut db = ProjectDatabase::new(project_metadata, system)?; @@ -117,7 +125,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result { db.project().set_included_paths(&mut db, check_paths); } - let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options); + let project_options_overrides = ProjectOptionsOverrides::new(config_file, options); + let (main_loop, main_loop_cancellation_token) = MainLoop::new(project_options_overrides); // Listen to Ctrl+C and abort the watch mode. let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token)); @@ -178,11 +187,13 @@ struct MainLoop { /// The file system watcher, if running in watch mode. watcher: Option, - cli_options: Options, + project_options_overrides: ProjectOptionsOverrides, } impl MainLoop { - fn new(cli_options: Options) -> (Self, MainLoopCancellationToken) { + fn new( + project_options_overrides: ProjectOptionsOverrides, + ) -> (Self, MainLoopCancellationToken) { let (sender, receiver) = crossbeam_channel::bounded(10); ( @@ -190,7 +201,7 @@ impl MainLoop { sender: sender.clone(), receiver, watcher: None, - cli_options, + project_options_overrides, }, MainLoopCancellationToken { sender }, ) @@ -340,7 +351,7 @@ impl MainLoop { MainLoopMessage::ApplyChanges(changes) => { revision += 1; // Automatically cancels any pending queries and waits for them to complete. - db.apply_changes(changes, Some(&self.cli_options)); + db.apply_changes(changes, Some(&self.project_options_overrides)); if let Some(watcher) = self.watcher.as_mut() { watcher.update(db); } diff --git a/crates/ty/tests/cli.rs b/crates/ty/tests/cli.rs index 344c41a9ff..a2892e4f71 100644 --- a/crates/ty/tests/cli.rs +++ b/crates/ty/tests/cli.rs @@ -1700,6 +1700,63 @@ fn check_conda_prefix_var_to_resolve_path() -> anyhow::Result<()> { ----- stderr ----- WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. "); + + Ok(()) +} + +#[test] +fn config_file_override() -> anyhow::Result<()> { + // Set `error-on-warning` to true in the configuration file + // Explicitly set `--warn unresolved-reference` to ensure the rule warns instead of errors + let case = TestCase::with_files(vec![ + ("test.py", r"print(x) # [unresolved-reference]"), + ( + "ty-override.toml", + r#" + [terminal] + error-on-warning = true + "#, + ), + ])?; + + // Ensure flag works via CLI arg + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config-file").arg("ty-override.toml"), @r" + success: false + exit_code: 1 + ----- stdout ----- + warning[unresolved-reference]: Name `x` used when not defined + --> test.py:1:7 + | + 1 | print(x) # [unresolved-reference] + | ^ + | + info: rule `unresolved-reference` was selected on the command line + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "); + + // Ensure the flag works via an environment variable + assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").env("TY_CONFIG_FILE", "ty-override.toml"), @r" + success: false + exit_code: 1 + ----- stdout ----- + warning[unresolved-reference]: Name `x` used when not defined + --> test.py:1:7 + | + 1 | print(x) # [unresolved-reference] + | ^ + | + info: rule `unresolved-reference` was selected on the command line + + Found 1 diagnostic + + ----- stderr ----- + WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors. + "); + Ok(()) } diff --git a/crates/ty/tests/file_watching.rs b/crates/ty/tests/file_watching.rs index 848117bf51..71c50ff6eb 100644 --- a/crates/ty/tests/file_watching.rs +++ b/crates/ty/tests/file_watching.rs @@ -10,7 +10,7 @@ use ruff_db::system::{ }; use ruff_db::{Db as _, Upcast}; use ruff_python_ast::PythonVersion; -use ty_project::metadata::options::{EnvironmentOptions, Options}; +use ty_project::metadata::options::{EnvironmentOptions, Options, ProjectOptionsOverrides}; use ty_project::metadata::pyproject::{PyProject, Tool}; use ty_project::metadata::value::{RangedValue, RelativePathBuf}; use ty_project::watch::{ChangeEvent, ProjectWatcher, directory_watcher}; @@ -164,8 +164,12 @@ impl TestCase { Ok(all_events) } - fn apply_changes(&mut self, changes: Vec) { - self.db.apply_changes(changes, None); + fn apply_changes( + &mut self, + changes: Vec, + project_options_overrides: Option<&ProjectOptionsOverrides>, + ) { + self.db.apply_changes(changes, project_options_overrides); } fn update_options(&mut self, options: Options) -> anyhow::Result<()> { @@ -180,7 +184,7 @@ impl TestCase { .context("Failed to write configuration")?; let changes = self.take_watch_changes(event_for_file("pyproject.toml")); - self.apply_changes(changes); + self.apply_changes(changes, None); if let Some(watcher) = &mut self.watcher { watcher.update(&self.db); @@ -476,7 +480,7 @@ fn new_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); let foo = case.system_file(&foo_path).expect("foo.py to exist."); @@ -499,7 +503,7 @@ fn new_ignored_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(case.system_file(&foo_path).is_ok()); case.assert_indexed_project_files([bar_file]); @@ -535,7 +539,7 @@ fn new_non_project_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("black.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(case.system_file(&black_path).is_ok()); @@ -576,7 +580,7 @@ fn new_files_with_explicit_included_paths() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("test2.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); let sub_a_file = case.system_file(&sub_a_path).expect("sub/a.py to exist"); @@ -621,7 +625,7 @@ fn new_file_in_included_out_of_project_directory() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("script2.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); let src_a_file = case.system_file(&src_a).unwrap(); let outside_b_file = case.system_file(&outside_b_path).unwrap(); @@ -648,7 +652,7 @@ fn changed_file() -> anyhow::Result<()> { assert!(!changes.is_empty()); - case.apply_changes(changes); + case.apply_changes(changes, None); assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')"); case.assert_indexed_project_files([foo]); @@ -671,7 +675,7 @@ fn deleted_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(!foo.exists(case.db())); case.assert_indexed_project_files([]); @@ -703,7 +707,7 @@ fn move_file_to_trash() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(!foo.exists(case.db())); case.assert_indexed_project_files([]); @@ -730,7 +734,7 @@ fn move_file_to_project() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); let foo_in_project = case.system_file(&foo_in_project)?; @@ -755,7 +759,7 @@ fn rename_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("bar.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(!foo.exists(case.db())); @@ -796,7 +800,7 @@ fn directory_moved_to_project() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("sub")); - case.apply_changes(changes); + case.apply_changes(changes, None); let init_file = case .system_file(sub_new_path.join("__init__.py")) @@ -853,7 +857,7 @@ fn directory_moved_to_trash() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("sub")); - case.apply_changes(changes); + case.apply_changes(changes, None); // `import sub.a` should no longer resolve assert!( @@ -916,7 +920,7 @@ fn directory_renamed() -> anyhow::Result<()> { // Linux and windows only emit an event for the newly created root directory, but not for every new component. let changes = case.stop_watch(event_for_file("sub")); - case.apply_changes(changes); + case.apply_changes(changes, None); // `import sub.a` should no longer resolve assert!( @@ -989,7 +993,7 @@ fn directory_deleted() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("sub")); - case.apply_changes(changes); + case.apply_changes(changes, None); // `import sub.a` should no longer resolve assert!( @@ -1035,7 +1039,7 @@ fn search_path() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("a.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some()); case.assert_indexed_project_files([case.system_file(case.project_path("bar.py")).unwrap()]); @@ -1066,7 +1070,7 @@ fn add_search_path() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("a.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(resolve_module(case.db().upcast(), &ModuleName::new_static("a").unwrap()).is_some()); @@ -1213,7 +1217,7 @@ fn changed_versions_file() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("VERSIONS")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert!(resolve_module(case.db(), &ModuleName::new("os").unwrap()).is_some()); @@ -1267,7 +1271,7 @@ fn hard_links_in_project() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert_eq!(source_text(case.db(), foo).as_str(), "print('Version 2')"); @@ -1338,7 +1342,7 @@ fn hard_links_to_target_outside_project() -> anyhow::Result<()> { let changes = case.stop_watch(ChangeEvent::is_changed); - case.apply_changes(changes); + case.apply_changes(changes, None); assert_eq!(source_text(case.db(), bar).as_str(), "print('Version 2')"); @@ -1377,7 +1381,7 @@ mod unix { let changes = case.stop_watch(event_for_file("foo.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert_eq!( foo.permissions(case.db()), @@ -1460,7 +1464,7 @@ mod unix { let changes = case.take_watch_changes(event_for_file("baz.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert_eq!( source_text(case.db(), baz_file).as_str(), @@ -1473,7 +1477,7 @@ mod unix { let changes = case.stop_watch(event_for_file("baz.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert_eq!( source_text(case.db(), baz_file).as_str(), @@ -1544,7 +1548,7 @@ mod unix { let changes = case.stop_watch(event_for_file("baz.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); // The file watcher is guaranteed to emit one event for the changed file, but it isn't specified // if the event is emitted for the "original" or linked path because both paths are watched. @@ -1658,7 +1662,7 @@ mod unix { let changes = case.stop_watch(event_for_file("baz.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); assert_eq!( source_text(case.db(), baz_original_file).as_str(), @@ -1715,7 +1719,7 @@ fn nested_projects_delete_root() -> anyhow::Result<()> { let changes = case.stop_watch(ChangeEvent::is_deleted); - case.apply_changes(changes); + case.apply_changes(changes, None); // It should now pick up the outer project. assert_eq!(case.db().project().root(case.db()), case.root_path()); @@ -1781,7 +1785,73 @@ fn changes_to_user_configuration() -> anyhow::Result<()> { let changes = case.stop_watch(event_for_file("ty.toml")); - case.apply_changes(changes); + case.apply_changes(changes, None); + + let diagnostics = case.db().check_file(foo); + + assert!( + diagnostics.len() == 1, + "Expected exactly one diagnostic but got: {diagnostics:#?}" + ); + + Ok(()) +} + +#[test] +fn changes_to_config_file_override() -> anyhow::Result<()> { + let mut case = setup(|context: &mut SetupContext| { + std::fs::write( + context.join_project_path("pyproject.toml").as_std_path(), + r#" + [project] + name = "test" + "#, + )?; + + std::fs::write( + context.join_project_path("foo.py").as_std_path(), + "a = 10 / 0", + )?; + + std::fs::write( + context.join_project_path("ty-override.toml").as_std_path(), + r#" + [rules] + division-by-zero = "ignore" + "#, + )?; + + Ok(()) + })?; + + let foo = case + .system_file(case.project_path("foo.py")) + .expect("foo.py to exist"); + let diagnostics = case.db().check_file(foo); + + assert!( + diagnostics.is_empty(), + "Expected no diagnostics but got: {diagnostics:#?}" + ); + + // Enable division-by-zero in the explicitly specified configuration with warning severity + update_file( + case.project_path("ty-override.toml"), + r#" + [rules] + division-by-zero = "warn" + "#, + )?; + + let changes = case.stop_watch(event_for_file("ty-override.toml")); + + case.apply_changes( + changes, + Some(&ProjectOptionsOverrides::new( + Some(case.project_path("ty-override.toml")), + Options::default(), + )), + ); let diagnostics = case.db().check_file(foo); @@ -1855,7 +1925,7 @@ fn rename_files_casing_only() -> anyhow::Result<()> { } let changes = case.stop_watch(event_for_file("Lib.py")); - case.apply_changes(changes); + case.apply_changes(changes, None); // Resolving `lib` should now fail but `Lib` should now succeed assert_eq!( diff --git a/crates/ty_project/src/db/changes.rs b/crates/ty_project/src/db/changes.rs index cf36a1946a..f2f72ae140 100644 --- a/crates/ty_project/src/db/changes.rs +++ b/crates/ty_project/src/db/changes.rs @@ -1,5 +1,5 @@ use crate::db::{Db, ProjectDatabase}; -use crate::metadata::options::Options; +use crate::metadata::options::ProjectOptionsOverrides; use crate::watch::{ChangeEvent, CreatedKind, DeletedKind}; use crate::{Project, ProjectMetadata}; use std::collections::BTreeSet; @@ -12,10 +12,18 @@ use rustc_hash::FxHashSet; use ty_python_semantic::Program; impl ProjectDatabase { - #[tracing::instrument(level = "debug", skip(self, changes, cli_options))] - pub fn apply_changes(&mut self, changes: Vec, cli_options: Option<&Options>) { + #[tracing::instrument(level = "debug", skip(self, changes, project_options_overrides))] + pub fn apply_changes( + &mut self, + changes: Vec, + project_options_overrides: Option<&ProjectOptionsOverrides>, + ) { let mut project = self.project(); let project_root = project.root(self).to_path_buf(); + let config_file_override = + project_options_overrides.and_then(|options| options.config_file_override.clone()); + let options = + project_options_overrides.map(|project_options| project_options.options.clone()); let program = Program::get(self); let custom_stdlib_versions_path = program .custom_stdlib_search_path(self) @@ -42,6 +50,14 @@ impl ProjectDatabase { tracing::trace!("Handle change: {:?}", change); if let Some(path) = change.system_path() { + if let Some(config_file) = &config_file_override { + if config_file.as_path() == path { + project_changed = true; + + continue; + } + } + if matches!( path.file_name(), Some(".gitignore" | ".ignore" | "ty.toml" | "pyproject.toml") @@ -170,10 +186,14 @@ impl ProjectDatabase { } if project_changed { - match ProjectMetadata::discover(&project_root, self.system()) { + let new_project_metadata = match config_file_override { + Some(config_file) => ProjectMetadata::from_config_file(config_file, self.system()), + None => ProjectMetadata::discover(&project_root, self.system()), + }; + match new_project_metadata { Ok(mut metadata) => { - if let Some(cli_options) = cli_options { - metadata.apply_cli_options(cli_options.clone()); + if let Some(cli_options) = options { + metadata.apply_options(cli_options); } if let Err(error) = metadata.apply_configuration_files(self.system()) { diff --git a/crates/ty_project/src/lib.rs b/crates/ty_project/src/lib.rs index 2c79edf9de..6c7b95c256 100644 --- a/crates/ty_project/src/lib.rs +++ b/crates/ty_project/src/lib.rs @@ -5,7 +5,7 @@ use crate::walk::{ProjectFilesFilter, ProjectFilesWalker}; pub use db::{Db, ProjectDatabase}; use files::{Index, Indexed, IndexedFiles}; use metadata::settings::Settings; -pub use metadata::{ProjectDiscoveryError, ProjectMetadata}; +pub use metadata::{ProjectMetadata, ProjectMetadataError}; use ruff_db::diagnostic::{ Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, create_parse_diagnostic, create_unsupported_syntax_diagnostic, diff --git a/crates/ty_project/src/metadata.rs b/crates/ty_project/src/metadata.rs index 2d866114ca..47896e5031 100644 --- a/crates/ty_project/src/metadata.rs +++ b/crates/ty_project/src/metadata.rs @@ -48,6 +48,29 @@ impl ProjectMetadata { } } + pub fn from_config_file( + path: SystemPathBuf, + system: &dyn System, + ) -> Result { + tracing::debug!("Using overridden configuration file at '{path}'"); + + let config_file = ConfigurationFile::from_path(path.clone(), system).map_err(|error| { + ProjectMetadataError::ConfigurationFileError { + source: Box::new(error), + path: path.clone(), + } + })?; + + let options = config_file.into_options(); + + Ok(Self { + name: Name::new(system.current_directory().file_name().unwrap_or("root")), + root: system.current_directory().to_path_buf(), + options, + extra_configuration_paths: vec![path], + }) + } + /// Loads a project from a `pyproject.toml` file. pub(crate) fn from_pyproject( pyproject: PyProject, @@ -106,11 +129,11 @@ impl ProjectMetadata { pub fn discover( path: &SystemPath, system: &dyn System, - ) -> Result { + ) -> Result { tracing::debug!("Searching for a project in '{path}'"); if !system.is_directory(path) { - return Err(ProjectDiscoveryError::NotADirectory(path.to_path_buf())); + return Err(ProjectMetadataError::NotADirectory(path.to_path_buf())); } let mut closest_project: Option = None; @@ -125,7 +148,7 @@ impl ProjectMetadata { ) { Ok(pyproject) => Some(pyproject), Err(error) => { - return Err(ProjectDiscoveryError::InvalidPyProject { + return Err(ProjectMetadataError::InvalidPyProject { path: pyproject_path, source: Box::new(error), }); @@ -144,7 +167,7 @@ impl ProjectMetadata { ) { Ok(options) => options, Err(error) => { - return Err(ProjectDiscoveryError::InvalidTyToml { + return Err(ProjectMetadataError::InvalidTyToml { path: ty_toml_path, source: Box::new(error), }); @@ -171,7 +194,7 @@ impl ProjectMetadata { .and_then(|pyproject| pyproject.project.as_ref()), ) .map_err(|err| { - ProjectDiscoveryError::InvalidRequiresPythonConstraint { + ProjectMetadataError::InvalidRequiresPythonConstraint { source: err, path: pyproject_path, } @@ -185,7 +208,7 @@ impl ProjectMetadata { let metadata = ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf()) .map_err( - |err| ProjectDiscoveryError::InvalidRequiresPythonConstraint { + |err| ProjectMetadataError::InvalidRequiresPythonConstraint { source: err, path: pyproject_path, }, @@ -249,7 +272,7 @@ impl ProjectMetadata { } /// Combine the project options with the CLI options where the CLI options take precedence. - pub fn apply_cli_options(&mut self, options: Options) { + pub fn apply_options(&mut self, options: Options) { self.options = options.combine(std::mem::take(&mut self.options)); } @@ -282,7 +305,7 @@ impl ProjectMetadata { } #[derive(Debug, Error)] -pub enum ProjectDiscoveryError { +pub enum ProjectMetadataError { #[error("project path '{0}' is not a directory")] NotADirectory(SystemPathBuf), @@ -303,6 +326,12 @@ pub enum ProjectDiscoveryError { source: ResolveRequiresPythonError, path: SystemPathBuf, }, + + #[error("Error loading configuration file at {path}: {source}")] + ConfigurationFileError { + source: Box, + path: SystemPathBuf, + }, } #[cfg(test)] @@ -314,7 +343,7 @@ mod tests { use ruff_db::system::{SystemPathBuf, TestSystem}; use ruff_python_ast::PythonVersion; - use crate::{ProjectDiscoveryError, ProjectMetadata}; + use crate::{ProjectMetadata, ProjectMetadataError}; #[test] fn project_without_pyproject() -> anyhow::Result<()> { @@ -1076,7 +1105,7 @@ expected `.`, `]` } #[track_caller] - fn assert_error_eq(error: &ProjectDiscoveryError, message: &str) { + fn assert_error_eq(error: &ProjectMetadataError, message: &str) { assert_eq!(error.to_string().replace('\\', "/"), message); } diff --git a/crates/ty_project/src/metadata/configuration_file.rs b/crates/ty_project/src/metadata/configuration_file.rs index 4190d2cd8b..ad985cdff5 100644 --- a/crates/ty_project/src/metadata/configuration_file.rs +++ b/crates/ty_project/src/metadata/configuration_file.rs @@ -14,6 +14,25 @@ pub(crate) struct ConfigurationFile { } impl ConfigurationFile { + pub(crate) fn from_path( + path: SystemPathBuf, + system: &dyn System, + ) -> Result { + let ty_toml_str = system.read_to_string(&path).map_err(|source| { + ConfigurationFileError::FileReadError { + source, + path: path.clone(), + } + })?; + + match Options::from_toml_str(&ty_toml_str, ValueSource::File(Arc::new(path.clone()))) { + Ok(options) => Ok(Self { path, options }), + Err(error) => Err(ConfigurationFileError::InvalidTyToml { + source: Box::new(error), + path, + }), + } + } /// Loads the user-level configuration file if it exists. /// /// Returns `None` if the file does not exist or if the concept of user-level configurations @@ -66,4 +85,10 @@ pub enum ConfigurationFileError { source: Box, path: SystemPathBuf, }, + #[error("Failed to read `{path}`: {source}")] + FileReadError { + #[source] + source: std::io::Error, + path: SystemPathBuf, + }, } diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 0581684faf..2e089d334f 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -2,7 +2,7 @@ use crate::Db; use crate::metadata::value::{RangedValue, RelativePathBuf, ValueSource, ValueSourceGuard}; use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticFormat, DiagnosticId, Severity, Span}; use ruff_db::files::system_path_to_file; -use ruff_db::system::{System, SystemPath}; +use ruff_db::system::{System, SystemPath, SystemPathBuf}; use ruff_macros::{Combine, OptionsMetadata}; use ruff_python_ast::PythonVersion; use rustc_hash::FxHashMap; @@ -575,3 +575,20 @@ impl OptionDiagnostic { } } } + +/// This is a wrapper for options that actually get loaded from configuration files +/// and the CLI, which also includes a `config_file_override` option that overrides +/// default configuration discovery with an explicitly-provided path to a configuration file +pub struct ProjectOptionsOverrides { + pub config_file_override: Option, + pub options: Options, +} + +impl ProjectOptionsOverrides { + pub fn new(config_file_override: Option, options: Options) -> Self { + Self { + config_file_override, + options, + } + } +}