diff --git a/Cargo.lock b/Cargo.lock index 9d53a24ceb388..2ec19de752dfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2104,6 +2104,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "toml 1.1.2+spec-1.1.0", + "toml_parser", "tracing", ] @@ -3434,6 +3435,7 @@ dependencies = [ "ruff_linter", "ruff_python_ast", "ruff_ranged_value", + "ruff_source_file", "ruff_workspace", "salsa", ] diff --git a/Cargo.toml b/Cargo.toml index eae906f5420e0..6f6474b834e55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -189,6 +189,7 @@ thiserror = { version = "2.0.0" } thin-vec = { version = "0.2.14" } tikv-jemallocator = { version = "0.6.0" } toml = { version = "1.0.0" } +toml_parser = { version = "1.0.0" } tracing = { version = "0.1.40" } tracing-flame = { version = "0.2.0" } tracing-indicatif = { version = "0.3.11" } diff --git a/crates/mdtest/Cargo.toml b/crates/mdtest/Cargo.toml index 14b57d3d78f51..75e1bbbcbd052 100644 --- a/crates/mdtest/Cargo.toml +++ b/crates/mdtest/Cargo.toml @@ -38,6 +38,7 @@ similar = { workspace = true } smallvec = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } +toml_parser = { workspace = true } tracing = { workspace = true } [lints] diff --git a/crates/mdtest/src/assertion.rs b/crates/mdtest/src/assertion.rs index be3318cb6901f..2c4dd279d4dbb 100644 --- a/crates/mdtest/src/assertion.rs +++ b/crates/mdtest/src/assertion.rs @@ -1,4 +1,4 @@ -//! Parse type and type-error assertions in Python comment form. +//! Parse inline diagnostic assertions from comments. //! //! Parses comments of the form `# revealed: SomeType` and `# error: 8 [rule-code] "message text"`. //! In the latter case, the `8` is a column number, and `"message text"` asserts that the full @@ -35,15 +35,21 @@ //! ``` use ruff_db::parsed::ParsedModuleRef; -use ruff_python_ast::token::Token; use ruff_python_trivia::{CommentRanges, Cursor}; use ruff_source_file::{LineIndex, OneIndexed}; use ruff_text_size::{Ranged, TextRange, TextSize}; use smallvec::SmallVec; use std::str::FromStr; +use toml_parser::lexer::TokenKind; use crate::RunOptions; +#[derive(Clone, Copy)] +pub(crate) enum AssertionSource<'a> { + Python(&'a ParsedModuleRef), + Toml, +} + /// Diagnostic assertion comments in a single embedded file. #[derive(Debug)] pub(crate) struct InlineFileAssertions<'s> { @@ -53,15 +59,49 @@ pub(crate) struct InlineFileAssertions<'s> { impl<'s> InlineFileAssertions<'s> { pub(crate) fn from_file( source: &'s str, - parsed: &ParsedModuleRef, + assertion_source: AssertionSource<'_>, file_index: &LineIndex, ) -> Self { - let mut by_line = Vec::new(); - let mut file_assertions = UnparsedAssertionsIter { - tokens: parsed.tokens().iter(), - source, + match assertion_source { + AssertionSource::Python(parsed) => Self::from_comment_ranges( + source, + parsed + .tokens() + .iter() + .filter(|token| token.kind().is_comment()) + .map(Ranged::range), + file_index, + ), + AssertionSource::Toml => Self::from_comment_ranges( + source, + toml_parser::Source::new(source) + .lex() + .filter(|token| token.kind() == TokenKind::Comment) + .map(|token| { + let span = token.span(); + TextRange::new( + TextSize::try_from(span.start()).unwrap(), + TextSize::try_from(span.end()).unwrap(), + ) + }), + file_index, + ), } - .peekable(); + } + + fn from_comment_ranges( + source: &'s str, + comment_ranges: impl Iterator, + file_index: &LineIndex, + ) -> Self { + let mut by_line = Vec::new(); + let mut file_assertions = comment_ranges + .filter_map(|range| { + let comment_text = &source[range]; + UnparsedAssertion::from_comment(comment_text) + .map(|assertion| AssertionWithRange(assertion, range)) + }) + .peekable(); while let Some(ranged_assertion) = file_assertions.next() { let mut collector = AssertionVec::new(); @@ -150,29 +190,6 @@ impl<'s> IntoIterator for InlineFileAssertions<'s> { } } -struct UnparsedAssertionsIter<'a, 's> { - source: &'s str, - tokens: std::slice::Iter<'a, Token>, -} - -impl<'s> Iterator for UnparsedAssertionsIter<'_, 's> { - type Item = AssertionWithRange<'s>; - - fn next(&mut self) -> Option { - loop { - let token = self.tokens.next()?; - if !token.kind().is_comment() { - continue; - } - - let comment_text = &self.source[token.range()]; - if let Some(assertion) = UnparsedAssertion::from_comment(comment_text) { - return Some(AssertionWithRange(assertion, token.range())); - } - } - } -} - /// An [`UnparsedAssertion`] with the [`TextRange`] of its original inline comment. #[derive(Debug)] struct AssertionWithRange<'a>(UnparsedAssertion<'a>, TextRange); @@ -530,7 +547,18 @@ mod tests { db.write_file("/src/test.py", source).unwrap(); let file = system_path_to_file(&db, "/src/test.py").unwrap(); let parsed = parsed_module(&db, file).load(&db); - InlineFileAssertions::from_file(source, &parsed, &line_index(&db, file)) + InlineFileAssertions::from_file( + source, + AssertionSource::Python(&parsed), + &line_index(&db, file), + ) + } + + fn get_toml_assertions(source: &str) -> InlineFileAssertions<'_> { + let mut db = TestDb::setup(); + db.write_file("/src/ruff.toml", source).unwrap(); + let file = system_path_to_file(&db, "/src/ruff.toml").unwrap(); + InlineFileAssertions::from_file(source, AssertionSource::Toml, &line_index(&db, file)) } fn into_vec(assertions: InlineFileAssertions<'_>) -> Vec> { @@ -581,6 +609,27 @@ mod tests { assert_eq!(format!("{assert}"), "error: "); } + #[test] + fn toml_comments() { + let source = dedent( + r##" + first = "# error: [not-a-comment]" + second = "value" # error: [rule-codes-in-selectors] + "##, + ); + let assertions = get_toml_assertions(&source); + + let [line] = &into_vec(assertions)[..] else { + panic!("expected one line"); + }; + + assert_eq!(line.line_number, OneIndexed::from_zero_indexed(2)); + let [assertion] = &line.assertions[..] else { + panic!("expected one assertion"); + }; + assert_eq!(format!("{assertion}"), "error: [rule-codes-in-selectors]"); + } + #[test] fn prior_line() { let source = dedent( diff --git a/crates/mdtest/src/lib.rs b/crates/mdtest/src/lib.rs index a76b97d51a8fe..a1d345dd39936 100644 --- a/crates/mdtest/src/lib.rs +++ b/crates/mdtest/src/lib.rs @@ -538,7 +538,15 @@ pub fn create_diagnostic_snapshot<'d, C>( writeln!(snapshot, "---").unwrap(); writeln!(snapshot).unwrap(); - writeln!(snapshot, "# Python source files").unwrap(); + let source_heading = if test + .files() + .all(|file| matches!(file.lang, "py" | "python" | "pyi" | "ipynb")) + { + "Python source files" + } else { + "Source files" + }; + writeln!(snapshot, "# {source_heading}").unwrap(); writeln!(snapshot).unwrap(); for file in test.files() { writeln!(snapshot, "## {}", file.relative_path()).unwrap(); diff --git a/crates/mdtest/src/matcher.rs b/crates/mdtest/src/matcher.rs index ff4e31a763ee0..b7fd536058eac 100644 --- a/crates/mdtest/src/matcher.rs +++ b/crates/mdtest/src/matcher.rs @@ -17,7 +17,9 @@ use ruff_source_file::{LineIndex, OneIndexed}; use smallvec::SmallVec; use crate::RunOptions; -use crate::assertion::{InlineFileAssertions, LineAssertions, ParsedAssertion, UnparsedAssertion}; +use crate::assertion::{ + AssertionSource, InlineFileAssertions, LineAssertions, ParsedAssertion, UnparsedAssertion, +}; use crate::diagnostic::SortedDiagnostics; #[derive(Debug, Default)] @@ -97,25 +99,34 @@ pub fn match_file( // Parse assertions from comments in the file, and get diagnostics from the file; both // ordered by line number. let source = source_text(db, file); - let parsed = parsed_module(db, file).load(db); let line_index = line_index(db, file); - let assertions = InlineFileAssertions::from_file(&source, &parsed, &line_index); - - // Sort diagnostics according to the line number of the starting offset of the token in which the diagnostic appears. - // - // This can be different to the line number of the starting offset of the diagnostic range! - // For example, if the diagnostic is a syntax error inside a stringized annotation, - // the syntax error's range will likely point to a sub-range of the string literal, - // which will make the error unmatchable by mdtest unless we look at the token in which - // the diagnostic occurs (the string-literal) and use the token start as the basis for - // the line number. - let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| { - let token_start = parsed - .tokens() - .token_range(diagnostic_range.start()) - .start(); - line_index.line_index(token_start) - }); + let (assertions, diagnostics) = if file.path(db).extension() == Some("toml") { + let assertions = + InlineFileAssertions::from_file(source.as_str(), AssertionSource::Toml, &line_index); + let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| { + line_index.line_index(diagnostic_range.start()) + }); + (assertions, diagnostics) + } else { + let parsed = parsed_module(db, file).load(db); + let assertions = InlineFileAssertions::from_file( + source.as_str(), + AssertionSource::Python(&parsed), + &line_index, + ); + + // Sort diagnostics according to the line number of the starting offset of the token in + // which the diagnostic appears. This can differ from the line containing the start of the + // diagnostic range, for example for syntax errors inside stringized annotations. + let diagnostics = SortedDiagnostics::new(diagnostics, &|diagnostic_range| { + let token_start = parsed + .tokens() + .token_range(diagnostic_range.start()) + .start(); + line_index.line_index(token_start) + }); + (assertions, diagnostics) + }; let mut line_diagnostics = diagnostics.iter_lines(); diff --git a/crates/mdtest/src/parser.rs b/crates/mdtest/src/parser.rs index a9b9fff9e4e49..28c4438c8bf45 100644 --- a/crates/mdtest/src/parser.rs +++ b/crates/mdtest/src/parser.rs @@ -373,14 +373,13 @@ impl EmbeddedFilePath<'_> { /// A single file embedded in a [`Section`] as a fenced code block. /// /// Currently must be a Python file (`py` language), a type stub (`pyi`), a Jupyter notebook -/// (`ipynb`) or a [typeshed `VERSIONS`] file. +/// (`ipynb`), an explicitly named TOML file, or a [typeshed `VERSIONS`] file. /// -/// TOML configuration blocks are also supported, but are not stored as `EmbeddedFile`s. In the -/// future we plan to support `pth` files as well. +/// Unnamed TOML blocks configure the test and are not stored as `EmbeddedFile`s. In the future we +/// plan to support `pth` files as well. /// -/// A Python embedded file makes its containing [`Section`] into a [`MarkdownTest`], and will be -/// type-checked and searched for inline-comment assertions to match against the diagnostics from -/// type checking. +/// A checkable embedded file makes its containing [`Section`] into a [`MarkdownTest`] and is +/// searched for inline-comment assertions to match against diagnostics. /// /// [typeshed `VERSIONS`]: https://github.com/python/typeshed/blob/c546278aae47de0b2b664973da4edb613400f6ce/stdlib/VERSIONS#L1-L18 #[derive(Debug)] @@ -390,7 +389,7 @@ pub struct EmbeddedFile<'s> { pub lang: &'s str, pub code: Cow<'s, str>, /// The checkable code blocks - pub python_code_blocks: Vec>, + pub code_blocks: Vec>, } impl EmbeddedFile<'_> { @@ -406,7 +405,7 @@ impl EmbeddedFile<'_> { let start_offset = existing_code.text_len(); existing_code.push_str(new_code); - self.python_code_blocks.push(CodeBlock { + self.code_blocks.push(CodeBlock { backticks: backtick_offsets, embedded_start_offset: start_offset, inline_snapshot_block: None, @@ -430,7 +429,7 @@ impl EmbeddedFile<'_> { } pub(crate) fn is_checkable(&self) -> bool { - matches!(self.lang, "py" | "python" | "pyi" | "ipynb") + matches!(self.lang, "py" | "python" | "pyi" | "ipynb" | "toml") } } @@ -808,7 +807,7 @@ where let section = self.stack.top(); let test_name = self.sections[section].title; - if lang == "toml" { + if lang == "toml" && self.explicit_path.is_none() { return self.process_config_block(code); } @@ -879,7 +878,7 @@ where section, lang, code: Cow::Borrowed(code), - python_code_blocks: vec![CodeBlock { + code_blocks: vec![CodeBlock { backticks: backtick_offsets, embedded_start_offset: TextSize::new(0), inline_snapshot_block: None, @@ -919,7 +918,7 @@ where fn current_section_has_merged_snippets(&self) -> bool { self.current_section_files .values() - .any(|id| self.files[*id].python_code_blocks.len() > 1) + .any(|id| self.files[*id].code_blocks.len() > 1) } fn process_config_block(&mut self, code: &str) -> anyhow::Result<()> { @@ -948,7 +947,7 @@ where let backtick_start = line_number(offsets.start(), self.source); bail!( - "`snapshot` code block on line {backtick_start} must follow a Python code block, but section has no files." + "`snapshot` code block on line {backtick_start} must follow a checkable code block, but section has no files." ); }; @@ -958,12 +957,12 @@ where let backtick_start = line_number(offsets.start(), self.source); bail!( - "`snapshot` code block on line {backtick_start} must follow a `python` code block in the same section but it follows a `{}` block.", + "`snapshot` code block on line {backtick_start} must follow a checkable code block in the same section but it follows a `{}` block.", file.lang ); } - let code_block = file.python_code_blocks.last_mut().unwrap(); + let code_block = file.code_blocks.last_mut().unwrap(); if let Some(existing_block) = &code_block.inline_snapshot_block { let code_block_start = line_number(code_block.embedded_start_offset(), self.source); @@ -971,7 +970,7 @@ where let existing_start = line_number(existing_block.range.start(), self.source); bail!( - "Python code block on line `{code_block_start}` has more than one `snapshot` block: first on line {existing_start} and another on line {backtick_start}.", + "Code block on line `{code_block_start}` has more than one `snapshot` block: first on line {existing_start} and another on line {backtick_start}.", ); } @@ -1243,6 +1242,32 @@ mod tests { assert_eq!(file.code, "{}"); } + #[test] + fn explicitly_named_toml_file() { + let source = dedent( + r#" + `ruff.toml`: + + ```toml + lint.select = ["F401"] + ``` + "#, + ); + let mf = parse("file.md", &source).unwrap(); + + let [test] = &mf.tests().collect::>()[..] else { + panic!("expected one test"); + }; + + let [file] = test.files().collect::>()[..] else { + panic!("expected one file"); + }; + + assert_eq!(file.path, EmbeddedFilePath::Explicit("ruff.toml")); + assert_eq!(file.lang, "toml"); + assert_eq!(file.code, r#"lint.select = ["F401"]"#); + } + #[test] fn multiple_tests() { let source = dedent( diff --git a/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md b/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md new file mode 100644 index 0000000000000..47fcd4206bdb0 --- /dev/null +++ b/crates/ruff_linter/resources/mdtest/ruff/invalid-pyproject-toml.md @@ -0,0 +1,22 @@ +# `invalid-pyproject-toml` (`RUF200`) + +```toml +[lint] +select = ["RUF200"] +``` + +`pyproject.toml`: + +```toml +[project] +name = 1 # snapshot: invalid-pyproject-toml +``` + +```snapshot +error[RUF200]: Failed to parse pyproject.toml: invalid type: integer `1`, expected a string + --> src/pyproject.toml:2:8 + | +2 | name = 1 # snapshot: invalid-pyproject-toml + | ^ + | +``` diff --git a/crates/ruff_mdtest/Cargo.toml b/crates/ruff_mdtest/Cargo.toml index 9209374fc5a3f..cbf24ad06a733 100644 --- a/crates/ruff_mdtest/Cargo.toml +++ b/crates/ruff_mdtest/Cargo.toml @@ -20,6 +20,7 @@ ruff_db = { workspace = true, features = ["os", "testing"] } ruff_linter = { workspace = true, features = ["testing"] } ruff_python_ast = { workspace = true } ruff_ranged_value = { workspace = true } +ruff_source_file = { workspace = true } ruff_workspace = { workspace = true } anyhow = { workspace = true } diff --git a/crates/ruff_mdtest/src/lib.rs b/crates/ruff_mdtest/src/lib.rs index 6f874962b3f5e..1fbe0b1512638 100644 --- a/crates/ruff_mdtest/src/lib.rs +++ b/crates/ruff_mdtest/src/lib.rs @@ -10,9 +10,12 @@ use ruff_db::diagnostic::{Annotation, Diagnostic, Span}; use ruff_db::files::{File, system_path_to_file}; use ruff_db::source::source_text; use ruff_db::system::{DbWithWritableSystem as _, SystemPathBuf}; +use ruff_linter::pyproject_toml::lint_pyproject_toml; use ruff_linter::source_kind::SourceKind; use ruff_linter::test::test_contents; +use ruff_python_ast::SourceType; use ruff_ranged_value::{ValueSource, ValueSourceGuard}; +use ruff_source_file::SourceFileBuilder; use ruff_workspace::configuration::Configuration; use ruff_workspace::options::Options; @@ -68,8 +71,8 @@ fn run_test( } assert!( - matches!(embedded.lang, "py" | "pyi" | "python" | "ipynb"), - "Supported file types are: py (or python), pyi, ipynb, and ignore" + matches!(embedded.lang, "py" | "pyi" | "python" | "ipynb" | "toml"), + "Supported file types are: py (or python), pyi, ipynb, toml, and ignore" ); let full_path = embedded.full_path(&project_root); @@ -80,7 +83,7 @@ fn run_test( Some(TestFile { file, - code_blocks: embedded.python_code_blocks.clone(), + code_blocks: embedded.code_blocks.clone(), }) }) .collect(); @@ -107,20 +110,31 @@ fn run_test( let mdtest_result = attempt_test( |file| { let source = source_text(db, file); - let source_kind = if let Some(notebook) = source.as_notebook() { - SourceKind::ipy_notebook(notebook.clone()) - } else { - SourceKind::Python { - code: source.as_str().to_string(), - is_stub: file.is_stub(db), - } - }; let path = file .path(db) .as_system_path() .expect("mdtest files are on the system") .as_std_path(); - test_contents(&source_kind, path, &settings.linter).0 + match SourceType::from(path) { + SourceType::Python(_) => { + let source_kind = if let Some(notebook) = source.as_notebook() { + SourceKind::ipy_notebook(notebook.clone()) + } else { + SourceKind::Python { + code: source.as_str().to_string(), + is_stub: file.is_stub(db), + } + }; + test_contents(&source_kind, path, &settings.linter).0 + } + SourceType::Toml(source_type) if source_type.is_pyproject() => { + let source_file = + SourceFileBuilder::new(path.to_string_lossy(), source.as_str()) + .finish(); + lint_pyproject_toml(&source_file, &settings.linter) + } + SourceType::Toml(_) | SourceType::Markdown => Vec::new(), + } }, test_file, ); diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index a5a60304fa0af..d8d06f6eefc0b 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -230,7 +230,7 @@ fn run_test( Some(TestFile { file, - code_blocks: embedded.python_code_blocks.clone(), + code_blocks: embedded.code_blocks.clone(), }) }) .collect();