Allow passing ParseOptions to inline tests (#16357)

## Summary

This PR adds support for a pragma-style header for inline parser tests
containing JSON-serialized `ParseOptions`. For example,

```python
# parse_options: { "target-version": "3.9" }
match 2:
    case 1:
        pass
```

The line must start with `# parse_options: ` and then the rest of the
(trimmed) line is deserialized into `ParseOptions` used for parsing the
the test.

## Test Plan

Existing inline tests, plus two new inline tests for
`match-before-py310`.

---------

Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
This commit is contained in:
Brent Westbrook 2025-02-27 10:23:15 -05:00 committed by GitHub
parent 568cf88c6c
commit 764aa0e6a1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 238 additions and 7 deletions

View file

@ -5,7 +5,7 @@ use std::path::Path;
use ruff_annotate_snippets::{Level, Renderer, Snippet};
use ruff_python_ast::visitor::source_order::{walk_module, SourceOrderVisitor, TraversalSignal};
use ruff_python_ast::{AnyNodeRef, Mod};
use ruff_python_ast::{AnyNodeRef, Mod, PythonVersion};
use ruff_python_parser::{parse_unchecked, Mode, ParseErrorType, ParseOptions, Token};
use ruff_source_file::{LineIndex, OneIndexed, SourceCode};
use ruff_text_size::{Ranged, TextLen, TextRange, TextSize};
@ -34,9 +34,14 @@ fn inline_err() {
/// Snapshots the AST.
fn test_valid_syntax(input_path: &Path) {
let source = fs::read_to_string(input_path).expect("Expected test file to exist");
let parsed = parse_unchecked(&source, ParseOptions::from(Mode::Module));
let options = extract_options(&source).unwrap_or_else(|| {
ParseOptions::from(Mode::Module).with_target_version(PythonVersion::latest())
});
let parsed = parse_unchecked(&source, options);
if !parsed.is_valid() {
let is_valid = parsed.is_valid() && parsed.unsupported_syntax_errors().is_empty();
if !is_valid {
let line_index = LineIndex::from_source_text(&source);
let source_code = SourceCode::new(&source, &line_index);
@ -55,6 +60,19 @@ fn test_valid_syntax(input_path: &Path) {
.unwrap();
}
for error in parsed.unsupported_syntax_errors() {
writeln!(
&mut message,
"{}\n",
CodeFrame {
range: error.range,
error: &ParseErrorType::OtherError(error.to_string()),
source_code: &source_code,
}
)
.unwrap();
}
panic!("{input_path:?}: {message}");
}
@ -78,10 +96,15 @@ fn test_valid_syntax(input_path: &Path) {
/// Snapshots the AST and the error messages.
fn test_invalid_syntax(input_path: &Path) {
let source = fs::read_to_string(input_path).expect("Expected test file to exist");
let parsed = parse_unchecked(&source, ParseOptions::from(Mode::Module));
let options = extract_options(&source).unwrap_or_else(|| {
ParseOptions::from(Mode::Module).with_target_version(PythonVersion::latest())
});
let parsed = parse_unchecked(&source, options);
let is_valid = parsed.is_valid() && parsed.unsupported_syntax_errors().is_empty();
assert!(
!parsed.is_valid(),
!is_valid,
"{input_path:?}: Expected parser to generate at least one syntax error for a program containing syntax errors."
);
@ -92,11 +115,13 @@ fn test_invalid_syntax(input_path: &Path) {
writeln!(&mut output, "## AST").unwrap();
writeln!(&mut output, "\n```\n{:#?}\n```", parsed.syntax()).unwrap();
writeln!(&mut output, "## Errors\n").unwrap();
let line_index = LineIndex::from_source_text(&source);
let source_code = SourceCode::new(&source, &line_index);
if !parsed.errors().is_empty() {
writeln!(&mut output, "## Errors\n").unwrap();
}
for error in parsed.errors() {
writeln!(
&mut output,
@ -110,6 +135,23 @@ fn test_invalid_syntax(input_path: &Path) {
.unwrap();
}
if !parsed.unsupported_syntax_errors().is_empty() {
writeln!(&mut output, "## Unsupported Syntax Errors\n").unwrap();
}
for error in parsed.unsupported_syntax_errors() {
writeln!(
&mut output,
"{}\n",
CodeFrame {
range: error.range,
error: &ParseErrorType::OtherError(error.to_string()),
source_code: &source_code,
}
)
.unwrap();
}
insta::with_settings!({
omit_expression => true,
input_file => input_path,
@ -119,6 +161,53 @@ fn test_invalid_syntax(input_path: &Path) {
});
}
/// Copy of [`ParseOptions`] for deriving [`Deserialize`] with serde as a dev-dependency.
#[derive(serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct JsonParseOptions {
#[serde(default)]
mode: JsonMode,
#[serde(default)]
target_version: PythonVersion,
}
/// Copy of [`Mode`] for deserialization.
#[derive(Default, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
enum JsonMode {
#[default]
Module,
Expression,
ParenthesizedExpression,
Ipython,
}
impl From<JsonParseOptions> for ParseOptions {
fn from(value: JsonParseOptions) -> Self {
let mode = match value.mode {
JsonMode::Module => Mode::Module,
JsonMode::Expression => Mode::Expression,
JsonMode::ParenthesizedExpression => Mode::ParenthesizedExpression,
JsonMode::Ipython => Mode::Ipython,
};
Self::from(mode).with_target_version(value.target_version)
}
}
/// Extract [`ParseOptions`] from an initial pragma line, if present.
///
/// For example,
///
/// ```python
/// # parse_options: { "target-version": "3.10" }
/// def f(): ...
fn extract_options(source: &str) -> Option<ParseOptions> {
let header = source.lines().next()?;
let (_label, options) = header.split_once("# parse_options: ")?;
let options: Option<JsonParseOptions> = serde_json::from_str(options.trim()).ok();
options.map(ParseOptions::from)
}
// Test that is intentionally ignored by default.
// Use it for quickly debugging a parser issue.
#[test]