Start detecting version-related syntax errors in the parser (#16090)

## Summary

This PR builds on the changes in #16220 to pass a target Python version
to the parser. It also adds the `Parser::unsupported_syntax_errors` field, which
collects version-related syntax errors while parsing. These syntax
errors are then turned into `Message`s in ruff (in preview mode).

This PR only detects one syntax error (`match` statement before Python
3.10), but it has been pretty quick to extend to several other simple
errors (see #16308 for example).

## Test Plan

The current tests are CLI tests in the linter crate, but these could be
supplemented with inline parser tests after #16357.

I also tested the display of these syntax errors in VS Code:


![image](https://github.com/user-attachments/assets/062b4441-740e-46c3-887c-a954049ef26e)

![image](https://github.com/user-attachments/assets/101f55b8-146c-4d59-b6b0-922f19bcd0fa)

---------

Co-authored-by: Alex Waygood <alex.waygood@gmail.com>
This commit is contained in:
Brent Westbrook 2025-02-25 23:03:48 -05:00 committed by GitHub
parent b39a4ad01d
commit 78806361fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 356 additions and 37 deletions

View file

@ -10,10 +10,10 @@ use rustc_hash::FxHashMap;
use ruff_diagnostics::Diagnostic;
use ruff_notebook::Notebook;
use ruff_python_ast::{ModModule, PySourceType};
use ruff_python_ast::{ModModule, PySourceType, PythonVersion};
use ruff_python_codegen::Stylist;
use ruff_python_index::Indexer;
use ruff_python_parser::{ParseError, Parsed};
use ruff_python_parser::{ParseError, ParseOptions, Parsed, UnsupportedSyntaxError};
use ruff_source_file::SourceFileBuilder;
use ruff_text_size::Ranged;
@ -71,6 +71,7 @@ pub fn check_path(
source_kind: &SourceKind,
source_type: PySourceType,
parsed: &Parsed<ModModule>,
target_version: PythonVersion,
) -> Vec<Diagnostic> {
// Aggregate all diagnostics.
let mut diagnostics = vec![];
@ -104,8 +105,6 @@ pub fn check_path(
));
}
let target_version = settings.resolve_target_version(path);
// Run the filesystem-based rules.
if settings
.rules
@ -335,7 +334,8 @@ pub fn add_noqa_to_path(
settings: &LinterSettings,
) -> Result<usize> {
// Parse once.
let parsed = ruff_python_parser::parse_unchecked_source(source_kind.source_code(), source_type);
let target_version = settings.resolve_target_version(path);
let parsed = parse_unchecked_source(source_kind, source_type, target_version);
// Map row and column locations to byte slices (lazily).
let locator = Locator::new(source_kind.source_code());
@ -367,6 +367,7 @@ pub fn add_noqa_to_path(
source_kind,
source_type,
&parsed,
target_version,
);
// Add any missing `# noqa` pragmas.
@ -393,7 +394,8 @@ pub fn lint_only(
source_type: PySourceType,
source: ParseSource,
) -> LinterResult {
let parsed = source.into_parsed(source_kind, source_type);
let target_version = settings.resolve_target_version(path);
let parsed = source.into_parsed(source_kind, source_type, target_version);
// Map row and column locations to byte slices (lazily).
let locator = Locator::new(source_kind.source_code());
@ -425,12 +427,20 @@ pub fn lint_only(
source_kind,
source_type,
&parsed,
target_version,
);
let syntax_errors = if settings.preview.is_enabled() {
parsed.unsupported_syntax_errors()
} else {
&[]
};
LinterResult {
messages: diagnostics_to_messages(
diagnostics,
parsed.errors(),
syntax_errors,
path,
&locator,
&directives,
@ -443,6 +453,7 @@ pub fn lint_only(
fn diagnostics_to_messages(
diagnostics: Vec<Diagnostic>,
parse_errors: &[ParseError],
unsupported_syntax_errors: &[UnsupportedSyntaxError],
path: &Path,
locator: &Locator,
directives: &Directives,
@ -461,6 +472,9 @@ fn diagnostics_to_messages(
parse_errors
.iter()
.map(|parse_error| Message::from_parse_error(parse_error, locator, file.deref().clone()))
.chain(unsupported_syntax_errors.iter().map(|syntax_error| {
Message::from_unsupported_syntax_error(syntax_error, file.deref().clone())
}))
.chain(diagnostics.into_iter().map(|diagnostic| {
let noqa_offset = directives.noqa_line_for.resolve(diagnostic.start());
Message::from_diagnostic(diagnostic, file.deref().clone(), noqa_offset)
@ -491,11 +505,12 @@ pub fn lint_fix<'a>(
// Track whether the _initial_ source code is valid syntax.
let mut is_valid_syntax = false;
let target_version = settings.resolve_target_version(path);
// Continuously fix until the source code stabilizes.
loop {
// Parse once.
let parsed =
ruff_python_parser::parse_unchecked_source(transformed.source_code(), source_type);
let parsed = parse_unchecked_source(&transformed, source_type, target_version);
// Map row and column locations to byte slices (lazily).
let locator = Locator::new(transformed.source_code());
@ -527,6 +542,7 @@ pub fn lint_fix<'a>(
&transformed,
source_type,
&parsed,
target_version,
);
if iterations == 0 {
@ -573,11 +589,18 @@ pub fn lint_fix<'a>(
report_failed_to_converge_error(path, transformed.source_code(), &diagnostics);
}
let syntax_errors = if settings.preview.is_enabled() {
parsed.unsupported_syntax_errors()
} else {
&[]
};
return Ok(FixerResult {
result: LinterResult {
messages: diagnostics_to_messages(
diagnostics,
parsed.errors(),
syntax_errors,
path,
&locator,
&directives,
@ -680,16 +703,35 @@ pub enum ParseSource {
impl ParseSource {
/// Consumes the [`ParseSource`] and returns the parsed [`Parsed`], parsing the source code if
/// necessary.
fn into_parsed(self, source_kind: &SourceKind, source_type: PySourceType) -> Parsed<ModModule> {
fn into_parsed(
self,
source_kind: &SourceKind,
source_type: PySourceType,
target_version: PythonVersion,
) -> Parsed<ModModule> {
match self {
ParseSource::None => {
ruff_python_parser::parse_unchecked_source(source_kind.source_code(), source_type)
}
ParseSource::None => parse_unchecked_source(source_kind, source_type, target_version),
ParseSource::Precomputed(parsed) => parsed,
}
}
}
/// Like [`ruff_python_parser::parse_unchecked_source`] but with an additional [`PythonVersion`]
/// argument.
fn parse_unchecked_source(
source_kind: &SourceKind,
source_type: PySourceType,
target_version: PythonVersion,
) -> Parsed<ModModule> {
let options = ParseOptions::from(source_type).with_target_version(target_version);
// SAFETY: Safe because `PySourceType` always parses to a `ModModule`. See
// `ruff_python_parser::parse_unchecked_source`. We use `parse_unchecked` (and thus
// have to unwrap) in order to pass the `PythonVersion` via `ParseOptions`.
ruff_python_parser::parse_unchecked(source_kind.source_code(), options)
.try_into_module()
.expect("PySourceType always parses into a module")
}
#[cfg(test)]
mod tests {
use std::path::Path;