Factor out the mdtest crate (#24616)

Summary
--

This is a first step toward adding mdtests for Ruff. I actually wrote
the code
in the opposite order, first copy-pasting `ty_test` to a `ruff_test`
crate, and then
factoring out the shared code, but I figured it would be easier to
review in
this order. I also opened a stacked PR with the `ruff_test` changes
(#24617)
to show that the API works well for that too.

The main change here is moving several of the modules from `ty_test` to
a new
`mdtest` crate:
- `assertion`
- `diagnostic`
- `matcher`
- `parser`

Beyond moving these files to the new crate, I made `Matcher` functions
take a
`&dyn Db` to support passing a different concrete type from `ruff_test`,
and I
also made the parser generic over an `MdtestConfig` trait to allow Ruff
to use a
separate config struct. I also introduced new `TestConfig` and `TestDb`
types to allow
testing the `matcher` and `parser` within the `mdtest` crate without
depending
on either the real ty `Db` or `ty_test` config type.

The lib.rs file from `ty_test` was essentially split in half, with the
shared
code moved to the `mdtest` crate and the ty-specific parts kept in
`ty_test`.

Test Plan
--

All existing mdtests and the unit tests from `ty_test` should still
pass, and
the stacked branch with the `ruff_test` crate tests the split API
This commit is contained in:
Brent Westbrook
2026-04-16 10:32:24 -04:00
committed by GitHub
parent 725fbb736d
commit 08c56c83cf
12 changed files with 849 additions and 663 deletions
Generated
+31 -12
View File
@@ -2035,6 +2035,36 @@ version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3eede3bdf92f3b4f9dc04072a9ce5ab557d5ec9038773bf9ffcd5588b3cc05b"
[[package]]
name = "mdtest"
version = "0.0.0"
dependencies = [
"anyhow",
"camino",
"colored 3.1.1",
"indexmap",
"insta",
"memchr",
"path-slash",
"regex",
"ruff_db",
"ruff_diagnostics",
"ruff_index",
"ruff_python_ast",
"ruff_python_trivia",
"ruff_source_file",
"ruff_text_size",
"rustc-hash",
"rustc-stable-hash",
"salsa",
"serde",
"similar 3.1.0",
"smallvec",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"tracing",
]
[[package]]
name = "memchr"
version = "2.8.0"
@@ -4747,28 +4777,17 @@ dependencies = [
"camino",
"colored 3.1.1",
"dunce",
"indexmap",
"insta",
"memchr",
"path-slash",
"regex",
"mdtest",
"ruff_db",
"ruff_diagnostics",
"ruff_index",
"ruff_notebook",
"ruff_python_ast",
"ruff_python_trivia",
"ruff_source_file",
"ruff_text_size",
"rustc-hash",
"rustc-stable-hash",
"salsa",
"serde",
"similar 3.1.0",
"smallvec",
"tempfile",
"thiserror 2.0.18",
"toml 1.1.2+spec-1.1.0",
"tracing",
"ty_module_resolver",
"ty_python_core",
+2
View File
@@ -57,6 +57,8 @@ ty_static = { path = "crates/ty_static" }
ty_test = { path = "crates/ty_test" }
ty_vendored = { path = "crates/ty_vendored" }
mdtest = { path = "crates/mdtest" }
aho-corasick = { version = "1.1.3" }
anstream = { version = "1.0.0" }
anstyle = { version = "1.0.10" }
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "mdtest"
version = "0.0.0"
publish = false
edition.workspace = true
rust-version.workspace = true
homepage.workspace = true
documentation.workspace = true
repository.workspace = true
authors.workspace = true
license.workspace = true
[lib]
doctest = false
[dependencies]
ruff_db = { workspace = true }
ruff_diagnostics = { workspace = true }
ruff_index = { workspace = true }
ruff_python_ast = { workspace = true }
ruff_python_trivia = { workspace = true }
ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
anyhow = { workspace = true }
camino = { workspace = true }
colored = { workspace = true }
indexmap = { workspace = true }
insta = { workspace = true }
memchr = { workspace = true }
path-slash = { workspace = true }
regex = { workspace = true }
rustc-hash = { workspace = true }
rustc-stable-hash = { workspace = true }
salsa = { workspace = true }
serde = { workspace = true }
similar = { workspace = true }
smallvec = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
[lints]
workspace = true
@@ -497,30 +497,16 @@ pub(crate) enum ErrorAssertionParseError<'a> {
#[cfg(test)]
mod tests {
use super::*;
use crate::Db;
use crate::tests::TestDb;
use ruff_db::files::system_path_to_file;
use ruff_db::parsed::parsed_module;
use ruff_db::source::line_index;
use ruff_db::system::DbWithWritableSystem as _;
use ruff_db::{Db as _, files::system_path_to_file};
use ruff_python_trivia::textwrap::dedent;
use ruff_source_file::OneIndexed;
use ty_module_resolver::SearchPathSettings;
use ty_python_core::platform::PythonPlatform;
use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings};
use ty_python_semantic::PythonVersionWithSource;
fn get_assertions(source: &str) -> InlineFileAssertions<'_> {
let mut db = Db::setup();
let settings = ProgramSettings {
python_version: PythonVersionWithSource::default(),
python_platform: PythonPlatform::default(),
search_paths: SearchPathSettings::new(Vec::new())
.to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
.unwrap(),
};
Program::init_or_update(&mut db, settings);
let mut db = TestDb::setup();
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);
@@ -140,7 +140,6 @@ struct DiagnosticWithLine<'a> {
#[cfg(test)]
mod tests {
use crate::db::Db;
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span};
use ruff_db::files::system_path_to_file;
use ruff_db::source::line_index;
@@ -148,9 +147,11 @@ mod tests {
use ruff_source_file::OneIndexed;
use ruff_text_size::{TextRange, TextSize};
use crate::tests::TestDb;
#[test]
fn sort_and_group() {
let mut db = Db::setup();
let mut db = TestDb::setup();
db.write_file("/src/test.py", "one\ntwo\n").unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
let lines = line_index(&db, file);
+522
View File
@@ -0,0 +1,522 @@
use std::fmt::{Display, Write};
use camino::Utf8Path;
use colored::Colorize;
use similar::{ChangeTag, TextDiff};
use ruff_db::diagnostic::{Diagnostic, DisplayDiagnosticConfig, FileResolver};
use ruff_db::source::line_index;
use ruff_diagnostics::Applicability;
use ruff_source_file::OneIndexed;
use ruff_text_size::{Ranged, TextRange};
use crate::matcher::Failure;
use crate::parser::BacktickOffsets;
/// Filter which tests to run in mdtest.
///
/// Only tests whose names contain this filter string will be executed.
pub const MDTEST_TEST_FILTER: &str = "MDTEST_TEST_FILTER";
/// If set to a value other than "0", updates the content of inline snapshots.
const MDTEST_UPDATE_SNAPSHOTS: &str = "MDTEST_UPDATE_SNAPSHOTS";
/// Switch mdtest output format to GitHub Actions annotations.
///
/// If set (to any value), mdtest will output errors in GitHub Actions format.
const MDTEST_GITHUB_ANNOTATIONS_FORMAT: &str = "MDTEST_GITHUB_ANNOTATIONS_FORMAT";
mod assertion;
mod diagnostic;
pub mod matcher;
pub mod parser;
/// Determine the output format from the `MDTEST_GITHUB_ANNOTATIONS_FORMAT` environment variable.
pub fn output_format() -> OutputFormat {
if std::env::var(MDTEST_GITHUB_ANNOTATIONS_FORMAT).is_ok() {
OutputFormat::GitHub
} else {
OutputFormat::Cli
}
}
/// Defines the format in which mdtest should print an error to the terminal
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
/// The format `cargo test` should use by default.
Cli,
/// A format that will provide annotations from GitHub Actions
/// if mdtest fails on a PR.
/// See <https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-an-error-message>
GitHub,
}
impl OutputFormat {
pub const fn is_cli(self) -> bool {
matches!(self, OutputFormat::Cli)
}
/// Write a test error in the appropriate format.
///
/// For CLI format, errors are appended to `assertion_buf` so they appear
/// in the assertion-failure message.
///
/// For GitHub format, errors are printed directly to stdout so that GitHub
/// Actions can detect them as workflow commands. Workflow commands must
/// appear at the beginning of a line in stdout to be parsed by GitHub.
#[expect(clippy::print_stdout)]
pub fn write_error(
self,
assertion_buf: &mut String,
file: &str,
line: OneIndexed,
failure: &Failure,
) {
match self {
OutputFormat::Cli => {
let _ = writeln!(
assertion_buf,
"{file_line} {message}",
file_line = format!("{file}:{line}").cyan(),
message = Indented(failure.message()),
);
if let Some((expected, actual)) = failure.diff() {
let _ = render_diff(assertion_buf, actual, expected);
}
}
OutputFormat::GitHub => {
println!(
"::error file={file},line={line}::{message}",
message = failure.message()
);
}
}
}
/// Write a module-resolution inconsistency in the appropriate format.
///
/// See [`write_error`](Self::write_error) for details on why GitHub-format
/// messages must be printed directly to stdout.
#[expect(clippy::print_stdout)]
pub fn write_inconsistency(
self,
assertion_buf: &mut String,
fixture_path: &Utf8Path,
inconsistency: &impl Display,
) {
match self {
OutputFormat::Cli => {
let info = fixture_path.to_string().cyan();
let _ = writeln!(assertion_buf, " {info} {inconsistency}");
}
OutputFormat::GitHub => {
println!("::error file={fixture_path}::{inconsistency}");
}
}
}
}
/// Indents every line except the first when formatting `T` by four spaces.
///
/// ## Examples
/// Wrapping the message part indents the `error[...]` diagnostic frame by four spaces:
///
/// ```text
/// crates/ty_python_semantic/resources/mdtest/mro.md:465 Fixing the diagnostics caused a fatal error:
/// error[internal-error]: Applying fixes introduced a syntax error. Reverting changes.
/// --> src/mdtest_snippet.py:1:1
/// info: This indicates a bug in ty.
/// ```
struct Indented<T>(T);
impl<T> std::fmt::Display for Indented<T>
where
T: std::fmt::Display,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut w = IndentingWriter {
f,
at_line_start: false,
};
write!(&mut w, "{}", self.0)
}
}
struct IndentingWriter<'a, 'b> {
f: &'a mut std::fmt::Formatter<'b>,
at_line_start: bool,
}
impl Write for IndentingWriter<'_, '_> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
for part in s.split_inclusive('\n') {
if self.at_line_start {
self.f.write_str(" ")?;
}
self.f.write_str(part)?;
self.at_line_start = part.ends_with('\n');
}
Ok(())
}
}
pub type Failures = Vec<FileFailures>;
/// The failures for a single file in a test by line number.
pub struct FileFailures {
/// Positional information about the code block(s) to reconstruct absolute line numbers.
pub backtick_offsets: Vec<BacktickOffsets>,
/// The failures by lines in the file.
pub by_line: matcher::FailuresByLine,
}
/// File in a test.
pub struct TestFile<'a> {
pub file: ruff_db::files::File,
/// Information about the checkable code block(s) that compose this file.
pub code_blocks: Vec<parser::CodeBlock<'a>>,
}
impl TestFile<'_> {
pub fn to_code_block_backtick_offsets(&self) -> Vec<BacktickOffsets> {
self.code_blocks
.iter()
.map(parser::CodeBlock::backtick_offsets)
.collect()
}
}
pub(crate) fn diagnostic_display_config(tool_name: &'static str) -> DisplayDiagnosticConfig {
DisplayDiagnosticConfig::new(tool_name)
.color(false)
.show_fix_diff(true)
.with_fix_applicability(Applicability::DisplayOnly)
// Surrounding context in source annotations can be confusing in mdtests,
// since you may get to see context from the *subsequent* code block (all
// code blocks are merged into a single file). It also leads to a lot of
// duplication in general. So we just set it to zero here for concise
// and clear snapshots.
.context(0)
}
pub fn render_diagnostic(
resolver: &dyn FileResolver,
tool_name: &'static str,
diagnostic: &Diagnostic,
) -> String {
diagnostic
.display(resolver, &diagnostic_display_config(tool_name))
.to_string()
}
pub(crate) fn render_diagnostics(
resolver: &dyn FileResolver,
tool_name: &'static str,
diagnostics: &[Diagnostic],
) -> String {
let mut rendered = String::new();
for diag in diagnostics {
writeln!(rendered, "{}", render_diagnostic(resolver, tool_name, diag)).unwrap();
}
rendered.trim_end_matches('\n').to_string()
}
pub(crate) fn is_update_inline_snapshots_enabled() -> bool {
let is_enabled: std::sync::LazyLock<_> = std::sync::LazyLock::new(|| {
std::env::var_os(MDTEST_UPDATE_SNAPSHOTS).is_some_and(|v| v != "0")
});
*is_enabled
}
pub(crate) fn apply_snapshot_filters(rendered: &str) -> std::borrow::Cow<'_, str> {
static INLINE_SNAPSHOT_PATH_FILTER: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r#"\\(\w\w|\.|")"#).unwrap());
INLINE_SNAPSHOT_PATH_FILTER.replace_all(rendered, "/$1")
}
pub fn validate_inline_snapshot(
db: &dyn ruff_db::Db,
tool_name: &'static str,
test_file: &TestFile<'_>,
inline_diagnostics: &[Diagnostic],
markdown_edits: &mut Vec<MarkdownEdit>,
) -> Result<(), matcher::FailuresByLine> {
let update_snapshots = is_update_inline_snapshots_enabled();
let line_index = line_index(db, test_file.file);
let mut failures = matcher::FailuresByLine::default();
let mut inline_diagnostics = inline_diagnostics;
// Group the inline diagnostics by code block. We do this by using the code blocks
// start offsets. All diagnostics between the current's and next code blocks offset belong to the current code block.
for (index, code_block) in test_file.code_blocks.iter().enumerate() {
let next_block_start_offset = test_file
.code_blocks
.get(index + 1)
.map_or(ruff_text_size::TextSize::new(u32::MAX), |next_code_block| {
next_code_block.embedded_start_offset()
});
// Find the offset of the first diagnostic that belongs to the next code block.
let diagnostics_end = inline_diagnostics
.iter()
.position(|diagnostic| {
diagnostic
.primary_span()
.and_then(|span| span.range())
.map(TextRange::start)
.is_some_and(|offset| offset >= next_block_start_offset)
})
.unwrap_or(inline_diagnostics.len());
let (block_diagnostics, remaining_diagnostics) =
inline_diagnostics.split_at(diagnostics_end);
inline_diagnostics = remaining_diagnostics;
let failure_line = line_index.line_index(code_block.embedded_start_offset());
let Some(first_diagnostic) = block_diagnostics.first() else {
// If there are no inline diagnostics (no usages of `# snapshot`) but the code block has a
// diagnostics section, mark it as unnecessary or remove it.
if let Some(snapshot_code_block) = code_block.inline_snapshot_block() {
if update_snapshots {
markdown_edits.push(MarkdownEdit {
range: snapshot_code_block.range(),
replacement: String::new(),
});
} else {
failures.push(
failure_line,
vec![Failure::new(
"This code block has a `snapshot` code block but no `# snapshot` assertions. Remove the `snapshot` code block or add a `# snapshot:` assertion.",
)],
);
}
}
continue;
};
let actual = apply_snapshot_filters(&render_diagnostics(&db, tool_name, block_diagnostics))
.into_owned();
let Some(snapshot_code_block) = code_block.inline_snapshot_block() else {
if update_snapshots {
markdown_edits.push(MarkdownEdit {
range: TextRange::empty(code_block.backtick_offsets().end()),
replacement: format!("\n\n```snapshot\n{actual}\n```"),
});
} else {
let first_range = first_diagnostic.primary_span().unwrap().range().unwrap();
let line = line_index.line_index(first_range.start());
failures.push(
line,
vec![Failure::new(format!(
"Add a `snapshot` block for this `# snapshot` assertion, or set `{MDTEST_UPDATE_SNAPSHOTS}=1` to insert one automatically",
))],
);
}
continue;
};
if snapshot_code_block.expected == actual {
continue;
}
if update_snapshots {
markdown_edits.push(MarkdownEdit {
range: snapshot_code_block.range(),
replacement: format!("```snapshot\n{actual}\n```"),
});
} else {
failures.push(
failure_line,
vec![Failure::new(format_args!(
"inline diagnostics snapshot are out of date; set `{MDTEST_UPDATE_SNAPSHOTS}=1` to update the `snapshot` block",
)).with_diff(snapshot_code_block.expected.to_string(), actual)],
);
}
}
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}
fn render_diff(f: &mut dyn std::fmt::Write, expected: &str, actual: &str) -> std::fmt::Result {
let diff = TextDiff::from_lines(expected, actual);
writeln!(f, "{}", "--- expected".red())?;
writeln!(f, "{}", "+++ actual".green())?;
let mut unified = diff.unified_diff();
let unified = unified.header("expected", "actual");
for hunk in unified.iter_hunks() {
writeln!(f, "{}", hunk.header())?;
for change in hunk.iter_changes() {
let value = change.value();
match change.tag() {
ChangeTag::Equal => write!(f, " {value}")?,
ChangeTag::Delete => {
write!(f, "{}{}", "-".red(), value.red())?;
}
ChangeTag::Insert => {
write!(f, "{}{}", "+".green(), value.green()).unwrap();
}
}
if !diff.newline_terminated() || change.missing_newline() {
writeln!(f)?;
}
}
}
Ok(())
}
pub fn try_apply_markdown_edits(
absolute_fixture_path: &Utf8Path,
source: &str,
mut edits: Vec<MarkdownEdit>,
) {
edits.sort_unstable_by_key(|edit| edit.range.start());
let mut updated = source.to_string();
for edit in edits.into_iter().rev() {
updated.replace_range(
edit.range.start().to_usize()..edit.range.end().to_usize(),
&edit.replacement,
);
}
if let Err(err) = std::fs::write(absolute_fixture_path, updated) {
tracing::error!("Failed to write updated inline snapshots in: {err}");
}
}
pub fn create_diagnostic_snapshot<'d, C>(
resolver: &dyn FileResolver,
tool_name: &'static str,
relative_fixture_path: &Utf8Path,
test: &parser::MarkdownTest<'_, '_, C>,
diagnostics: impl IntoIterator<Item = &'d Diagnostic>,
) -> String {
let mut snapshot = String::new();
writeln!(snapshot).unwrap();
writeln!(snapshot, "---").unwrap();
writeln!(snapshot, "mdtest name: {}", test.uncontracted_name()).unwrap();
writeln!(snapshot, "mdtest path: {relative_fixture_path}").unwrap();
writeln!(snapshot, "---").unwrap();
writeln!(snapshot).unwrap();
writeln!(snapshot, "# Python source files").unwrap();
writeln!(snapshot).unwrap();
for file in test.files() {
writeln!(snapshot, "## {}", file.relative_path()).unwrap();
writeln!(snapshot).unwrap();
// Note that we don't use ```py here because the line numbering
// we add makes it invalid Python. This sacrifices syntax
// highlighting when you look at the snapshot on GitHub,
// but the line numbers are extremely useful for analyzing
// snapshots. So we keep them.
writeln!(snapshot, "```").unwrap();
let line_number_width = file.code.lines().count().to_string().len();
for (i, line) in file.code.lines().enumerate() {
let line_number = i + 1;
writeln!(snapshot, "{line_number:>line_number_width$} | {line}").unwrap();
}
writeln!(snapshot, "```").unwrap();
writeln!(snapshot).unwrap();
}
writeln!(snapshot, "# Diagnostics").unwrap();
writeln!(snapshot).unwrap();
for (index, diagnostic) in diagnostics.into_iter().enumerate() {
if index > 0 {
writeln!(snapshot).unwrap();
}
writeln!(snapshot, "```").unwrap();
write!(
snapshot,
"{}",
render_diagnostic(resolver, tool_name, diagnostic)
)
.unwrap();
writeln!(snapshot, "```").unwrap();
}
snapshot
}
#[derive(Debug, Clone)]
pub struct MarkdownEdit {
pub(crate) range: TextRange,
pub(crate) replacement: String,
}
#[cfg(test)]
pub(crate) mod tests {
use ruff_db::Db;
use ruff_db::files::Files;
use ruff_db::system::{DbWithTestSystem, System, TestSystem};
use ruff_db::vendored::VendoredFileSystem;
/// Database that can be used for testing.
///
/// Uses an in-memory filesystem and an empty vendored filesystem. Since the
/// parser only needs source text and line info, no typeshed stubs are required.
#[salsa::db]
#[derive(Default, Clone)]
pub(crate) struct TestDb {
storage: salsa::Storage<Self>,
files: Files,
system: TestSystem,
vendored: VendoredFileSystem,
}
impl TestDb {
pub(crate) fn setup() -> Self {
Self::default()
}
}
#[salsa::db]
impl Db for TestDb {
fn vendored(&self) -> &VendoredFileSystem {
&self.vendored
}
fn system(&self) -> &dyn System {
&self.system
}
fn files(&self) -> &Files {
&self.files
}
fn python_version(&self) -> ruff_python_ast::PythonVersion {
ruff_python_ast::PythonVersion::latest_ty()
}
}
impl DbWithTestSystem for TestDb {
fn test_system(&self) -> &TestSystem {
&self.system
}
fn test_system_mut(&mut self) -> &mut TestSystem {
&mut self.system
}
}
#[salsa::db]
impl salsa::Database for TestDb {}
}
@@ -8,6 +8,7 @@ use std::sync::LazyLock;
use colored::Colorize;
use path_slash::PathExt;
use ruff_db::Db;
use ruff_db::diagnostic::{Diagnostic, DiagnosticId};
use ruff_db::files::File;
use ruff_db::parsed::parsed_module;
@@ -16,17 +17,16 @@ use ruff_source_file::{LineIndex, OneIndexed};
use smallvec::SmallVec;
use crate::assertion::{InlineFileAssertions, LineAssertions, ParsedAssertion, UnparsedAssertion};
use crate::db::Db;
use crate::diagnostic::SortedDiagnostics;
#[derive(Debug, Default)]
pub(super) struct FailuresByLine {
pub struct FailuresByLine {
failures: Vec<Failure>,
lines: Vec<LineFailures>,
}
impl FailuresByLine {
pub(super) fn iter(&self) -> impl Iterator<Item = (OneIndexed, &[Failure])> {
pub fn iter(&self) -> impl Iterator<Item = (OneIndexed, &[Failure])> {
self.lines.iter().map(|line_failures| {
(
line_failures.line_number,
@@ -35,7 +35,7 @@ impl FailuresByLine {
})
}
pub(super) fn push(&mut self, line_number: OneIndexed, messages: Vec<Failure>) {
pub fn push(&mut self, line_number: OneIndexed, messages: Vec<Failure>) {
let start = self.failures.len();
self.failures.extend(messages);
self.lines.push(LineFailures {
@@ -50,7 +50,7 @@ impl FailuresByLine {
}
#[derive(Debug, Clone)]
pub(super) struct Failure {
pub struct Failure {
message: String,
/// Optional diff that is shown alongside the error message.
/// The tuple represents the (expected, actual) values for the diff.
@@ -58,7 +58,7 @@ pub(super) struct Failure {
}
impl Failure {
pub(super) fn new(message: impl std::fmt::Display) -> Self {
pub fn new(message: impl std::fmt::Display) -> Self {
Self {
message: message.to_string(),
diff: None,
@@ -87,8 +87,8 @@ struct LineFailures {
range: Range<usize>,
}
pub(super) fn match_file(
db: &Db,
pub fn match_file(
db: &dyn Db,
file: File,
diagnostics: &[Diagnostic],
) -> Result<Vec<Diagnostic>, FailuresByLine> {
@@ -305,7 +305,7 @@ struct Matcher {
}
impl Matcher {
fn from_file(db: &Db, file: File) -> Self {
fn from_file(db: &dyn Db, file: File) -> Self {
Self {
line_index: line_index(db, file),
source: source_text(db, file),
@@ -510,18 +510,15 @@ fn match_reveal_type_diagnostic(
#[cfg(test)]
mod tests {
use crate::tests::TestDb;
use super::FailuresByLine;
use ruff_db::Db;
use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, Severity, Span};
use ruff_db::files::{File, system_path_to_file};
use ruff_db::system::DbWithWritableSystem as _;
use ruff_python_trivia::textwrap::dedent;
use ruff_source_file::OneIndexed;
use ruff_text_size::TextRange;
use ty_module_resolver::SearchPathSettings;
use ty_python_core::platform::PythonPlatform;
use ty_python_core::program::{FallibleStrategy, Program, ProgramSettings};
use ty_python_semantic::PythonVersionWithSource;
struct ExpectedDiagnostic {
id: DiagnosticId,
@@ -563,17 +560,7 @@ mod tests {
) -> Result<Vec<Diagnostic>, FailuresByLine> {
colored::control::set_override(false);
let mut db = crate::db::Db::setup();
let settings = ProgramSettings {
python_version: PythonVersionWithSource::default(),
python_platform: PythonPlatform::default(),
search_paths: SearchPathSettings::new(Vec::new())
.to_search_paths(db.system(), db.vendored(), &FallibleStrategy)
.expect("Valid search paths settings"),
};
Program::init_or_update(&mut db, settings);
let mut db = TestDb::setup();
db.write_file("/src/test.py", source).unwrap();
let file = system_path_to_file(&db, "/src/test.py").unwrap();
@@ -4,22 +4,33 @@ use std::{
hash::Hash,
};
use anyhow::bail;
use anyhow::{Context, bail};
use indexmap::{IndexMap, map::Entry};
use ruff_db::system::{SystemPath, SystemPathBuf};
use rustc_hash::{FxBuildHasher, FxHashMap};
use crate::config::MarkdownTestConfig;
use ruff_index::{IndexVec, newtype_index};
use ruff_python_ast::PySourceType;
use ruff_python_trivia::Cursor;
use ruff_source_file::{LineIndex, LineRanges, OneIndexed};
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
use rustc_stable_hash::{FromStableHash, SipHasher128Hash, StableSipHasher128};
use serde::Deserialize;
/// Parse the Markdown `source` as a test suite with given `title`.
pub(crate) fn parse<'s>(title: &'s str, source: &'s str) -> anyhow::Result<MarkdownTestSuite<'s>> {
let parser = Parser::new(title, source);
///
/// `validate_config` is invoked once for every literally-declared `toml` config block
/// (after deserialization, before it replaces the section's inherited config) so callers
/// can enforce invariants that mdtest itself shouldn't know about.
pub fn parse<'s, C>(
title: &'s str,
source: &'s str,
validate_config: impl FnMut(&C) -> anyhow::Result<()>,
) -> anyhow::Result<MarkdownTestSuite<'s, C>>
where
C: Clone + Default + for<'de> Deserialize<'de>,
{
let parser = Parser::new(title, source, validate_config);
parser.parse()
}
@@ -27,16 +38,16 @@ pub(crate) fn parse<'s>(title: &'s str, source: &'s str) -> anyhow::Result<Markd
///
/// Borrows from the source string and filepath it was created from.
#[derive(Debug)]
pub(crate) struct MarkdownTestSuite<'s> {
pub struct MarkdownTestSuite<'s, C> {
/// Header sections.
sections: IndexVec<SectionId, Section<'s>>,
sections: IndexVec<SectionId, Section<'s, C>>,
/// Test files embedded within the Markdown file.
files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>,
}
impl<'s> MarkdownTestSuite<'s> {
pub(crate) fn tests(&self) -> MarkdownTestIterator<'_, 's> {
impl<'s, C> MarkdownTestSuite<'s, C> {
pub fn tests(&self) -> MarkdownTestIterator<'_, 's, C> {
MarkdownTestIterator {
suite: self,
current_file_index: 0,
@@ -69,13 +80,13 @@ impl LowerHex for Hash128 {
/// headers in the file), containing one or more embedded Python files as fenced code blocks, and
/// containing no nested header subsections.
#[derive(Debug)]
pub(crate) struct MarkdownTest<'m, 's> {
suite: &'m MarkdownTestSuite<'s>,
section: &'m Section<'s>,
pub struct MarkdownTest<'m, 's, C> {
suite: &'m MarkdownTestSuite<'s, C>,
section: &'m Section<'s, C>,
files: &'m [EmbeddedFile<'s>],
}
impl<'m, 's> MarkdownTest<'m, 's> {
impl<'m, 's, C> MarkdownTest<'m, 's, C> {
const MAX_TITLE_LENGTH: usize = 20;
const ELLIPSIS: char = '\u{2026}';
@@ -125,33 +136,34 @@ impl<'m, 's> MarkdownTest<'m, 's> {
contracted_name
}
pub(crate) fn uncontracted_name(&self) -> String {
pub fn uncontracted_name(&self) -> String {
self.joined_name(false)
}
pub(crate) fn name(&self) -> String {
pub fn name(&self) -> String {
self.joined_name(true)
}
pub(crate) fn files(&self) -> impl Iterator<Item = &'m EmbeddedFile<'s>> {
pub fn files(&self) -> impl Iterator<Item = &'m EmbeddedFile<'s>> {
self.files.iter()
}
pub(crate) fn configuration(&self) -> &MarkdownTestConfig {
pub fn configuration(&self) -> &C {
&self.section.config
}
pub(super) fn should_snapshot_diagnostics(&self) -> bool {
pub fn should_snapshot_diagnostics(&self) -> bool {
self.section
.directives
.has_directive_set(MdtestDirective::SnapshotDiagnostics)
}
pub(super) fn should_expect_panic(&self) -> Result<Option<&str>, ()> {
#[expect(clippy::result_unit_err)]
pub fn should_expect_panic(&self) -> Result<Option<&str>, ()> {
self.section.directives.get(MdtestDirective::ExpectPanic)
}
pub(super) fn should_skip_pulling_types(&self) -> bool {
pub fn should_skip_pulling_types(&self) -> bool {
self.section
.directives
.has_directive_set(MdtestDirective::PullTypesSkip)
@@ -160,13 +172,13 @@ impl<'m, 's> MarkdownTest<'m, 's> {
/// Iterator yielding all [`MarkdownTest`]s in a [`MarkdownTestSuite`].
#[derive(Debug)]
pub(crate) struct MarkdownTestIterator<'m, 's> {
suite: &'m MarkdownTestSuite<'s>,
pub struct MarkdownTestIterator<'m, 's, C> {
suite: &'m MarkdownTestSuite<'s, C>,
current_file_index: usize,
}
impl<'m, 's> Iterator for MarkdownTestIterator<'m, 's> {
type Item = MarkdownTest<'m, 's>;
impl<'m, 's, C> Iterator for MarkdownTestIterator<'m, 's, C> {
type Item = MarkdownTest<'m, 's, C>;
fn next(&mut self) -> Option<Self::Item> {
let mut current_file_index = self.current_file_index;
@@ -200,11 +212,11 @@ struct SectionId;
/// [`MarkdownTest`]), or it may contain nested sections (headers with more `#` characters), but
/// not both.
#[derive(Debug)]
struct Section<'s> {
pub struct Section<'s, C> {
title: &'s str,
level: u8,
parent_id: Option<SectionId>,
config: MarkdownTestConfig,
config: C,
directives: MdtestDirectives,
}
@@ -216,7 +228,7 @@ struct EmbeddedFileId;
/// The start is the offset of the first triple-backtick in the code block, and the end is the
/// offset immediately after the closing triple-backtick.
#[derive(Debug, Copy, Clone)]
pub(crate) struct BacktickOffsets(TextRange);
pub struct BacktickOffsets(TextRange);
impl Ranged for BacktickOffsets {
fn range(&self) -> TextRange {
@@ -272,12 +284,12 @@ impl Ranged for InlineSnapshotBlock<'_> {
/// count of the first block, and then add the new relative line number (1)
/// to the absolute start line of the second block (12), resulting in an
/// absolute line number of 13.
pub(crate) struct EmbeddedFileSourceMap {
pub struct EmbeddedFileSourceMap {
start_line_and_line_count: Vec<(usize, usize)>,
}
impl EmbeddedFileSourceMap {
pub(crate) fn new(
pub fn new(
md_index: &LineIndex,
dimensions: impl IntoIterator<Item = BacktickOffsets>,
) -> EmbeddedFileSourceMap {
@@ -302,7 +314,7 @@ impl EmbeddedFileSourceMap {
///
/// # Panics
/// If called when the markdown file has no code blocks.
pub(crate) fn to_absolute_line_number(
pub fn to_absolute_line_number(
&self,
relative_line_number: OneIndexed,
) -> std::result::Result<OneIndexed, OneIndexed> {
@@ -368,13 +380,13 @@ impl EmbeddedFilePath<'_> {
///
/// [typeshed `VERSIONS`]: https://github.com/python/typeshed/blob/c546278aae47de0b2b664973da4edb613400f6ce/stdlib/VERSIONS#L1-L18
#[derive(Debug)]
pub(crate) struct EmbeddedFile<'s> {
pub struct EmbeddedFile<'s> {
section: SectionId,
path: EmbeddedFilePath<'s>,
pub(crate) lang: &'s str,
pub(crate) code: Cow<'s, str>,
pub lang: &'s str,
pub code: Cow<'s, str>,
/// The checkable code blocks
pub(crate) python_code_blocks: Vec<CodeBlock<'s>>,
pub python_code_blocks: Vec<CodeBlock<'s>>,
}
impl EmbeddedFile<'_> {
@@ -402,7 +414,7 @@ impl EmbeddedFile<'_> {
}
/// Returns the full path using unix file-path convention.
pub(crate) fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf {
pub fn full_path(&self, project_root: &SystemPath) -> SystemPathBuf {
// Don't use `SystemPath::absolute` here because it's platform dependent
// and we want to use unix file-path convention.
let relative_path = self.relative_path();
@@ -419,7 +431,7 @@ impl EmbeddedFile<'_> {
}
#[derive(Debug, Clone)]
pub(crate) struct CodeBlock<'s> {
pub struct CodeBlock<'s> {
/// The offsets of the code block's code fences in the markdown source.
backticks: BacktickOffsets,
/// The offset in the concatenated file source at which this code block starts.
@@ -473,10 +485,9 @@ impl SectionStack {
}
/// Parse the source of a Markdown file into a [`MarkdownTestSuite`].
#[derive(Debug)]
struct Parser<'s> {
struct Parser<'s, C, F> {
/// [`Section`]s of the final [`MarkdownTestSuite`].
sections: IndexVec<SectionId, Section<'s>>,
sections: IndexVec<SectionId, Section<'s, C>>,
/// [`EmbeddedFile`]s of the final [`MarkdownTestSuite`].
files: IndexVec<EmbeddedFileId, EmbeddedFile<'s>>,
@@ -501,19 +512,22 @@ struct Parser<'s> {
/// Whether or not the current section has a config block.
current_section_has_config: bool,
/// Whether or not any section in the file has external dependencies.
/// Only one section per file is allowed to have dependencies (for lockfile support).
file_has_dependencies: bool,
/// Callback to validate config blocks
validate_config: F,
}
impl<'s> Parser<'s> {
fn new(title: &'s str, source: &'s str) -> Self {
impl<'s, C, F> Parser<'s, C, F>
where
C: Clone + Default + for<'de> Deserialize<'de>,
F: FnMut(&C) -> anyhow::Result<()>,
{
fn new(title: &'s str, source: &'s str, validate_config: F) -> Self {
let mut sections = IndexVec::default();
let root_section_id = sections.push(Section {
title,
level: 0,
parent_id: None,
config: MarkdownTestConfig::default(),
config: C::default(),
directives: MdtestDirectives::default(),
});
Self {
@@ -526,16 +540,16 @@ impl<'s> Parser<'s> {
stack: SectionStack::new(root_section_id),
current_section_files: IndexMap::default(),
current_section_has_config: false,
file_has_dependencies: false,
validate_config,
}
}
fn parse(mut self) -> anyhow::Result<MarkdownTestSuite<'s>> {
fn parse(mut self) -> anyhow::Result<MarkdownTestSuite<'s, C>> {
self.parse_impl()?;
Ok(self.finish())
}
fn finish(mut self) -> MarkdownTestSuite<'s> {
fn finish(mut self) -> MarkdownTestSuite<'s, C> {
self.sections.shrink_to_fit();
self.files.shrink_to_fit();
@@ -908,17 +922,9 @@ impl<'s> Parser<'s> {
bail!("Multiple TOML configuration blocks in the same section are not allowed.");
}
let config = MarkdownTestConfig::from_str(code)?;
let config: C = toml::from_str(code).context("Error while parsing Markdown TOML config")?;
if config.dependencies().is_some() {
if self.file_has_dependencies {
bail!(
"Multiple sections with `[project]` dependencies in the same file are not allowed. \
External dependencies must be specified in a single top-level configuration block."
);
}
self.file_has_dependencies = true;
}
(self.validate_config)(&config)?;
let current_section = &mut self.sections[self.stack.top()];
current_section.config = config;
@@ -1070,12 +1076,55 @@ mod tests {
use ruff_source_file::OneIndexed;
use insta::assert_snapshot;
use serde::Deserialize;
use crate::parser::EmbeddedFilePath;
/// A minimal copy of `ty_test::config::MarkdownTestConfig` for testing the
/// parser.
///
/// Supports the following options:
///
/// ```toml
/// [project]
/// dependencies = ["package==1.2.3"]
///
/// [environment]
/// typeshed = "path/to/typeshed"
/// ```
#[derive(Clone, Debug, Default, Deserialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
#[expect(
unused,
reason = "These fields are only used for testing deserialization and never read"
)]
struct TestConfig {
project: Option<Project>,
environment: Option<Environment>,
}
#[derive(Clone, Debug, Default, Deserialize)]
struct Project {
#[expect(unused)]
dependencies: Option<Vec<String>>,
}
#[derive(Clone, Debug, Default, Deserialize)]
struct Environment {
#[expect(unused)]
typeshed: Option<String>,
}
fn parse<'s>(
title: &'s str,
source: &'s str,
) -> anyhow::Result<super::MarkdownTestSuite<'s, TestConfig>> {
super::parse::<TestConfig>(title, source, |_| Ok(()))
}
#[test]
fn empty() {
let mf = super::parse("file.md", "").unwrap();
let mf = parse("file.md", "").unwrap();
assert!(mf.tests().next().is_none());
}
@@ -1114,7 +1163,7 @@ mod tests {
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1142,7 +1191,7 @@ mod tests {
x = 1
```",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1193,7 +1242,7 @@ mod tests {
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test1, test2, test3] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected three tests");
@@ -1263,7 +1312,7 @@ mod tests {
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test1, test2] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected two tests");
@@ -1321,7 +1370,7 @@ mod tests {
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1358,7 +1407,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `One` has duplicate files named `foo.py`."
@@ -1405,7 +1454,7 @@ mod tests {
```
",
] {
let err = super::parse("file.md", &dedent(source)).expect_err("Should fail to parse");
let err = parse("file.md", &dedent(source)).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Merged snippets in test `One` are not allowed in the presence of other files."
@@ -1432,7 +1481,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Merged snippets in test `One` are not allowed in the presence of other files."
@@ -1450,7 +1499,7 @@ mod tests {
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1474,7 +1523,7 @@ mod tests {
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1495,7 +1544,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1519,7 +1568,7 @@ mod tests {
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Cannot auto-generate file name for code block with empty language specifier in test `No language specifier`"
@@ -1537,7 +1586,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Cannot auto-generate file name for code block with language `json` in test `JSON test?`"
@@ -1557,7 +1606,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"File extension of test file path `a.py` in test `Accidental stub` does not match language specified `pyi` of code block on line `6`"
@@ -1576,7 +1625,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1601,7 +1650,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1622,7 +1671,7 @@ mod tests {
x = 1
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Unterminated code block on line 2.");
}
@@ -1642,7 +1691,7 @@ mod tests {
x = 1
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Unterminated code block on line 10.");
}
@@ -1659,7 +1708,7 @@ mod tests {
```
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1679,7 +1728,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(err.to_string(), "Indented code blocks are not supported.");
}
@@ -1696,7 +1745,7 @@ mod tests {
## Two
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Header 'Two' not valid inside a test case; parent 'One' has code files."
@@ -1716,7 +1765,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1748,7 +1797,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1782,7 +1831,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Test `file.md` has duplicate files named `foo.py`."
@@ -1806,7 +1855,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"The file name `mdtest_snippet.py` in test `Name clash` must not be used explicitly."
@@ -1825,7 +1874,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1850,7 +1899,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1874,7 +1923,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1899,7 +1948,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1925,7 +1974,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1953,7 +2002,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -1982,7 +2031,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -2011,7 +2060,7 @@ mod tests {
",
);
let mf = super::parse("file.md", &source).unwrap();
let mf = parse("file.md", &source).unwrap();
let [test] = &mf.tests().collect::<Vec<_>>()[..] else {
panic!("expected one test");
@@ -2041,7 +2090,7 @@ mod tests {
",
);
super::parse("file.md", &source).expect_err(
parse("file.md", &source).expect_err(
"Code blocks must start on a new line and be preceded by at least one blank line.",
);
}
@@ -2055,7 +2104,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Trailing code-block metadata is not supported. Only the code block language can be specified."
@@ -2076,7 +2125,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Section config to enable snapshotting diagnostics should appear at most once.",
@@ -2101,7 +2150,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Section config to enable snapshotting diagnostics should appear at most once.",
@@ -2126,7 +2175,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Section config to enable snapshotting diagnostics must \
@@ -2148,7 +2197,7 @@ mod tests {
<!-- snapshot-diagnostics -->
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Section config to enable snapshotting diagnostics must \
@@ -2168,7 +2217,7 @@ mod tests {
```
",
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
let err = parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Unknown HTML comment `snpshotttt-digggggnosstic` -- possibly a typo? \
@@ -2190,42 +2239,7 @@ mod tests {
<!-- fmt:on -->
",
);
let parse_result = super::parse("file.md", &source);
let parse_result = parse("file.md", &source);
assert!(parse_result.is_ok(), "{parse_result:?}");
}
#[test]
fn multiple_sections_with_dependencies_not_allowed() {
let source = dedent(
r#"
# First section
```toml
[project]
dependencies = ["pydantic==2.12.2"]
```
```py
x = 1
```
# Second section
```toml
[project]
dependencies = ["numpy==2.0.0"]
```
```py
y = 2
```
"#,
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Multiple sections with `[project]` dependencies in the same file are not allowed. \
External dependencies must be specified in a single top-level configuration block."
);
}
}
-13
View File
@@ -1,11 +1,5 @@
use anyhow::anyhow;
use camino::Utf8Path;
use ty_test::OutputFormat;
/// Switch mdtest output format to GitHub Actions annotations.
///
/// If set (to any value), mdtest will output errors in GitHub Actions format.
const MDTEST_GITHUB_ANNOTATIONS_FORMAT: &str = "MDTEST_GITHUB_ANNOTATIONS_FORMAT";
/// See `crates/ty_test/README.md` for documentation on these tests.
#[expect(clippy::needless_pass_by_value)]
@@ -25,12 +19,6 @@ fn mdtest(fixture_path: &Utf8Path, content: String) -> datatest_stable::Result<(
.unwrap_or(fixture_path)
.as_str();
let output_format = if std::env::var(MDTEST_GITHUB_ANNOTATIONS_FORMAT).is_ok() {
OutputFormat::GitHub
} else {
OutputFormat::Cli
};
// Limit multithreading in tests to avoid that they compete
// for the same resources (tests are run concurrently most of the time).
let pool = rayon::ThreadPoolBuilder::new()
@@ -46,7 +34,6 @@ fn mdtest(fixture_path: &Utf8Path, content: String) -> datatest_stable::Result<(
&snapshot_path,
short_title,
test_name,
output_format,
)
})?;
+6 -15
View File
@@ -14,38 +14,29 @@ license.workspace = true
doctest = false
[dependencies]
mdtest = { workspace = true }
ruff_db = { workspace = true, features = ["os", "testing"] }
ruff_diagnostics = { workspace = true }
ruff_index = { workspace = true }
ruff_notebook = { workspace = true }
ruff_python_ast = { workspace = true }
ruff_python_trivia = { workspace = true }
ruff_source_file = { workspace = true }
ruff_text_size = { workspace = true }
ty_module_resolver = { workspace = true }
ty_python_semantic = { workspace = true, features = ["serde", "testing"] }
ty_vendored = { workspace = true }
ty_python_core = { workspace = true }
ty_vendored = { workspace = true }
anyhow = { workspace = true }
camino = { workspace = true }
colored = { workspace = true }
dunce = { workspace = true }
indexmap = { workspace = true }
insta = { workspace = true, features = ["filters"] }
memchr = { workspace = true }
path-slash = { workspace = true }
regex = { workspace = true }
rustc-hash = { workspace = true }
rustc-stable-hash = { workspace = true }
salsa = { workspace = true }
serde = { workspace = true }
similar = { workspace = true }
smallvec = { workspace = true }
serde = { workspace = true, features = ["derive"] }
tempfile = { workspace = true }
thiserror = { workspace = true }
toml = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
ruff_python_trivia = { workspace = true }
[lints]
workspace = true
-5
View File
@@ -12,7 +12,6 @@
//! dependencies = ["pydantic==2.12.2"]
//! ```
use anyhow::Context;
use ruff_db::system::{SystemPath, SystemPathBuf};
use ruff_python_ast::PythonVersion;
use serde::{Deserialize, Serialize};
@@ -41,10 +40,6 @@ pub(crate) struct MarkdownTestConfig {
}
impl MarkdownTestConfig {
pub(crate) fn from_str(s: &str) -> anyhow::Result<Self> {
toml::from_str(s).context("Error while parsing Markdown TOML config")
}
pub(crate) fn python_version(&self) -> Option<PythonVersion> {
self.environment.as_ref()?.python_version
}
+78 -440
View File
@@ -1,26 +1,22 @@
use crate::config::Log;
use crate::config::{Log, MarkdownTestConfig, SystemKind};
use crate::db::Db;
use crate::matcher::Failure;
use crate::parser::{BacktickOffsets, CodeBlock, EmbeddedFileSourceMap};
use anyhow::anyhow;
use anyhow::{anyhow, bail};
use camino::Utf8Path;
use colored::Colorize;
use config::SystemKind;
use parser as test_parser;
use mdtest::matcher::{self, Failure};
use mdtest::parser::{self, EmbeddedFileSourceMap};
use mdtest::{Failures, FileFailures, MDTEST_TEST_FILTER, MarkdownEdit, TestFile, output_format};
use ruff_db::Db as _;
use ruff_db::cancellation::CancellationTokenSource;
use ruff_db::diagnostic::{Diagnostic, DiagnosticId, DisplayDiagnosticConfig};
use ruff_db::diagnostic::DiagnosticId;
use ruff_db::files::{File, FileRootKind, system_path_to_file};
use ruff_db::panic::{PanicError, catch_unwind};
use ruff_db::source::line_index;
use ruff_db::system::{DbWithWritableSystem as _, SystemPath, SystemPathBuf};
use ruff_db::testing::{setup_logging, setup_logging_with_filter};
use ruff_diagnostics::Applicability;
use ruff_source_file::{LineIndex, OneIndexed};
use ruff_text_size::{Ranged, TextRange};
use similar::{ChangeTag, TextDiff};
use std::backtrace::BacktraceStatus;
use std::fmt::{Display, Write};
use std::fmt::Write;
use ty_module_resolver::{
Module, SearchPath, SearchPathSettings, list_modules, resolve_module_confident,
};
@@ -33,21 +29,9 @@ use ty_python_semantic::{
fix_all_diagnostics,
};
mod assertion;
mod config;
mod db;
mod diagnostic;
mod external_dependencies;
mod matcher;
mod parser;
/// Filter which tests to run in mdtest.
///
/// Only tests whose names contain this filter string will be executed.
const MDTEST_TEST_FILTER: &str = "MDTEST_TEST_FILTER";
/// If set to a value other than "0", updates the content of inline snapshots.
const MDTEST_UPDATE_SNAPSHOTS: &str = "MDTEST_UPDATE_SNAPSHOTS";
/// If set to a value other than "0", runs tests that include external dependencies.
const MDTEST_EXTERNAL: &str = "MDTEST_EXTERNAL";
@@ -62,12 +46,13 @@ pub fn run(
snapshot_path: &Utf8Path,
short_title: &str,
test_name: &str,
output_format: OutputFormat,
) -> anyhow::Result<()> {
let suite = test_parser::parse(short_title, source)
.map_err(|err| anyhow!("Failed to parse fixture: {err}"))?;
let output_format = output_format();
let mut db = db::Db::setup();
let suite =
parse(short_title, source).map_err(|err| anyhow!("Failed to parse fixture: {err}"))?;
let mut db = Db::setup();
let mut markdown_edits = vec![];
let filter = std::env::var(MDTEST_TEST_FILTER).ok();
@@ -182,7 +167,7 @@ pub fn run(
}
if !markdown_edits.is_empty() {
try_apply_markdown_edits(absolute_fixture_path, source, markdown_edits);
mdtest::try_apply_markdown_edits(absolute_fixture_path, source, markdown_edits);
}
assert!(!any_failures, "{}", &assertion);
@@ -190,127 +175,6 @@ pub fn run(
Ok(())
}
/// Defines the format in which mdtest should print an error to the terminal
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutputFormat {
/// The format `cargo test` should use by default.
Cli,
/// A format that will provide annotations from GitHub Actions
/// if mdtest fails on a PR.
/// See <https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#setting-an-error-message>
GitHub,
}
impl OutputFormat {
const fn is_cli(self) -> bool {
matches!(self, OutputFormat::Cli)
}
/// Write a test error in the appropriate format.
///
/// For CLI format, errors are appended to `assertion_buf` so they appear
/// in the assertion-failure message.
///
/// For GitHub format, errors are printed directly to stdout so that GitHub
/// Actions can detect them as workflow commands. Workflow commands must
/// appear at the beginning of a line in stdout to be parsed by GitHub.
#[expect(clippy::print_stdout)]
fn write_error(
self,
assertion_buf: &mut String,
file: &str,
line: OneIndexed,
failure: &Failure,
) {
match self {
OutputFormat::Cli => {
let _ = writeln!(
assertion_buf,
"{file_line} {message}",
file_line = format!("{file}:{line}").cyan(),
message = Indented(failure.message()),
);
if let Some((expected, actual)) = failure.diff() {
let _ = render_diff(assertion_buf, actual, expected);
}
}
OutputFormat::GitHub => {
println!(
"::error file={file},line={line}::{message}",
message = failure.message()
);
}
}
}
/// Write a module-resolution inconsistency in the appropriate format.
///
/// See [`write_error`](Self::write_error) for details on why GitHub-format
/// messages must be printed directly to stdout.
#[expect(clippy::print_stdout)]
fn write_inconsistency(
self,
assertion_buf: &mut String,
fixture_path: &Utf8Path,
inconsistency: &impl Display,
) {
match self {
OutputFormat::Cli => {
let info = fixture_path.to_string().cyan();
let _ = writeln!(assertion_buf, " {info} {inconsistency}");
}
OutputFormat::GitHub => {
println!("::error file={fixture_path}::{inconsistency}");
}
}
}
}
/// Indents every line except the first when formatting `T` by four spaces.
///
/// ## Examples
/// Wrapping the message part indents the `error[...]` diagnostic frame by four spaces:
///
/// ```text
/// crates/ty_python_semantic/resources/mdtest/mro.md:465 Fixing the diagnostics caused a fatal error:
/// error[internal-error]: Applying fixes introduced a syntax error. Reverting changes.
/// --> src/mdtest_snippet.py:1:1
/// info: This indicates a bug in ty.
/// ```
struct Indented<T>(T);
impl<T> std::fmt::Display for Indented<T>
where
T: std::fmt::Display,
{
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut w = IndentingWriter {
f,
at_line_start: false,
};
write!(&mut w, "{}", self.0)
}
}
struct IndentingWriter<'a, 'b> {
f: &'a mut std::fmt::Formatter<'b>,
at_line_start: bool,
}
impl Write for IndentingWriter<'_, '_> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
for part in s.split_inclusive('\n') {
if self.at_line_start {
self.f.write_str(" ")?;
}
self.f.write_str(part)?;
self.at_line_start = part.ends_with('\n');
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TestOutcome {
Success,
@@ -323,18 +187,12 @@ impl TestOutcome {
}
}
#[derive(Debug, Clone)]
struct MarkdownEdit {
range: TextRange,
replacement: String,
}
fn run_test(
db: &mut db::Db,
absolute_fixture_path: &Utf8Path,
relative_fixture_path: &Utf8Path,
snapshot_path: &Utf8Path,
test: &parser::MarkdownTest,
test: &parser::MarkdownTest<'_, '_, MarkdownTestConfig>,
) -> Result<(TestOutcome, Vec<MarkdownEdit>), Failures> {
// Initialize the system and remove all files and directories to reset the system to a clean state.
match test.configuration().system.unwrap_or_default() {
@@ -579,8 +437,9 @@ fn run_test(
let failure = match matcher::match_file(db, test_file.file, &diagnostics).and_then(
|inline_diagnostics| {
validate_inline_snapshot(
mdtest::validate_inline_snapshot(
db,
"ty",
test_file,
&inline_diagnostics,
&mut markdown_edits,
@@ -676,8 +535,9 @@ fn run_test(
// Filter out `revealed-type` and `undefined-reveal` diagnostics from snapshots,
// since they make snapshots very noisy!
let snapshot = create_diagnostic_snapshot(
let snapshot = mdtest::create_diagnostic_snapshot(
db,
"ty",
relative_fixture_path,
test,
all_diagnostics.iter().filter(|diagnostic| {
@@ -728,7 +588,7 @@ fn run_test(
OneIndexed::from_zero_indexed(0),
vec![Failure::new(format_args!(
"Fixing the diagnostics caused a fatal error:\n{}",
render_diagnostic(db, &diagnostic)
mdtest::render_diagnostic(db, "ty", &diagnostic)
))],
);
let failure = FileFailures {
@@ -834,287 +694,6 @@ impl std::fmt::Display for ModuleInconsistency<'_> {
}
}
type Failures = Vec<FileFailures>;
/// The failures for a single file in a test by line number.
#[derive(Debug)]
struct FileFailures {
/// Positional information about the code block(s) to reconstruct absolute line numbers.
backtick_offsets: Vec<BacktickOffsets>,
/// The failures by lines in the file.
by_line: matcher::FailuresByLine,
}
/// File in a test.
struct TestFile<'a> {
file: File,
/// Information about the checkable code block(s) that compose this file.
code_blocks: Vec<CodeBlock<'a>>,
}
impl TestFile<'_> {
pub(crate) fn to_code_block_backtick_offsets(&self) -> Vec<BacktickOffsets> {
self.code_blocks
.iter()
.map(parser::CodeBlock::backtick_offsets)
.collect()
}
}
fn diagnostic_display_config() -> DisplayDiagnosticConfig {
DisplayDiagnosticConfig::new("ty")
.color(false)
.show_fix_diff(true)
.with_fix_applicability(Applicability::DisplayOnly)
// Surrounding context in source annotations can be confusing in mdtests,
// since you may get to see context from the *subsequent* code block (all
// code blocks are merged into a single file). It also leads to a lot of
// duplication in general. So we just set it to zero here for concise
// and clear snapshots.
.context(0)
}
fn render_diagnostic(db: &mut Db, diagnostic: &Diagnostic) -> String {
diagnostic
.display(db, &diagnostic_display_config())
.to_string()
}
fn render_diagnostics(db: &mut Db, diagnostics: &[Diagnostic]) -> String {
let mut rendered = String::new();
for diag in diagnostics {
writeln!(rendered, "{}", render_diagnostic(db, diag)).unwrap();
}
rendered.trim_end_matches('\n').to_string()
}
fn is_update_inline_snapshots_enabled() -> bool {
let is_enabled: std::sync::LazyLock<_> = std::sync::LazyLock::new(|| {
std::env::var_os(MDTEST_UPDATE_SNAPSHOTS).is_some_and(|v| v != "0")
});
*is_enabled
}
fn apply_snapshot_filters(rendered: &str) -> std::borrow::Cow<'_, str> {
static INLINE_SNAPSHOT_PATH_FILTER: std::sync::LazyLock<regex::Regex> =
std::sync::LazyLock::new(|| regex::Regex::new(r#"\\(\w\w|\.|")"#).unwrap());
INLINE_SNAPSHOT_PATH_FILTER.replace_all(rendered, "/$1")
}
fn validate_inline_snapshot(
db: &mut db::Db,
test_file: &TestFile<'_>,
inline_diagnostics: &[Diagnostic],
markdown_edits: &mut Vec<MarkdownEdit>,
) -> Result<(), matcher::FailuresByLine> {
let update_snapshots = is_update_inline_snapshots_enabled();
let line_index = line_index(db, test_file.file);
let mut failures = matcher::FailuresByLine::default();
let mut inline_diagnostics = inline_diagnostics;
// Group the inline diagnostics by code block. We do this by using the code blocks
// start offsets. All diagnostics between the current's and next code blocks offset belong to the current code block.
for (index, code_block) in test_file.code_blocks.iter().enumerate() {
let next_block_start_offset = test_file
.code_blocks
.get(index + 1)
.map_or(ruff_text_size::TextSize::new(u32::MAX), |next_code_block| {
next_code_block.embedded_start_offset()
});
// Find the offset of the first diagnostic that belongs to the next code block.
let diagnostics_end = inline_diagnostics
.iter()
.position(|diagnostic| {
diagnostic
.primary_span()
.and_then(|span| span.range())
.map(TextRange::start)
.is_some_and(|offset| offset >= next_block_start_offset)
})
.unwrap_or(inline_diagnostics.len());
let (block_diagnostics, remaining_diagnostics) =
inline_diagnostics.split_at(diagnostics_end);
inline_diagnostics = remaining_diagnostics;
let failure_line = line_index.line_index(code_block.embedded_start_offset());
let Some(first_diagnostic) = block_diagnostics.first() else {
// If there are no inline diagnostics (no usages of `# snapshot`) but the code block has a
// diagnostics section, mark it as unnecessary or remove it.
if let Some(snapshot_code_block) = code_block.inline_snapshot_block() {
if update_snapshots {
markdown_edits.push(MarkdownEdit {
range: snapshot_code_block.range(),
replacement: String::new(),
});
} else {
failures.push(
failure_line,
vec![Failure::new(
"This code block has a `snapshot` code block but no `# snapshot` assertions. Remove the `snapshot` code block or add a `# snapshot:` assertion.",
)],
);
}
}
continue;
};
let actual =
apply_snapshot_filters(&render_diagnostics(db, block_diagnostics)).into_owned();
let Some(snapshot_code_block) = code_block.inline_snapshot_block() else {
if update_snapshots {
markdown_edits.push(MarkdownEdit {
range: TextRange::empty(code_block.backtick_offsets().end()),
replacement: format!("\n\n```snapshot\n{actual}\n```"),
});
} else {
let first_range = first_diagnostic.primary_span().unwrap().range().unwrap();
let line = line_index.line_index(first_range.start());
failures.push(
line,
vec![Failure::new(format!(
"Add a `snapshot` block for this `# snapshot` assertion, or set `{MDTEST_UPDATE_SNAPSHOTS}=1` to insert one automatically",
))],
);
}
continue;
};
if snapshot_code_block.expected == actual {
continue;
}
if update_snapshots {
markdown_edits.push(MarkdownEdit {
range: snapshot_code_block.range(),
replacement: format!("```snapshot\n{actual}\n```"),
});
} else {
failures.push(
failure_line,
vec![Failure::new(format_args!(
"inline diagnostics snapshot are out of date; set `{MDTEST_UPDATE_SNAPSHOTS}=1` to update the `snapshot` block",
)).with_diff(snapshot_code_block.expected.to_string(), actual)],
);
}
}
if failures.is_empty() {
Ok(())
} else {
Err(failures)
}
}
fn render_diff(f: &mut dyn std::fmt::Write, expected: &str, actual: &str) -> std::fmt::Result {
let diff = TextDiff::from_lines(expected, actual);
writeln!(f, "{}", "--- expected".red())?;
writeln!(f, "{}", "+++ actual".green())?;
let mut unified = diff.unified_diff();
let unified = unified.header("expected", "actual");
for hunk in unified.iter_hunks() {
writeln!(f, "{}", hunk.header())?;
for change in hunk.iter_changes() {
let value = change.value();
match change.tag() {
ChangeTag::Equal => write!(f, " {value}")?,
ChangeTag::Delete => {
write!(f, "{}{}", "-".red(), value.red())?;
}
ChangeTag::Insert => {
write!(f, "{}{}", "+".green(), value.green()).unwrap();
}
}
if !diff.newline_terminated() || change.missing_newline() {
writeln!(f)?;
}
}
}
Ok(())
}
fn try_apply_markdown_edits(
absolute_fixture_path: &Utf8Path,
source: &str,
mut edits: Vec<MarkdownEdit>,
) {
edits.sort_unstable_by_key(|edit| edit.range.start());
let mut updated = source.to_string();
for edit in edits.into_iter().rev() {
updated.replace_range(
edit.range.start().to_usize()..edit.range.end().to_usize(),
&edit.replacement,
);
}
if let Err(err) = std::fs::write(absolute_fixture_path, updated) {
tracing::error!("Failed to write updated inline snapshots in: {err}");
}
}
fn create_diagnostic_snapshot<'d>(
db: &mut db::Db,
relative_fixture_path: &Utf8Path,
test: &parser::MarkdownTest,
diagnostics: impl IntoIterator<Item = &'d Diagnostic>,
) -> String {
let mut snapshot = String::new();
writeln!(snapshot).unwrap();
writeln!(snapshot, "---").unwrap();
writeln!(snapshot, "mdtest name: {}", test.uncontracted_name()).unwrap();
writeln!(snapshot, "mdtest path: {relative_fixture_path}").unwrap();
writeln!(snapshot, "---").unwrap();
writeln!(snapshot).unwrap();
writeln!(snapshot, "# Python source files").unwrap();
writeln!(snapshot).unwrap();
for file in test.files() {
writeln!(snapshot, "## {}", file.relative_path()).unwrap();
writeln!(snapshot).unwrap();
// Note that we don't use ```py here because the line numbering
// we add makes it invalid Python. This sacrifices syntax
// highlighting when you look at the snapshot on GitHub,
// but the line numbers are extremely useful for analyzing
// snapshots. So we keep them.
writeln!(snapshot, "```").unwrap();
let line_number_width = file.code.lines().count().to_string().len();
for (i, line) in file.code.lines().enumerate() {
let line_number = i + 1;
writeln!(snapshot, "{line_number:>line_number_width$} | {line}").unwrap();
}
writeln!(snapshot, "```").unwrap();
writeln!(snapshot).unwrap();
}
writeln!(snapshot, "# Diagnostics").unwrap();
writeln!(snapshot).unwrap();
for (index, diagnostic) in diagnostics.into_iter().enumerate() {
if index > 0 {
writeln!(snapshot).unwrap();
}
writeln!(snapshot, "```").unwrap();
write!(snapshot, "{}", render_diagnostic(db, diagnostic)).unwrap();
writeln!(snapshot, "```").unwrap();
}
snapshot
}
/// Run a function over an embedded test file, catching any panics that occur in the process.
///
/// If no panic occurs, the result of the function is returned as an `Ok()` variant.
@@ -1197,3 +776,62 @@ impl AttemptTestError<'_> {
}
}
}
fn parse<'s>(
short_title: &'s str,
source: &'s str,
) -> anyhow::Result<parser::MarkdownTestSuite<'s, MarkdownTestConfig>> {
let mut file_has_dependencies = false;
parser::parse::<MarkdownTestConfig>(short_title, source, |config| {
if config.dependencies().is_some() {
if file_has_dependencies {
bail!(
"Multiple sections with `[project]` dependencies in the same file are not allowed. \
External dependencies must be specified in a single top-level configuration block."
);
}
file_has_dependencies = true;
}
Ok(())
})
}
#[cfg(test)]
mod tests {
use ruff_python_trivia::textwrap::dedent;
#[test]
fn multiple_sections_with_dependencies_not_allowed() {
let source = dedent(
r#"
# First section
```toml
[project]
dependencies = ["pydantic==2.12.2"]
```
```py
x = 1
```
# Second section
```toml
[project]
dependencies = ["numpy==2.0.0"]
```
```py
y = 2
```
"#,
);
let err = super::parse("file.md", &source).expect_err("Should fail to parse");
assert_eq!(
err.to_string(),
"Multiple sections with `[project]` dependencies in the same file are not allowed. \
External dependencies must be specified in a single top-level configuration block."
);
}
}