Skip to content

Commit 53b0733

Browse files
authored
Add TOML support to mdtest (#26802)
Summary -- This PR was split off from #26772 to add support for testing TOML lint rules to mdtest. We use the toml_parser crate to extract comments and now call `lint_pyproject_toml` in ruff_mdtest. Because we already use `toml` blocks for test configuration and because Ruff's current TOML lints need to know the filename, I opted to require a filename for linted TOML blocks. For example, ````markdown ```toml lint.select = ["F401"] ``` ```` remains a configuration block, but ````markdown `ruff.toml`: ```toml lint.select = ["F401"] ``` ```` will be linted. Test Plan -- Added one test for RUF200 and unit tests for the parsing itself.
1 parent c957c9e commit 53b0733

11 files changed

Lines changed: 215 additions & 81 deletions

File tree

Cargo.lock

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

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ thiserror = { version = "2.0.0" }
189189
thin-vec = { version = "0.2.14" }
190190
tikv-jemallocator = { version = "0.6.0" }
191191
toml = { version = "1.0.0" }
192+
toml_parser = { version = "1.0.0" }
192193
tracing = { version = "0.1.40" }
193194
tracing-flame = { version = "0.2.0" }
194195
tracing-indicatif = { version = "0.3.11" }

crates/mdtest/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ similar = { workspace = true }
3838
smallvec = { workspace = true }
3939
thiserror = { workspace = true }
4040
toml = { workspace = true }
41+
toml_parser = { workspace = true }
4142
tracing = { workspace = true }
4243

4344
[lints]

crates/mdtest/src/assertion.rs

Lines changed: 81 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
//! Parse type and type-error assertions in Python comment form.
1+
//! Parse inline diagnostic assertions from comments.
22
//!
33
//! Parses comments of the form `# revealed: SomeType` and `# error: 8 [rule-code] "message text"`.
44
//! In the latter case, the `8` is a column number, and `"message text"` asserts that the full
@@ -35,15 +35,21 @@
3535
//! ```
3636
3737
use ruff_db::parsed::ParsedModuleRef;
38-
use ruff_python_ast::token::Token;
3938
use ruff_python_trivia::{CommentRanges, Cursor};
4039
use ruff_source_file::{LineIndex, OneIndexed};
4140
use ruff_text_size::{Ranged, TextRange, TextSize};
4241
use smallvec::SmallVec;
4342
use std::str::FromStr;
43+
use toml_parser::lexer::TokenKind;
4444

4545
use crate::RunOptions;
4646

47+
#[derive(Clone, Copy)]
48+
pub(crate) enum AssertionSource<'a> {
49+
Python(&'a ParsedModuleRef),
50+
Toml,
51+
}
52+
4753
/// Diagnostic assertion comments in a single embedded file.
4854
#[derive(Debug)]
4955
pub(crate) struct InlineFileAssertions<'s> {
@@ -53,15 +59,49 @@ pub(crate) struct InlineFileAssertions<'s> {
5359
impl<'s> InlineFileAssertions<'s> {
5460
pub(crate) fn from_file(
5561
source: &'s str,
56-
parsed: &ParsedModuleRef,
62+
assertion_source: AssertionSource<'_>,
5763
file_index: &LineIndex,
5864
) -> Self {
59-
let mut by_line = Vec::new();
60-
let mut file_assertions = UnparsedAssertionsIter {
61-
tokens: parsed.tokens().iter(),
62-
source,
65+
match assertion_source {
66+
AssertionSource::Python(parsed) => Self::from_comment_ranges(
67+
source,
68+
parsed
69+
.tokens()
70+
.iter()
71+
.filter(|token| token.kind().is_comment())
72+
.map(Ranged::range),
73+
file_index,
74+
),
75+
AssertionSource::Toml => Self::from_comment_ranges(
76+
source,
77+
toml_parser::Source::new(source)
78+
.lex()
79+
.filter(|token| token.kind() == TokenKind::Comment)
80+
.map(|token| {
81+
let span = token.span();
82+
TextRange::new(
83+
TextSize::try_from(span.start()).unwrap(),
84+
TextSize::try_from(span.end()).unwrap(),
85+
)
86+
}),
87+
file_index,
88+
),
6389
}
64-
.peekable();
90+
}
91+
92+
fn from_comment_ranges(
93+
source: &'s str,
94+
comment_ranges: impl Iterator<Item = TextRange>,
95+
file_index: &LineIndex,
96+
) -> Self {
97+
let mut by_line = Vec::new();
98+
let mut file_assertions = comment_ranges
99+
.filter_map(|range| {
100+
let comment_text = &source[range];
101+
UnparsedAssertion::from_comment(comment_text)
102+
.map(|assertion| AssertionWithRange(assertion, range))
103+
})
104+
.peekable();
65105

66106
while let Some(ranged_assertion) = file_assertions.next() {
67107
let mut collector = AssertionVec::new();
@@ -150,29 +190,6 @@ impl<'s> IntoIterator for InlineFileAssertions<'s> {
150190
}
151191
}
152192

153-
struct UnparsedAssertionsIter<'a, 's> {
154-
source: &'s str,
155-
tokens: std::slice::Iter<'a, Token>,
156-
}
157-
158-
impl<'s> Iterator for UnparsedAssertionsIter<'_, 's> {
159-
type Item = AssertionWithRange<'s>;
160-
161-
fn next(&mut self) -> Option<Self::Item> {
162-
loop {
163-
let token = self.tokens.next()?;
164-
if !token.kind().is_comment() {
165-
continue;
166-
}
167-
168-
let comment_text = &self.source[token.range()];
169-
if let Some(assertion) = UnparsedAssertion::from_comment(comment_text) {
170-
return Some(AssertionWithRange(assertion, token.range()));
171-
}
172-
}
173-
}
174-
}
175-
176193
/// An [`UnparsedAssertion`] with the [`TextRange`] of its original inline comment.
177194
#[derive(Debug)]
178195
struct AssertionWithRange<'a>(UnparsedAssertion<'a>, TextRange);
@@ -530,7 +547,18 @@ mod tests {
530547
db.write_file("/src/test.py", source).unwrap();
531548
let file = system_path_to_file(&db, "/src/test.py").unwrap();
532549
let parsed = parsed_module(&db, file).load(&db);
533-
InlineFileAssertions::from_file(source, &parsed, &line_index(&db, file))
550+
InlineFileAssertions::from_file(
551+
source,
552+
AssertionSource::Python(&parsed),
553+
&line_index(&db, file),
554+
)
555+
}
556+
557+
fn get_toml_assertions(source: &str) -> InlineFileAssertions<'_> {
558+
let mut db = TestDb::setup();
559+
db.write_file("/src/ruff.toml", source).unwrap();
560+
let file = system_path_to_file(&db, "/src/ruff.toml").unwrap();
561+
InlineFileAssertions::from_file(source, AssertionSource::Toml, &line_index(&db, file))
534562
}
535563

536564
fn into_vec(assertions: InlineFileAssertions<'_>) -> Vec<LineAssertions<'_>> {
@@ -581,6 +609,27 @@ mod tests {
581609
assert_eq!(format!("{assert}"), "error: ");
582610
}
583611

612+
#[test]
613+
fn toml_comments() {
614+
let source = dedent(
615+
r##"
616+
first = "# error: [not-a-comment]"
617+
second = "value" # error: [rule-codes-in-selectors]
618+
"##,
619+
);
620+
let assertions = get_toml_assertions(&source);
621+
622+
let [line] = &into_vec(assertions)[..] else {
623+
panic!("expected one line");
624+
};
625+
626+
assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2));
627+
let [assertion] = &line.assertions[..] else {
628+
panic!("expected one assertion");
629+
};
630+
assert_eq!(format!("{assertion}"), "error: [rule-codes-in-selectors]");
631+
}
632+
584633
#[test]
585634
fn prior_line() {
586635
let source = dedent(

crates/mdtest/src/lib.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -538,7 +538,15 @@ pub fn create_diagnostic_snapshot<'d, C>(
538538
writeln!(snapshot, "---").unwrap();
539539
writeln!(snapshot).unwrap();
540540

541-
writeln!(snapshot, "# Python source files").unwrap();
541+
let source_heading = if test
542+
.files()
543+
.all(|file| matches!(file.lang, "py" | "python" | "pyi" | "ipynb"))
544+
{
545+
"Python source files"
546+
} else {
547+
"Source files"
548+
};
549+
writeln!(snapshot, "# {source_heading}").unwrap();
542550
writeln!(snapshot).unwrap();
543551
for file in test.files() {
544552
writeln!(snapshot, "## {}", file.relative_path()).unwrap();

crates/mdtest/src/matcher.rs

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ use ruff_source_file::{LineIndex, OneIndexed};
1717
use smallvec::SmallVec;
1818

1919
use crate::RunOptions;
20-
use crate::assertion::{InlineFileAssertions, LineAssertions, ParsedAssertion, UnparsedAssertion};
20+
use crate::assertion::{
21+
AssertionSource, InlineFileAssertions, LineAssertions, ParsedAssertion, UnparsedAssertion,
22+
};
2123
use crate::diagnostic::SortedDiagnostics;
2224

2325
#[derive(Debug, Default)]
@@ -97,25 +99,34 @@ pub fn match_file(
9799
// Parse assertions from comments in the file, and get diagnostics from the file; both
98100
// ordered by line number.
99101
let source = source_text(db, file);
100-
let parsed = parsed_module(db, file).load(db);
101102
let line_index = line_index(db, file);
102-
let assertions = InlineFileAssertions::from_file(&source, &parsed, &line_index);
103-
104-
// Sort diagnostics according to the line number of the starting offset of the token in which the diagnostic appears.
105-
//
106-
// This can be different to the line number of the starting offset of the diagnostic range!
107-
// For example, if the diagnostic is a syntax error inside a stringized annotation,
108-
// the syntax error's range will likely point to a sub-range of the string literal,
109-
// which will make the error unmatchable by mdtest unless we look at the token in which
110-
// the diagnostic occurs (the string-literal) and use the token start as the basis for
111-
// the line number.
112-
let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| {
113-
let token_start = parsed
114-
.tokens()
115-
.token_range(diagnostic_range.start())
116-
.start();
117-
line_index.line_index(token_start)
118-
});
103+
let (assertions, diagnostics) = if file.path(db).extension() == Some("toml") {
104+
let assertions =
105+
InlineFileAssertions::from_file(source.as_str(), AssertionSource::Toml, &line_index);
106+
let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| {
107+
line_index.line_index(diagnostic_range.start())
108+
});
109+
(assertions, diagnostics)
110+
} else {
111+
let parsed = parsed_module(db, file).load(db);
112+
let assertions = InlineFileAssertions::from_file(
113+
source.as_str(),
114+
AssertionSource::Python(&parsed),
115+
&line_index,
116+
);
117+
118+
// Sort diagnostics according to the line number of the starting offset of the token in
119+
// which the diagnostic appears. This can differ from the line containing the start of the
120+
// diagnostic range, for example for syntax errors inside stringized annotations.
121+
let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| {
122+
let token_start = parsed
123+
.tokens()
124+
.token_range(diagnostic_range.start())
125+
.start();
126+
line_index.line_index(token_start)
127+
});
128+
(assertions, diagnostics)
129+
};
119130

120131
let mut line_diagnostics = diagnostics.iter_lines();
121132

0 commit comments

Comments
 (0)