Skip to content

[syntax-errors] Name is parameter and nonlocal - #27628

Merged
ntBre merged 5 commits into
astral-sh:mainfrom
WhiteFox0-0:name_is_param_and_nonlocal
Aug 13, 2026
Merged

ntBre merged 5 commits into
astral-sh:mainfrom
WhiteFox0-0:name_is_param_and_nonlocal

Conversation

@WhiteFox0-0

Copy link
Copy Markdown
Contributor

Summary

Part of #17412
Detects semantic syntax error where name is parameter and nonlocal

Test Plan

Added tests in nonlocal_parameter.py

@ntBre
ntBre requested review from ntBre and removed request for MichaReiser and dhruvmanila August 10, 2026 13:06
@astral-sh-bot

astral-sh-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

Memory usage report

Memory usage unchanged ✅

@astral-sh-bot

astral-sh-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Full report with detailed diff (timing results)

@astral-sh-bot

astral-sh-bot Bot commented Aug 10, 2026

Copy link
Copy Markdown

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

@ntBre ntBre left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, this looks good on the Ruff side, but I think we need to check how ty handles some duplicate diagnostics. I'd also prefer if we could use mdtests on the Ruff side too.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's use mdtests for this:

ruff/CONTRIBUTING.md

Lines 233 to 240 in bd2d6f6

#### Rule testing: fixtures and snapshots
To test rules, Ruff uses the mdtest framework, initially developed for ty. Mdtests are written as
Markdown files with Python code and TOML configuration blocks alongside prose
descriptions. Generally, there will be one directory per linter (e.g. `flake8-bandit` for the `S`
rules) with nested files for each rule (e.g. `unsafe-markup-use.md` for `S704`). Within these
files, you can define additional Markdown sections to group related tests and their settings
together.

We actually already have a file for this in ty:

## name cannot refer to a parameter and a global variable

And we could now add one in Ruff, maybe at crates/ruff_linter/resources/mdtest/invalid-syntax/nonlocal-parameter.md.

Speaking of ty, we may also need to check where it implements some of these checks. It already emits two diagnostics on this code:

def f(a):
    nonlocal a

Playground

I think this PR will make it emit a third.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the no binding for nonlocal ``a`` found comes from

if !ctx.in_module_scope() {
for name in names {
if !ctx.has_nonlocal_binding(name) {
Self::add_error(
ctx,
SemanticSyntaxErrorKind::NonlocalWithoutBinding(name.to_string()),
name.range,
);
}
}

this is where i updated

for name in names {
    if ctx.is_bound_parameter(name) {
        Self::add_error(
            ctx,
            SemanticSyntaxErrorKind::NonlocalParameter(name.to_string()),
            name.range,
        );
    }

    if !ctx.has_nonlocal_binding(name) {
        Self::add_error(
            ctx,
            SemanticSyntaxErrorKind::NonlocalWithoutBinding(name.to_string()),
            name.range,
        );
    }
}

And following causes name a is used prior to nonlocal declaration

if symbol.is_bound() || symbol.is_declared() || symbol.is_used() {
self.report_semantic_error(SemanticSyntaxError {
kind: SemanticSyntaxErrorKind::LoadBeforeNonlocalDeclaration {
name: name.to_string(),
start: name.range.start(),
},
range: name.range,
python_version: self.python_version(),
});
}

After i added !symbol.is_parameter()

if (symbol.is_bound() || symbol.is_declared() || symbol.is_used()) 
    && !symbol.is_parameter()

it throws 2 errors

[invalid-syntax] "name `a` cannot refer to a parameter and a nonlocal variable"
[invalid-syntax] "no binding for nonlocal `a` found"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have added the mdtest in ty.
I tried adding mdtest in ruff crates/ruff_linter/resources/mdtest/invalid-syntax/nonlocal-parameter.md but got unmatched assertion: snapshot: invalid-syntax maybe i am missing something

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried adding mdtest in ruff

Oops, yeah that's my bad. Unfortunately the Ruff mdtest runner filters out syntax errors. I should probably fix that at some point. Thanks for trying!

On the ty side, it looks like the "no binding for nonlocal a found` actually comes from this code:

if matches!(declaration.kind, GlobalOrNonlocal::Nonlocal) {
self.report_semantic_error(SemanticSyntaxError {
kind: SemanticSyntaxErrorKind::NonlocalWithoutBinding(name.to_string()),
range: declaration.range,
python_version: self.python_version(),
});
}

which we can skip with a patch like this (which also covers the other is_parameter check):

                         .record_expression(name, self.current_scope());
                     let symbol_id = self.add_symbol(name.id.clone());
                     let symbol = self.current_place_table().symbol(symbol_id);
+                    // The semantic checker already reports that parameters cannot be nonlocal.
+                    if symbol.is_parameter() {
+                        continue;
+                    }
                     // Check whether the variable has already been accessed in this scope.
-                    if (symbol.is_bound() || symbol.is_declared() || symbol.is_used())
-                        && !symbol.is_parameter()
-                    {
+                    if symbol.is_bound() || symbol.is_declared() || symbol.is_used() {
                         self.report_semantic_error(SemanticSyntaxError {
                             kind: SemanticSyntaxErrorKind::LoadBeforeNonlocalDeclaration {
                                 name: name.to_string(),

but I think we'll need a ty reviewer to verify whether that's the correct behavior. It seems preferable to me to match CPython and only emit the parameter and nonlocal error:

>>> def f(a):
...     nonlocal a
...
  File "<python-input-0>", line 2
    nonlocal a
    ^^^^^^^^^^
SyntaxError: name 'a' is parameter and nonlocal

but I'm not quite sure if there are other implications of skipping the rest of the loop like in this patch. It at least doesn't seem to break any existing tests when I tried it locally.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CPython and only emit the parameter and nonlocal

I did look at this but i thought that Cpython emits only the first error it encounters rather than continue.
but as far as i know we do check for other encounters and report them too

I looked at the parameter and global implementation for the same it also emits the binding error so fi we are to change that for nonlocal should also look to change it for parameter and global

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, I guess you're right. The name is used prior to nonlocal declaration overlaps directly with parameter and nonlocal, but you could fix the parameter issue and still have no binding for nonlocal so it seems okay to emit both. Thanks for pushing back!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that makes sense, so is there anything else that needs to be done

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I think this looks good then, thank you!

@WhiteFox0-0
WhiteFox0-0 requested review from a team as code owners August 11, 2026 11:57
Comment thread crates/ty_python_semantic/resources/mdtest/diagnostics/semantic_syntax_errors.md Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried adding mdtest in ruff

Oops, yeah that's my bad. Unfortunately the Ruff mdtest runner filters out syntax errors. I should probably fix that at some point. Thanks for trying!

On the ty side, it looks like the "no binding for nonlocal a found` actually comes from this code:

if matches!(declaration.kind, GlobalOrNonlocal::Nonlocal) {
self.report_semantic_error(SemanticSyntaxError {
kind: SemanticSyntaxErrorKind::NonlocalWithoutBinding(name.to_string()),
range: declaration.range,
python_version: self.python_version(),
});
}

which we can skip with a patch like this (which also covers the other is_parameter check):

                         .record_expression(name, self.current_scope());
                     let symbol_id = self.add_symbol(name.id.clone());
                     let symbol = self.current_place_table().symbol(symbol_id);
+                    // The semantic checker already reports that parameters cannot be nonlocal.
+                    if symbol.is_parameter() {
+                        continue;
+                    }
                     // Check whether the variable has already been accessed in this scope.
-                    if (symbol.is_bound() || symbol.is_declared() || symbol.is_used())
-                        && !symbol.is_parameter()
-                    {
+                    if symbol.is_bound() || symbol.is_declared() || symbol.is_used() {
                         self.report_semantic_error(SemanticSyntaxError {
                             kind: SemanticSyntaxErrorKind::LoadBeforeNonlocalDeclaration {
                                 name: name.to_string(),

but I think we'll need a ty reviewer to verify whether that's the correct behavior. It seems preferable to me to match CPython and only emit the parameter and nonlocal error:

>>> def f(a):
...     nonlocal a
...
  File "<python-input-0>", line 2
    nonlocal a
    ^^^^^^^^^^
SyntaxError: name 'a' is parameter and nonlocal

but I'm not quite sure if there are other implications of skipping the rest of the loop like in this patch. It at least doesn't seem to break any existing tests when I tried it locally.

@ntBre ntBre added the rule Implementing or modifying a lint rule label Aug 12, 2026
def g(a):
if True:
nonlocal a # snapshot: invalid-syntax
nonlocal a # error: [invalid-syntax]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change does not look desirable. This mdtest is in the diagnostics/ subdirectory, so its entire purpose is to snapshot the diagnostic and ensure it looks the way we want it to. I don't think we should replace snapshot: with simply error: here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh sorry, that was my bad recommendation then. I thought one snapshot would suffice but didn't notice the directory. I may have put this file in the wrong directory to begin with...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh! Sorry, I may not have looked closely enough here. If you created this file and these extra snapshots are redundant, go ahead. (But you may be right that ideally we'd split diagnostics/semantic_syntax_errors.md into two files, where this one is fully focused on how the diagnostics look, and another file handles all the semantic edge cases.)

@ntBre ntBre left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! This looks good to me. I can follow up on the ty mdtest placement if needed.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No I think this looks good then, thank you!

@astral-sh-bot

astral-sh-bot Bot commented Aug 13, 2026

Copy link
Copy Markdown

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 97.14%. The percentage of expected errors that received a diagnostic held steady at 93.44%. The number of fully passing files held steady at 107/137.

@ntBre
ntBre merged commit d332d20 into astral-sh:main Aug 13, 2026
66 checks passed
@WhiteFox0-0
WhiteFox0-0 deleted the name_is_param_and_nonlocal branch August 13, 2026 18:04
@WhiteFox0-0
WhiteFox0-0 restored the name_is_param_and_nonlocal branch August 14, 2026 11:12
@WhiteFox0-0
WhiteFox0-0 deleted the name_is_param_and_nonlocal branch August 14, 2026 11:13
George-Ogden pushed a commit to George-Ogden/ruff that referenced this pull request Aug 16, 2026
## Summary
Part of astral-sh#17412
Detects semantic syntax error where name is parameter and nonlocal

## Test Plan
Added tests in `nonlocal_parameter.py`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

rule Implementing or modifying a lint rule

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants