Skip to content

Commit 7a25e54

Browse files
committed
[ty] Add --config-file
217
1 parent 01eeb2f commit 7a25e54

File tree

9 files changed

+165
-26
lines changed

9 files changed

+165
-26
lines changed

crates/ty/docs/cli.md

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ty/src/args.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,15 @@ pub(crate) struct CheckCommand {
107107
#[clap(flatten)]
108108
pub(crate) config: ConfigsArg,
109109

110+
/// A path to a `ty.toml` configuration file
111+
///
112+
/// `pyproject.toml` files are not accepted.
113+
/// When provided, this file will be used in place of any discovered configuration (including user-level configuration).
114+
/// ty will skip project discovery and default to the current working directory.
115+
/// Paths are anchored at the current working directory.
116+
#[arg(long)]
117+
pub(crate) config_file: Option<SystemPathBuf>,
118+
110119
/// The format to use for printing diagnostic messages.
111120
#[arg(long)]
112121
pub(crate) output_format: Option<OutputFormat>,

crates/ty/src/lib.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, Severity};
2323
use ruff_db::max_parallelism;
2424
use ruff_db::system::{OsSystem, SystemPath, SystemPathBuf};
2525
use salsa::plumbing::ZalsaDatabase;
26-
use ty_project::metadata::options::Options;
26+
use ty_project::metadata::options::MetaOptions;
2727
use ty_project::watch::ProjectWatcher;
2828
use ty_project::{Db, DummyReporter, Reporter, watch};
2929
use ty_project::{ProjectDatabase, ProjectMetadata};
@@ -102,12 +102,17 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
102102
.map(|path| SystemPath::absolute(path, &cwd))
103103
.collect();
104104

105-
let system = OsSystem::new(cwd);
105+
let system = OsSystem::new(cwd.clone());
106106
let watch = args.watch;
107107
let exit_zero = args.exit_zero;
108+
let config_file = args.config_file.clone();
109+
110+
let mut project_metadata = match &config_file {
111+
Some(config_file) => ProjectMetadata::from_config_file(config_file.clone(), cwd, &system)?,
112+
None => ProjectMetadata::discover(&project_path, &system)?,
113+
};
108114

109115
let cli_options = args.into_options();
110-
let mut project_metadata = ProjectMetadata::discover(&project_path, &system)?;
111116
project_metadata.apply_cli_options(cli_options.clone());
112117
project_metadata.apply_configuration_files(&system)?;
113118

@@ -117,7 +122,8 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
117122
db.project().set_included_paths(&mut db, check_paths);
118123
}
119124

120-
let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options);
125+
let meta_options = MetaOptions::new(config_file, cli_options);
126+
let (main_loop, main_loop_cancellation_token) = MainLoop::new(meta_options);
121127

122128
// Listen to Ctrl+C and abort the watch mode.
123129
let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token));
@@ -178,19 +184,19 @@ struct MainLoop {
178184
/// The file system watcher, if running in watch mode.
179185
watcher: Option<ProjectWatcher>,
180186

181-
cli_options: Options,
187+
meta_options: MetaOptions,
182188
}
183189

184190
impl MainLoop {
185-
fn new(cli_options: Options) -> (Self, MainLoopCancellationToken) {
191+
fn new(meta_options: MetaOptions) -> (Self, MainLoopCancellationToken) {
186192
let (sender, receiver) = crossbeam_channel::bounded(10);
187193

188194
(
189195
Self {
190196
sender: sender.clone(),
191197
receiver,
192198
watcher: None,
193-
cli_options,
199+
meta_options,
194200
},
195201
MainLoopCancellationToken { sender },
196202
)
@@ -338,7 +344,7 @@ impl MainLoop {
338344
MainLoopMessage::ApplyChanges(changes) => {
339345
revision += 1;
340346
// Automatically cancels any pending queries and waits for them to complete.
341-
db.apply_changes(changes, Some(&self.cli_options));
347+
db.apply_changes(changes, Some(&self.meta_options));
342348
if let Some(watcher) = self.watcher.as_mut() {
343349
watcher.update(db);
344350
}

crates/ty/tests/cli.rs

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1454,10 +1454,10 @@ fn cli_config_args_toml_string_basic() -> anyhow::Result<()> {
14541454
}
14551455

14561456
#[test]
1457-
fn cli_config_args_overrides_knot_toml() -> anyhow::Result<()> {
1457+
fn cli_config_args_overrides_ty_toml() -> anyhow::Result<()> {
14581458
let case = TestCase::with_files(vec![
14591459
(
1460-
"knot.toml",
1460+
"ty.toml",
14611461
r#"
14621462
[terminal]
14631463
error-on-warning = true
@@ -1534,6 +1534,39 @@ fn cli_config_args_invalid_option() -> anyhow::Result<()> {
15341534
Ok(())
15351535
}
15361536

1537+
#[test]
1538+
fn config_file_override() -> anyhow::Result<()> {
1539+
let case = TestCase::with_files(vec![
1540+
("test.py", r"print(x) # [unresolved-reference]"),
1541+
(
1542+
"ty-override.toml",
1543+
r#"
1544+
[terminal]
1545+
error-on-warning = true
1546+
"#,
1547+
),
1548+
])?;
1549+
assert_cmd_snapshot!(case.command().arg("--warn").arg("unresolved-reference").arg("--config-file").arg("ty-override.toml"), @r"
1550+
success: false
1551+
exit_code: 1
1552+
----- stdout -----
1553+
warning[unresolved-reference]: Name `x` used when not defined
1554+
--> test.py:1:7
1555+
|
1556+
1 | print(x) # [unresolved-reference]
1557+
| ^
1558+
|
1559+
info: rule `unresolved-reference` was selected on the command line
1560+
1561+
Found 1 diagnostic
1562+
1563+
----- stderr -----
1564+
WARN ty is pre-release software and not ready for production use. Expect to encounter bugs, missing features, and fatal errors.
1565+
");
1566+
1567+
Ok(())
1568+
}
1569+
15371570
struct TestCase {
15381571
_temp_dir: TempDir,
15391572
_settings_scope: SettingsBindDropGuard,

crates/ty_project/src/db/changes.rs

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
use crate::db::{Db, ProjectDatabase};
2-
use crate::metadata::options::Options;
2+
use crate::metadata::options::MetaOptions;
33
use crate::watch::{ChangeEvent, CreatedKind, DeletedKind};
44
use crate::{Project, ProjectMetadata};
55
use std::collections::BTreeSet;
@@ -12,10 +12,13 @@ use rustc_hash::FxHashSet;
1212
use ty_python_semantic::Program;
1313

1414
impl ProjectDatabase {
15-
#[tracing::instrument(level = "debug", skip(self, changes, cli_options))]
16-
pub fn apply_changes(&mut self, changes: Vec<ChangeEvent>, cli_options: Option<&Options>) {
15+
#[tracing::instrument(level = "debug", skip(self, changes, meta_options))]
16+
pub fn apply_changes(&mut self, changes: Vec<ChangeEvent>, meta_options: Option<&MetaOptions>) {
1717
let mut project = self.project();
1818
let project_root = project.root(self).to_path_buf();
19+
let config_file_override =
20+
meta_options.and_then(|options| options.config_file_override.clone());
21+
let cli_options = meta_options.map(|options| options.cli_options.clone());
1922
let program = Program::get(self);
2023
let custom_stdlib_versions_path = program
2124
.custom_stdlib_search_path(self)
@@ -42,6 +45,14 @@ impl ProjectDatabase {
4245
tracing::trace!("Handle change: {:?}", change);
4346

4447
if let Some(path) = change.system_path() {
48+
if let Some(config_file) = &config_file_override {
49+
if path.eq(config_file.as_path()) {
50+
project_changed = true;
51+
52+
continue;
53+
}
54+
}
55+
4556
if matches!(
4657
path.file_name(),
4758
Some(".gitignore" | ".ignore" | "ty.toml" | "pyproject.toml")
@@ -170,10 +181,16 @@ impl ProjectDatabase {
170181
}
171182

172183
if project_changed {
173-
match ProjectMetadata::discover(&project_root, self.system()) {
184+
let new_project_metadata = match config_file_override {
185+
Some(config_file) => {
186+
ProjectMetadata::from_config_file(config_file, project_root, self.system())
187+
}
188+
None => ProjectMetadata::discover(&project_root, self.system()),
189+
};
190+
match new_project_metadata {
174191
Ok(mut metadata) => {
175192
if let Some(cli_options) = cli_options {
176-
metadata.apply_cli_options(cli_options.clone());
193+
metadata.apply_cli_options(cli_options);
177194
}
178195

179196
if let Err(error) = metadata.apply_configuration_files(self.system()) {

crates/ty_project/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::walk::{ProjectFilesFilter, ProjectFilesWalker};
55
pub use db::{Db, ProjectDatabase};
66
use files::{Index, Indexed, IndexedFiles};
77
use metadata::settings::Settings;
8-
pub use metadata::{ProjectDiscoveryError, ProjectMetadata};
8+
pub use metadata::{ProjectMetadata, ProjectMetadataError};
99
use ruff_db::diagnostic::{
1010
Annotation, Diagnostic, DiagnosticId, Severity, Span, SubDiagnostic, create_parse_diagnostic,
1111
create_unsupported_syntax_diagnostic,

crates/ty_project/src/metadata.rs

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ impl ProjectMetadata {
4848
}
4949
}
5050

51+
pub fn from_config_file(
52+
path: SystemPathBuf,
53+
root: SystemPathBuf,
54+
system: &dyn System,
55+
) -> Result<Self, ProjectMetadataError> {
56+
tracing::debug!("Using overridden configuration file at '{path}'");
57+
58+
let config_file = ConfigurationFile::from_path(path.clone(), system).map_err(|error| {
59+
ProjectMetadataError::ConfigurationFileError {
60+
source: Box::new(error),
61+
path: path.clone(),
62+
}
63+
})?;
64+
65+
let options = config_file.into_options();
66+
67+
Ok(Self {
68+
name: Name::new(root.file_name().unwrap_or("root")),
69+
root,
70+
options,
71+
extra_configuration_paths: vec![path],
72+
})
73+
}
74+
5175
/// Loads a project from a `pyproject.toml` file.
5276
pub(crate) fn from_pyproject(
5377
pyproject: PyProject,
@@ -106,11 +130,11 @@ impl ProjectMetadata {
106130
pub fn discover(
107131
path: &SystemPath,
108132
system: &dyn System,
109-
) -> Result<ProjectMetadata, ProjectDiscoveryError> {
133+
) -> Result<ProjectMetadata, ProjectMetadataError> {
110134
tracing::debug!("Searching for a project in '{path}'");
111135

112136
if !system.is_directory(path) {
113-
return Err(ProjectDiscoveryError::NotADirectory(path.to_path_buf()));
137+
return Err(ProjectMetadataError::NotADirectory(path.to_path_buf()));
114138
}
115139

116140
let mut closest_project: Option<ProjectMetadata> = None;
@@ -125,7 +149,7 @@ impl ProjectMetadata {
125149
) {
126150
Ok(pyproject) => Some(pyproject),
127151
Err(error) => {
128-
return Err(ProjectDiscoveryError::InvalidPyProject {
152+
return Err(ProjectMetadataError::InvalidPyProject {
129153
path: pyproject_path,
130154
source: Box::new(error),
131155
});
@@ -144,7 +168,7 @@ impl ProjectMetadata {
144168
) {
145169
Ok(options) => options,
146170
Err(error) => {
147-
return Err(ProjectDiscoveryError::InvalidTyToml {
171+
return Err(ProjectMetadataError::InvalidTyToml {
148172
path: ty_toml_path,
149173
source: Box::new(error),
150174
});
@@ -171,7 +195,7 @@ impl ProjectMetadata {
171195
.and_then(|pyproject| pyproject.project.as_ref()),
172196
)
173197
.map_err(|err| {
174-
ProjectDiscoveryError::InvalidRequiresPythonConstraint {
198+
ProjectMetadataError::InvalidRequiresPythonConstraint {
175199
source: err,
176200
path: pyproject_path,
177201
}
@@ -185,7 +209,7 @@ impl ProjectMetadata {
185209
let metadata =
186210
ProjectMetadata::from_pyproject(pyproject, project_root.to_path_buf())
187211
.map_err(
188-
|err| ProjectDiscoveryError::InvalidRequiresPythonConstraint {
212+
|err| ProjectMetadataError::InvalidRequiresPythonConstraint {
189213
source: err,
190214
path: pyproject_path,
191215
},
@@ -282,7 +306,7 @@ impl ProjectMetadata {
282306
}
283307

284308
#[derive(Debug, Error)]
285-
pub enum ProjectDiscoveryError {
309+
pub enum ProjectMetadataError {
286310
#[error("project path '{0}' is not a directory")]
287311
NotADirectory(SystemPathBuf),
288312

@@ -303,6 +327,12 @@ pub enum ProjectDiscoveryError {
303327
source: ResolveRequiresPythonError,
304328
path: SystemPathBuf,
305329
},
330+
331+
#[error("Error loading configuration file at {path}: {source}")]
332+
ConfigurationFileError {
333+
source: Box<ConfigurationFileError>,
334+
path: SystemPathBuf,
335+
},
306336
}
307337

308338
#[cfg(test)]
@@ -314,7 +344,7 @@ mod tests {
314344
use ruff_db::system::{SystemPathBuf, TestSystem};
315345
use ruff_python_ast::PythonVersion;
316346

317-
use crate::{ProjectDiscoveryError, ProjectMetadata};
347+
use crate::{ProjectMetadata, ProjectMetadataError};
318348

319349
#[test]
320350
fn project_without_pyproject() -> anyhow::Result<()> {
@@ -1030,7 +1060,7 @@ expected `.`, `]`
10301060
}
10311061

10321062
#[track_caller]
1033-
fn assert_error_eq(error: &ProjectDiscoveryError, message: &str) {
1063+
fn assert_error_eq(error: &ProjectMetadataError, message: &str) {
10341064
assert_eq!(error.to_string().replace('\\', "/"), message);
10351065
}
10361066

crates/ty_project/src/metadata/configuration_file.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,25 @@ pub(crate) struct ConfigurationFile {
1414
}
1515

1616
impl ConfigurationFile {
17+
pub(crate) fn from_path(
18+
path: SystemPathBuf,
19+
system: &dyn System,
20+
) -> Result<Self, ConfigurationFileError> {
21+
let ty_toml_str = system.read_to_string(&path).map_err(|source| {
22+
ConfigurationFileError::FileReadError {
23+
source,
24+
path: path.clone(),
25+
}
26+
})?;
27+
28+
match Options::from_toml_str(&ty_toml_str, ValueSource::File(Arc::new(path.clone()))) {
29+
Ok(options) => Ok(Self { path, options }),
30+
Err(error) => Err(ConfigurationFileError::InvalidTyToml {
31+
source: Box::new(error),
32+
path,
33+
}),
34+
}
35+
}
1736
/// Loads the user-level configuration file if it exists.
1837
///
1938
/// Returns `None` if the file does not exist or if the concept of user-level configurations
@@ -66,4 +85,10 @@ pub enum ConfigurationFileError {
6685
source: Box<TyTomlError>,
6786
path: SystemPathBuf,
6887
},
88+
#[error("Failed to read `{path}`: {source}")]
89+
FileReadError {
90+
#[source]
91+
source: std::io::Error,
92+
path: SystemPathBuf,
93+
},
6994
}

0 commit comments

Comments
 (0)