Skip to content

Commit 678b0c2

Browse files
authored
[red-knot] Resolve Options to Settings (#16000)
## Summary This PR generalize the idea that we may want to emit diagnostics for invalid or incompatible configuration values similar to how we already do it for `rules`. This PR introduces a new `Settings` struct that is similar to `Options` but, unlike `Options`, are fields have their default values filled in and they use a representation optimized for reads. The diagnostics created during loading the `Settings` are stored on the `Project` so that we can emit them when calling `check`. The motivation for this work is that it simplifies adding new settings. That's also why I went ahead and added the `terminal.error-on-warning` setting to demonstrate how new settings are added. ## Test Plan Existing tests, new CLI test.
1 parent 524cf6e commit 678b0c2

13 files changed

Lines changed: 214 additions & 62 deletions

File tree

crates/red_knot/src/args.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use crate::logging::Verbosity;
22
use crate::python_version::PythonVersion;
33
use clap::{ArgAction, ArgMatches, Error, Parser};
4-
use red_knot_project::metadata::options::{EnvironmentOptions, Options};
4+
use red_knot_project::metadata::options::{EnvironmentOptions, Options, TerminalOptions};
55
use red_knot_project::metadata::value::{RangedValue, RelativePathBuf};
66
use red_knot_python_semantic::lint;
77
use ruff_db::system::SystemPathBuf;
@@ -67,8 +67,8 @@ pub(crate) struct CheckCommand {
6767
pub(crate) rules: RulesArg,
6868

6969
/// Use exit code 1 if there are any warning-level diagnostics.
70-
#[arg(long, conflicts_with = "exit_zero")]
71-
pub(crate) error_on_warning: bool,
70+
#[arg(long, conflicts_with = "exit_zero", default_missing_value = "true", num_args=0..1)]
71+
pub(crate) error_on_warning: Option<bool>,
7272

7373
/// Always use exit code 0, even when there are error-level diagnostics.
7474
#[arg(long)]
@@ -107,6 +107,9 @@ impl CheckCommand {
107107
}),
108108
..EnvironmentOptions::default()
109109
}),
110+
terminal: Some(TerminalOptions {
111+
error_on_warning: self.error_on_warning,
112+
}),
110113
rules,
111114
..Default::default()
112115
}

crates/red_knot/src/main.rs

Lines changed: 11 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ use clap::Parser;
1111
use colored::Colorize;
1212
use crossbeam::channel as crossbeam_channel;
1313
use red_knot_project::metadata::options::Options;
14-
use red_knot_project::watch;
1514
use red_knot_project::watch::ProjectWatcher;
15+
use red_knot_project::{watch, Db};
1616
use red_knot_project::{ProjectDatabase, ProjectMetadata};
1717
use red_knot_server::run_server;
1818
use ruff_db::diagnostic::{Diagnostic, Severity};
@@ -97,19 +97,14 @@ fn run_check(args: CheckCommand) -> anyhow::Result<ExitStatus> {
9797
let system = OsSystem::new(cwd);
9898
let watch = args.watch;
9999
let exit_zero = args.exit_zero;
100-
let min_error_severity = if args.error_on_warning {
101-
Severity::Warning
102-
} else {
103-
Severity::Error
104-
};
105100

106101
let cli_options = args.into_options();
107102
let mut workspace_metadata = ProjectMetadata::discover(system.current_directory(), &system)?;
108103
workspace_metadata.apply_cli_options(cli_options.clone());
109104

110105
let mut db = ProjectDatabase::new(workspace_metadata, system)?;
111106

112-
let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options, min_error_severity);
107+
let (main_loop, main_loop_cancellation_token) = MainLoop::new(cli_options);
113108

114109
// Listen to Ctrl+C and abort the watch mode.
115110
let main_loop_cancellation_token = Mutex::new(Some(main_loop_cancellation_token));
@@ -167,18 +162,10 @@ struct MainLoop {
167162
watcher: Option<ProjectWatcher>,
168163

169164
cli_options: Options,
170-
171-
/// The minimum severity to consider an error when deciding the exit status.
172-
///
173-
/// TODO(micha): Get from the terminal settings.
174-
min_error_severity: Severity,
175165
}
176166

177167
impl MainLoop {
178-
fn new(
179-
cli_options: Options,
180-
min_error_severity: Severity,
181-
) -> (Self, MainLoopCancellationToken) {
168+
fn new(cli_options: Options) -> (Self, MainLoopCancellationToken) {
182169
let (sender, receiver) = crossbeam_channel::bounded(10);
183170

184171
(
@@ -187,7 +174,6 @@ impl MainLoop {
187174
receiver,
188175
watcher: None,
189176
cli_options,
190-
min_error_severity,
191177
},
192178
MainLoopCancellationToken { sender },
193179
)
@@ -245,9 +231,16 @@ impl MainLoop {
245231
result,
246232
revision: check_revision,
247233
} => {
234+
let min_error_severity =
235+
if db.project().settings(db).terminal().error_on_warning {
236+
Severity::Warning
237+
} else {
238+
Severity::Error
239+
};
240+
248241
let failed = result
249242
.iter()
250-
.any(|diagnostic| diagnostic.severity() >= self.min_error_severity);
243+
.any(|diagnostic| diagnostic.severity() >= min_error_severity);
251244

252245
if check_revision == revision {
253246
#[allow(clippy::print_stdout)]

crates/red_knot/tests/cli.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,37 @@ fn exit_code_no_errors_but_error_on_warning_is_true() -> anyhow::Result<()> {
575575
Ok(())
576576
}
577577

578+
#[test]
579+
fn exit_code_no_errors_but_error_on_warning_is_enabled_in_configuration() -> anyhow::Result<()> {
580+
let case = TestCase::with_files([
581+
("test.py", r"print(x) # [unresolved-reference]"),
582+
(
583+
"knot.toml",
584+
r#"
585+
[terminal]
586+
error-on-warning = true
587+
"#,
588+
),
589+
])?;
590+
591+
assert_cmd_snapshot!(case.command(), @r###"
592+
success: false
593+
exit_code: 1
594+
----- stdout -----
595+
warning: lint:unresolved-reference
596+
--> <temp_dir>/test.py:1:7
597+
|
598+
1 | print(x) # [unresolved-reference]
599+
| - Name `x` used when not defined
600+
|
601+
602+
603+
----- stderr -----
604+
"###);
605+
606+
Ok(())
607+
}
608+
578609
#[test]
579610
fn exit_code_both_warnings_and_errors() -> anyhow::Result<()> {
580611
let case = TestCase::with_file(

crates/red_knot_project/src/db.rs

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ impl SemanticDb for ProjectDatabase {
114114
project.is_file_open(self, file)
115115
}
116116

117-
fn rule_selection(&self) -> &RuleSelection {
118-
self.project().rule_selection(self)
117+
fn rule_selection(&self) -> Arc<RuleSelection> {
118+
self.project().rules(self)
119119
}
120120

121121
fn lint_registry(&self) -> &LintRegistry {
@@ -186,7 +186,6 @@ pub(crate) mod tests {
186186
files: Files,
187187
system: TestSystem,
188188
vendored: VendoredFileSystem,
189-
rule_selection: RuleSelection,
190189
project: Option<Project>,
191190
}
192191

@@ -198,7 +197,6 @@ pub(crate) mod tests {
198197
vendored: red_knot_vendored::file_system().clone(),
199198
files: Files::default(),
200199
events: Arc::default(),
201-
rule_selection: RuleSelection::from_registry(&DEFAULT_LINT_REGISTRY),
202200
project: None,
203201
};
204202

@@ -270,8 +268,8 @@ pub(crate) mod tests {
270268
!file.path(self).is_vendored_path()
271269
}
272270

273-
fn rule_selection(&self) -> &RuleSelection {
274-
&self.rule_selection
271+
fn rule_selection(&self) -> Arc<RuleSelection> {
272+
self.project().rules(self)
275273
}
276274

277275
fn lint_registry(&self) -> &LintRegistry {

crates/red_knot_project/src/lib.rs

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
use crate::metadata::options::OptionDiagnostic;
44
pub use db::{Db, ProjectDatabase};
55
use files::{Index, Indexed, IndexedFiles};
6+
use metadata::settings::Settings;
67
pub use metadata::{ProjectDiscoveryError, ProjectMetadata};
78
use red_knot_python_semantic::lint::{LintRegistry, LintRegistryBuilder, RuleSelection};
89
use red_knot_python_semantic::register_lints;
@@ -66,12 +67,22 @@ pub struct Project {
6667
/// The metadata describing the project, including the unresolved options.
6768
#[return_ref]
6869
pub metadata: ProjectMetadata,
70+
71+
/// The resolved project settings.
72+
#[return_ref]
73+
pub settings: Settings,
74+
75+
/// Diagnostics that were generated when resolving the project settings.
76+
#[return_ref]
77+
settings_diagnostics: Vec<OptionDiagnostic>,
6978
}
7079

7180
#[salsa::tracked]
7281
impl Project {
7382
pub fn from_metadata(db: &dyn Db, metadata: ProjectMetadata) -> Self {
74-
Project::builder(metadata)
83+
let (settings, settings_diagnostics) = metadata.options().to_settings(db);
84+
85+
Project::builder(metadata, settings, settings_diagnostics)
7586
.durability(Durability::MEDIUM)
7687
.open_fileset_durability(Durability::LOW)
7788
.file_set_durability(Durability::LOW)
@@ -86,30 +97,37 @@ impl Project {
8697
self.metadata(db).name()
8798
}
8899

100+
/// Returns the resolved linter rules for the project.
101+
///
102+
/// This is a salsa query to prevent re-computing queries if other, unrelated
103+
/// settings change. For example, we don't want that changing the terminal settings
104+
/// invalidates any type checking queries.
105+
#[salsa::tracked]
106+
pub fn rules(self, db: &dyn Db) -> Arc<RuleSelection> {
107+
self.settings(db).to_rules()
108+
}
109+
89110
pub fn reload(self, db: &mut dyn Db, metadata: ProjectMetadata) {
90111
tracing::debug!("Reloading project");
91112
assert_eq!(self.root(db), metadata.root());
92113

93114
if &metadata != self.metadata(db) {
115+
let (settings, settings_diagnostics) = metadata.options().to_settings(db);
116+
117+
if self.settings(db) != &settings {
118+
self.set_settings(db).to(settings);
119+
}
120+
121+
if self.settings_diagnostics(db) != &settings_diagnostics {
122+
self.set_settings_diagnostics(db).to(settings_diagnostics);
123+
}
124+
94125
self.set_metadata(db).to(metadata);
95126
}
96127

97128
self.reload_files(db);
98129
}
99130

100-
pub fn rule_selection(self, db: &dyn Db) -> &RuleSelection {
101-
let (selection, _) = self.rule_selection_with_diagnostics(db);
102-
selection
103-
}
104-
105-
#[salsa::tracked(return_ref)]
106-
fn rule_selection_with_diagnostics(
107-
self,
108-
db: &dyn Db,
109-
) -> (RuleSelection, Vec<OptionDiagnostic>) {
110-
self.metadata(db).options().to_rule_selection(db)
111-
}
112-
113131
/// Checks all open files in the project and its dependencies.
114132
pub(crate) fn check(self, db: &ProjectDatabase) -> Vec<Box<dyn Diagnostic>> {
115133
let project_span = tracing::debug_span!("Project::check");
@@ -118,8 +136,7 @@ impl Project {
118136
tracing::debug!("Checking project '{name}'", name = self.name(db));
119137

120138
let mut diagnostics: Vec<Box<dyn Diagnostic>> = Vec::new();
121-
let (_, options_diagnostics) = self.rule_selection_with_diagnostics(db);
122-
diagnostics.extend(options_diagnostics.iter().map(|diagnostic| {
139+
diagnostics.extend(self.settings_diagnostics(db).iter().map(|diagnostic| {
123140
let diagnostic: Box<dyn Diagnostic> = Box::new(diagnostic.clone());
124141
diagnostic
125142
}));
@@ -151,9 +168,8 @@ impl Project {
151168
}
152169

153170
pub(crate) fn check_file(self, db: &dyn Db, file: File) -> Vec<Box<dyn Diagnostic>> {
154-
let (_, options_diagnostics) = self.rule_selection_with_diagnostics(db);
155-
156-
let mut file_diagnostics: Vec<_> = options_diagnostics
171+
let mut file_diagnostics: Vec<_> = self
172+
.settings_diagnostics(db)
157173
.iter()
158174
.map(|diagnostic| {
159175
let diagnostic: Box<dyn Diagnostic> = Box::new(diagnostic.clone());

crates/red_knot_project/src/metadata.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ use options::Options;
1212

1313
pub mod options;
1414
pub mod pyproject;
15+
pub mod settings;
1516
pub mod value;
1617

1718
#[derive(Debug, PartialEq, Eq)]

crates/red_knot_project/src/metadata/options.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use std::borrow::Cow;
1515
use std::fmt::Debug;
1616
use thiserror::Error;
1717

18+
use super::settings::{Settings, TerminalSettings};
19+
1820
/// The options for the project.
1921
#[derive(Debug, Default, Clone, PartialEq, Eq, Combine, Serialize, Deserialize)]
2022
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
@@ -30,6 +32,9 @@ pub struct Options {
3032
/// Configures the enabled lints and their severity.
3133
#[serde(skip_serializing_if = "Option::is_none")]
3234
pub rules: Option<Rules>,
35+
36+
#[serde(skip_serializing_if = "Option::is_none")]
37+
pub terminal: Option<TerminalOptions>,
3338
}
3439

3540
impl Options {
@@ -110,7 +115,22 @@ impl Options {
110115
}
111116

112117
#[must_use]
113-
pub(crate) fn to_rule_selection(&self, db: &dyn Db) -> (RuleSelection, Vec<OptionDiagnostic>) {
118+
pub(crate) fn to_settings(&self, db: &dyn Db) -> (Settings, Vec<OptionDiagnostic>) {
119+
let (rules, diagnostics) = self.to_rule_selection(db);
120+
121+
let mut settings = Settings::new(rules);
122+
123+
if let Some(terminal) = self.terminal.as_ref() {
124+
settings.set_terminal(TerminalSettings {
125+
error_on_warning: terminal.error_on_warning.unwrap_or_default(),
126+
});
127+
}
128+
129+
(settings, diagnostics)
130+
}
131+
132+
#[must_use]
133+
fn to_rule_selection(&self, db: &dyn Db) -> (RuleSelection, Vec<OptionDiagnostic>) {
114134
let registry = db.lint_registry();
115135
let mut diagnostics = Vec::new();
116136

@@ -244,6 +264,16 @@ impl FromIterator<(RangedValue<String>, RangedValue<Level>)> for Rules {
244264
}
245265
}
246266

267+
#[derive(Debug, Default, Clone, Eq, PartialEq, Combine, Serialize, Deserialize)]
268+
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
269+
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
270+
pub struct TerminalOptions {
271+
/// Use exit code 1 if there are any warning-level diagnostics.
272+
///
273+
/// Defaults to `false`.
274+
pub error_on_warning: Option<bool>,
275+
}
276+
247277
#[cfg(feature = "schemars")]
248278
mod schema {
249279
use crate::DEFAULT_LINT_REGISTRY;

0 commit comments

Comments
 (0)