Skip to content

Commit c02d164

Browse files
Check required-version before parsing rules (#22410)
Co-authored-by: Micha Reiser <[email protected]>
1 parent 88aa3f8 commit c02d164

File tree

5 files changed

+163
-29
lines changed

5 files changed

+163
-29
lines changed

crates/ruff/tests/cli/lint.rs

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1126,6 +1126,35 @@ import os
11261126
Ok(())
11271127
}
11281128

1129+
#[test]
1130+
fn required_version_fails_to_parse() -> Result<()> {
1131+
let fixture = CliTest::with_file(
1132+
"ruff.toml",
1133+
r#"
1134+
required-version = "pikachu"
1135+
"#,
1136+
)?;
1137+
assert_cmd_snapshot!(fixture
1138+
.check_command(), @r#"
1139+
success: false
1140+
exit_code: 2
1141+
----- stdout -----
1142+
1143+
----- stderr -----
1144+
ruff failed
1145+
Cause: Failed to load configuration `[TMP]/ruff.toml`
1146+
Cause: Failed to parse [TMP]/ruff.toml
1147+
Cause: TOML parse error at line 2, column 20
1148+
|
1149+
2 | required-version = "pikachu"
1150+
| ^^^^^^^^^
1151+
Failed to parse version: Unexpected end of version specifier, expected operator:
1152+
pikachu
1153+
^^^^^^^
1154+
"#);
1155+
Ok(())
1156+
}
1157+
11291158
#[test]
11301159
fn required_version_exact_mismatch() -> Result<()> {
11311160
let version = env!("CARGO_PKG_VERSION");
@@ -1137,10 +1166,10 @@ required-version = "0.1.0"
11371166
"#,
11381167
)?;
11391168

1140-
insta::with_settings!({
1141-
filters => vec![(version, "[VERSION]")]
1142-
}, {
1143-
assert_cmd_snapshot!(fixture
1169+
let mut settings = insta::Settings::clone_current();
1170+
settings.add_filter(version, "[VERSION]");
1171+
settings.bind(|| {
1172+
assert_cmd_snapshot!(fixture
11441173
.check_command()
11451174
.arg("--config")
11461175
.arg("ruff.toml")
@@ -1154,6 +1183,7 @@ import os
11541183
11551184
----- stderr -----
11561185
ruff failed
1186+
Cause: Failed to load configuration `[TMP]/ruff.toml`
11571187
Cause: Required version `==0.1.0` does not match the running version `[VERSION]`
11581188
");
11591189
});
@@ -1212,10 +1242,10 @@ required-version = ">{version}"
12121242
),
12131243
)?;
12141244

1215-
insta::with_settings!({
1216-
filters => vec![(version, "[VERSION]")]
1217-
}, {
1218-
assert_cmd_snapshot!(fixture
1245+
let mut settings = insta::Settings::clone_current();
1246+
settings.add_filter(version, "[VERSION]");
1247+
settings.bind(|| {
1248+
assert_cmd_snapshot!(fixture
12191249
.check_command()
12201250
.arg("--config")
12211251
.arg("ruff.toml")
@@ -1229,6 +1259,48 @@ import os
12291259
12301260
----- stderr -----
12311261
ruff failed
1262+
Cause: Failed to load configuration `[TMP]/ruff.toml`
1263+
Cause: Required version `>[VERSION]` does not match the running version `[VERSION]`
1264+
");
1265+
});
1266+
1267+
Ok(())
1268+
}
1269+
1270+
#[test]
1271+
fn required_version_precedes_rule_validation() -> Result<()> {
1272+
let version = env!("CARGO_PKG_VERSION");
1273+
1274+
let fixture = CliTest::with_file(
1275+
"ruff.toml",
1276+
&format!(
1277+
r#"
1278+
required-version = ">{version}"
1279+
1280+
[lint]
1281+
select = ["RUF999"]
1282+
"#
1283+
),
1284+
)?;
1285+
1286+
let mut settings = insta::Settings::clone_current();
1287+
settings.add_filter(version, "[VERSION]");
1288+
settings.bind(|| {
1289+
assert_cmd_snapshot!(fixture
1290+
.check_command()
1291+
.arg("--config")
1292+
.arg("ruff.toml")
1293+
.arg("-")
1294+
.pass_stdin(r#"
1295+
import os
1296+
"#), @"
1297+
success: false
1298+
exit_code: 2
1299+
----- stdout -----
1300+
1301+
----- stderr -----
1302+
ruff failed
1303+
Cause: Failed to load configuration `[TMP]/ruff.toml`
12321304
Cause: Required version `>[VERSION]` does not match the running version `[VERSION]`
12331305
");
12341306
});

crates/ruff_linter/src/settings/types.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -607,13 +607,21 @@ impl TryFrom<String> for RequiredVersion {
607607
type Error = pep440_rs::VersionSpecifiersParseError;
608608

609609
fn try_from(value: String) -> Result<Self, Self::Error> {
610+
value.parse()
611+
}
612+
}
613+
614+
impl FromStr for RequiredVersion {
615+
type Err = pep440_rs::VersionSpecifiersParseError;
616+
617+
fn from_str(value: &str) -> Result<Self, Self::Err> {
610618
// Treat `0.3.1` as `==0.3.1`, for backwards compatibility.
611-
if let Ok(version) = pep440_rs::Version::from_str(&value) {
619+
if let Ok(version) = pep440_rs::Version::from_str(value) {
612620
Ok(Self(VersionSpecifiers::from(
613621
VersionSpecifier::equals_version(version),
614622
)))
615623
} else {
616-
Ok(Self(VersionSpecifiers::from_str(&value)?))
624+
Ok(Self(VersionSpecifiers::from_str(value)?))
617625
}
618626
}
619627
}

crates/ruff_workspace/src/configuration.rs

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ use std::collections::BTreeMap;
77
use std::env::VarError;
88
use std::num::{NonZeroU8, NonZeroU16};
99
use std::path::{Path, PathBuf};
10-
use std::str::FromStr;
1110

1211
use anyhow::{Context, Result, anyhow};
1312
use glob::{GlobError, Paths, PatternError, glob};
@@ -36,8 +35,7 @@ use ruff_linter::settings::{
3635
DEFAULT_SELECTORS, DUMMY_VARIABLE_RGX, LinterSettings, TASK_TAGS, TargetVersion,
3736
};
3837
use ruff_linter::{
39-
RUFF_PKG_VERSION, RuleSelector, fs, warn_user_once, warn_user_once_by_id,
40-
warn_user_once_by_message,
38+
RuleSelector, fs, warn_user_once, warn_user_once_by_id, warn_user_once_by_message,
4139
};
4240
use ruff_python_ast as ast;
4341
use ruff_python_formatter::{
@@ -53,6 +51,7 @@ use crate::options::{
5351
Flake8UnusedArgumentsOptions, FormatOptions, IsortOptions, LintCommonOptions, LintOptions,
5452
McCabeOptions, Options, Pep8NamingOptions, PyUpgradeOptions, PycodestyleOptions,
5553
PydoclintOptions, PydocstyleOptions, PyflakesOptions, PylintOptions, RuffOptions,
54+
validate_required_version,
5655
};
5756
use crate::settings::{
5857
EXCLUDE, FileResolverSettings, FormatterSettings, INCLUDE, INCLUDE_PREVIEW, LineEnding,
@@ -155,13 +154,7 @@ pub struct Configuration {
155154
impl Configuration {
156155
pub fn into_settings(self, project_root: &Path) -> Result<Settings> {
157156
if let Some(required_version) = &self.required_version {
158-
let ruff_pkg_version = pep440_rs::Version::from_str(RUFF_PKG_VERSION)
159-
.expect("RUFF_PKG_VERSION is not a valid PEP 440 version specifier");
160-
if !required_version.contains(&ruff_pkg_version) {
161-
return Err(anyhow!(
162-
"Required version `{required_version}` does not match the running version `{RUFF_PKG_VERSION}`"
163-
));
164-
}
157+
validate_required_version(required_version)?;
165158
}
166159

167160
let linter_target_version = TargetVersion(self.target_version);

crates/ruff_workspace/src/options.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,19 @@
1+
use anyhow::Result;
12
use regex::Regex;
23
use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet};
34
use serde::de::{self};
45
use serde::{Deserialize, Deserializer, Serialize};
56
use std::collections::{BTreeMap, BTreeSet};
67
use std::path::PathBuf;
8+
use std::str::FromStr;
79
use strum::IntoEnumIterator;
810
use unicode_normalization::UnicodeNormalization;
911

1012
use crate::settings::LineEnding;
1113
use ruff_formatter::IndentStyle;
1214
use ruff_graph::Direction;
15+
use ruff_linter::RUFF_PKG_VERSION;
16+
1317
use ruff_linter::line_width::{IndentWidth, LineLength};
1418
use ruff_linter::rules::flake8_import_conventions::settings::BannedAliases;
1519
use ruff_linter::rules::flake8_pytest_style::settings::SettingsError;
@@ -556,6 +560,17 @@ pub struct LintOptions {
556560
pub future_annotations: Option<bool>,
557561
}
558562

563+
pub fn validate_required_version(required_version: &RequiredVersion) -> anyhow::Result<()> {
564+
let ruff_pkg_version = pep440_rs::Version::from_str(RUFF_PKG_VERSION)
565+
.expect("RUFF_PKG_VERSION is not a valid PEP 440 version specifier");
566+
if !required_version.contains(&ruff_pkg_version) {
567+
return Err(anyhow::anyhow!(
568+
"Required version `{required_version}` does not match the running version `{RUFF_PKG_VERSION}`"
569+
));
570+
}
571+
Ok(())
572+
}
573+
559574
/// Newtype wrapper for [`LintCommonOptions`] that allows customizing the JSON schema and omitting the fields from the [`OptionsMetadata`].
560575
#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
561576
#[serde(transparent)]

crates/ruff_workspace/src/pyproject.rs

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ use std::path::{Path, PathBuf};
55
use anyhow::{Context, Result};
66
use log::debug;
77
use pep440_rs::{Operator, Version, VersionSpecifiers};
8+
use serde::de::DeserializeOwned;
89
use serde::{Deserialize, Serialize};
910
use strum::IntoEnumIterator;
1011

11-
use ruff_linter::settings::types::PythonVersion;
12+
use ruff_linter::settings::types::{PythonVersion, RequiredVersion};
1213

13-
use crate::options::Options;
14+
use crate::options::{Options, validate_required_version};
1415

1516
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
1617
struct Tools {
@@ -40,20 +41,38 @@ impl Pyproject {
4041
}
4142
}
4243

43-
/// Parse a `ruff.toml` file.
44-
fn parse_ruff_toml<P: AsRef<Path>>(path: P) -> Result<Options> {
44+
fn parse_toml<P: AsRef<Path>, T: DeserializeOwned>(path: P, table_path: &[&str]) -> Result<T> {
4545
let path = path.as_ref();
4646
let contents = std::fs::read_to_string(path)
4747
.with_context(|| format!("Failed to read {}", path.display()))?;
48-
toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
48+
49+
// Parse the TOML document once into a spanned representation so we can:
50+
// - Inspect `required-version` without triggering strict deserialization errors.
51+
// - Deserialize with precise spans (line/column and excerpt) on errors.
52+
let root = toml::de::DeTable::parse(&contents)
53+
.with_context(|| format!("Failed to parse {}", path.display()))?;
54+
55+
check_required_version(root.get_ref(), table_path)?;
56+
57+
let deserializer = toml::de::Deserializer::from(root);
58+
T::deserialize(deserializer)
59+
.map_err(|mut err| {
60+
// `Deserializer::from` doesn't have access to the original input, but we do.
61+
// Attach it so TOML errors include line/column and a source excerpt.
62+
err.set_input(Some(&contents));
63+
err
64+
})
65+
.with_context(|| format!("Failed to parse {}", path.display()))
66+
}
67+
68+
/// Parse a `ruff.toml` file.
69+
fn parse_ruff_toml<P: AsRef<Path>>(path: P) -> Result<Options> {
70+
parse_toml(path, &[])
4971
}
5072

5173
/// Parse a `pyproject.toml` file.
5274
fn parse_pyproject_toml<P: AsRef<Path>>(path: P) -> Result<Pyproject> {
53-
let path = path.as_ref();
54-
let contents = std::fs::read_to_string(path)
55-
.with_context(|| format!("Failed to read {}", path.display()))?;
56-
toml::from_str(&contents).with_context(|| format!("Failed to parse {}", path.display()))
75+
parse_toml(path, &["tool", "ruff"])
5776
}
5877

5978
/// Return `true` if a `pyproject.toml` contains a `[tool.ruff]` section.
@@ -98,6 +117,33 @@ pub fn find_settings_toml<P: AsRef<Path>>(path: P) -> Result<Option<PathBuf>> {
98117
Ok(None)
99118
}
100119

120+
fn check_required_version(value: &toml::de::DeTable, table_path: &[&str]) -> Result<()> {
121+
let mut current = value;
122+
for key in table_path {
123+
let Some(next) = current.get(*key) else {
124+
return Ok(());
125+
};
126+
let toml::de::DeValue::Table(next) = next.get_ref() else {
127+
return Ok(());
128+
};
129+
current = next;
130+
}
131+
132+
let required_version = current
133+
.get("required-version")
134+
.and_then(|value| value.get_ref().as_str());
135+
136+
let Some(required_version) = required_version else {
137+
return Ok(());
138+
};
139+
140+
// If it doesn't parse, we just fall through to normal parsing; it will give a nicer error message.
141+
if let Ok(required_version) = required_version.parse::<RequiredVersion>() {
142+
validate_required_version(&required_version)?;
143+
}
144+
Ok(())
145+
}
146+
101147
/// Derive target version from `required-version` in `pyproject.toml`, if
102148
/// such a file exists in an ancestor directory.
103149
pub fn find_fallback_target_version<P: AsRef<Path>>(path: P) -> Option<PythonVersion> {

0 commit comments

Comments
 (0)