Skip to content

[ruff] Detect syntax errors in individual notebook cells#26419

Merged
MichaReiser merged 8 commits into
astral-sh:mainfrom
omar-y-abdi:notebook-cell-syntax-errors
Jul 7, 2026
Merged

[ruff] Detect syntax errors in individual notebook cells#26419
MichaReiser merged 8 commits into
astral-sh:mainfrom
omar-y-abdi:notebook-cell-syntax-errors

Conversation

@omar-y-abdi

@omar-y-abdi omar-y-abdi commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes part of #23782.

Ruff concatenates all notebook cells into a single source before parsing, so a syntax error confined to one cell can be silently completed by the next cell's content.

Example: if True: in one cell with its body in the next, an unclosed bracket, an unterminated triple-quoted string or f-string, a line continuation, or a decorator whose def is in the following cell.

This parses each cell as its own module and combines the parsed modules into a single module via parse_cells_unchecked, using new_starts_at so the nodes keep their offsets into the concatenated source.

A cell is therefore valid only if it parses standalone, matching how Jupyter executes cells, while definitions from earlier cells stay visible to later cells because the combined module keeps every cell's statements in order.

Each cell's range stops before the newline that Ruff inserts between cells, so a syntax error anchored at the cell's trailing end lands on that separator, the cell's own last line, and is reported where it occurs rather than in the following cell. This covers a syntax error at a cell boundary, an unclosed bracket, an unterminated string, or a decorator whose def is in the next cell.

Regular files are unaffected, they take the existing single-parse path, and notebooks are still parsed in a single linear pass. The linter, language server, and ruff_dev print-tokens share this path so editor and test output match the CLI.

Because each cell is parsed without its trailing separator newline, the combined token stream bridges every internal cell boundary with a NonLogicalNewline so the boundary tokens don't produce spurious blank-line or whitespace diagnostics (E301-E303, E305, E306, W291/W293/W391); a notebook test asserts this.

A boundary Dedent can also share its offset with a suppression comment opening the next cell, which made range-suppression handling (# ruff: disable/enable) loop indefinitely; it now splits the token stream on end <= offset so the comment comes first, with a notebook test covering it.

ty (ruff_db) parses notebooks through the same per-cell path, so the type checker and its language server report these errors in the same place.

Test plan

  • cargo test -p ruff_python_parser notebook
  • cargo test -p ruff_linter notebook
  • cargo test -p ty_python_semantic notebook
  • ./target/debug/ruff check <split-cell .ipynb>: the error is now reported

Ruff concatenates all notebook cells into a single source before parsing, so a
syntax error confined to one cell can be silently completed by a following cell.

Parse each cell as its own module and combine the parsed modules into one module
via parse_unchecked_module_ranges, using new_starts_at so nodes keep their
offsets into the concatenated source. A cell is valid only if it parses
standalone, while definitions from earlier cells stay visible to later cells
because the combined module keeps every cell's statements in order.

Regular files are unaffected (unchanged single-parse path); notebooks are parsed
in a single linear pass, each cell once, with no second parse. ty
still concatenates and is left as a follow-up.
@astral-sh-bot

astral-sh-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

Memory usage report

Memory usage unchanged ✅

@astral-sh-bot

astral-sh-bot Bot commented Jun 26, 2026

Copy link
Copy Markdown

ecosystem-analyzer results

No diagnostic changes detected ✅

Flaky changes detected. This PR summary excludes flaky changes; see the HTML report for details.

Full report with detailed diff (timing results)

@astral-sh-bot

astral-sh-bot Bot commented Jun 26, 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.

@MichaReiser MichaReiser added bug Something isn't working notebook Related to (Jupyter) notebooks labels Jun 27, 2026

@MichaReiser MichaReiser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you.

There are a few more places that need changing:

  • We need to search all references to ruff_python_parser::parse_unchecked and make sure they call the new API for notebooks

  • It also seems that this new parsing now results in an infinite loop in the ruff:ignore handling, we should take a look at why

    # Cell 1
    def f():
        pass
    
    # Cell 2
    # ruff: disable[F401]
    
    # Cell 3
    import os

Comment thread crates/ruff_linter/src/linter.rs Outdated
@omar-y-abdi omar-y-abdi requested a review from MichaReiser July 1, 2026 08:43

@MichaReiser MichaReiser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you. I think there's one more case that we need to handle to truly close the linked issue

Comment thread crates/ruff_python_parser/src/parser/tests.rs Outdated
Each cell is parsed from a slice that ends at the next cell's start, so an error
anchored at that trailing EOF (an unclosed bracket, or a decorator whose def is
in the next cell) was attributed to the following cell. Anchor such errors at
the cell's last offset so they are reported in the cell that contains them.

Route ty (ruff_db) through the same per-cell path so the type checker surfaces
these errors too, and add a notebook mdtest covering the rendered output.
@omar-y-abdi omar-y-abdi requested review from a team as code owners July 4, 2026 16:06
Comment thread crates/ruff_python_parser/src/parser/tests.rs Outdated
@omar-y-abdi omar-y-abdi requested a review from MichaReiser July 4, 2026 16:40

@MichaReiser MichaReiser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you. I'd prefer if we don't need to manually adjust the error ranges. I think we can accomplish this by using the \n separator that our notebooks insert.

Comment thread crates/ruff_python_parser/src/lib.rs Outdated
@omar-y-abdi

Copy link
Copy Markdown
Contributor Author

@MichaReiser Sounds good, switching to the separator approach.

CellOffsets already exposes content_ranges(), which gives each cell up to but not including its trailing \n, with the next cell starting after it, exactly the ranges you describe.

Passing those into parse_cells_unchecked lets me drop the manual error adjustment: the per-cell EOF then lands on the separator \n, which is the cell's own last line, rather than the next cell's start offset, so it is attributed to the right cell.

I'll document the single-byte \n separator as a requirement on parse_cells_unchecked.

For the token stream, the content slice hides the \n from the lexer, so I'll confirm the concatenated tokens still line up and only add a trailing Newline over the separator if a test needs it.

Watching the boundary Dedent and suppression-comment case in particular.

I'll commit the changes soon.

@omar-y-abdi

omar-y-abdi commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

@MichaReiser Done, switched to the separator approach.

I documented the single-byte \n separator as a requirement on parse_cells_unchecked.

The per-cell parse no longer lexes that separator, so I bridge each internal cell boundary with a NonLogicalNewline over it, which keeps the blank-line rules from misreading the boundary.

Full test suite for the touched crates is green.

@omar-y-abdi omar-y-abdi requested a review from MichaReiser July 6, 2026 16:38
omar-y-abdi and others added 3 commits July 6, 2026 18:40
Parse each cell from its content range, which excludes the trailing `\n`
separator, instead of relocating errors after the fact. A per-cell EOF error
(an unclosed bracket, or a decorator whose `def` is in the next cell) then lands
on the separator, the cell's own last line, and is attributed to the right cell
without mutating the error range.

Because the per-cell parse no longer lexes the separator, bridge each internal
cell boundary with a `NonLogicalNewline` so token-based checks such as the
blank-line rules don't misread it. Document the single-byte `\n` separator
requirement on `parse_cells_unchecked`.

@MichaReiser MichaReiser left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you

@MichaReiser MichaReiser merged commit 311158e into astral-sh:main Jul 7, 2026
62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working notebook Related to (Jupyter) notebooks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants