mirror of
https://github.com/astral-sh/ruff.git
synced 2026-05-06 08:56:57 -04:00
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:
Generated
+31
-12
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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);
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
})?;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user