Skip to content

Grammar fixes for additional Cython syntax patterns - #19

Open
devdanzin wants to merge 20 commits into
b0o:masterfrom
devdanzin:fix/grammar-gaps
Open

devdanzin wants to merge 20 commits into
b0o:masterfrom
devdanzin:fix/grammar-gaps

Conversation

@devdanzin

Copy link
Copy Markdown

Summary

We're building a tool that needs to parse Cython into ASTs and found your parser. Along the way we added support for some syntax patterns we ran into in real-world Cython code and would like to share it back.

Disclosure: this branch was implemented by Claude Code (an AI coding assistant) with human review. The PR description was also drafted by Claude. Happy to discuss anything in detail.

What's in this PR

19 fix commits + 1 chore, each atomic (one grammar change per commit, with a corpus test). Grouped by area:

Type-system declarations

  • bc1578c C tuple types (T1, T2, ...) in cdef return types, parameters, ctypedef, casts, sizeof
  • 887a676 doubled-const declarations const char* const* X in extern blocks
  • 8779f6c 1-tuple form (T,) and trailing commas in tuple types
  • 114827d cppclass template defaults [K, V=int]
  • 7b469a1 (part of) array sizes with constant expressions int arr[N+1]; soft-keyword type names cdef type cls

Parameter forms

  • 7321edc Python-style annotated parameter with default def f(x: int = 1)
  • 7b469a1 (part of) hybrid C type + Python annotation Comm comm: Comm | None = None
  • 1924b7f not None / or None constraint combined with default value
  • 2322629 =? default sentinel for .pxd files

Function-pointer & exception clauses

  • 746e80b gil_spec and exception_value on function-pointer ctypedef (e.g. except? -1)
  • 4e18172 gil_spec and exception_value on cast-to-function-pointer (e.g. <T(*)(args) noexcept nogil>)
  • 4331559 except +* shorthand for C++ exception conversion

C++ extern expansion (.pxd files)

  • e76d7ff enum aliases cdef enum Type "ns::Type":, cppclass inheritance (Base), destructor names ~ClassName(), method aliases int method "renamed"(args), decorators on cppclass members
  • 14f2e47 nested struct/union/enum inside cppclass bodies
  • 3a672da free-function operator== etc. in extern blocks
  • b2f6fd0 check_size <ident> directive in [object T, ...] external_definition
  • 104d94c trailing comma in external_definition

Lexical tweaks

  • b474449 type as a soft keyword (usable as identifier)
  • 1924b7f (part of) new as a soft keyword
  • b420027 storageclass on ctypedef function-pointer (ctypedef public int (*F)(int))
  • 232d3a8 optional trailing ; on declarations

Repo tooling

  • ece06d5 bump tree-sitter-cli pin to 0.25.10. The committed src/parser.c was at LANGUAGE_VERSION 15 but package.json pinned ^0.24.3 (which emits LANGUAGE_VERSION 14). On a fresh checkout npm test fails with Incompatible language version 15. Expected minimum 13, maximum 14. Aligning the dev pin with the parser already in the tree fixes the build.

Validation

  • 154 existing corpus tests still pass.
  • 25 new corpus tests added.
  • Highlight tests (184 assertions) and tag tests (5 assertions) still pass.

Known not fixed

A few patterns we tried but couldn't land cleanly without breaking existing corpus tests:

  • Tuple types in Python-style def parameters (def f((int, double) xy)): collides with tuple_pattern in your lambdas and Default Tuple Arguments corpus tests. The cdef/cpdef route already handles tuple-typed parameters via a separate path.
  • Redundant extern inside cdef extern from blocks (extern int func()): adding extern as a storageclass produces an LR conflict with the outer cdef extern form.
  • C-style "function as parameter" without * (void kernel(args, ...)): structurally indistinguishable from typed parameters; broke 5+ corpus tests in our attempts.
  • function[double(double, int) noexcept] (function-signature as a template argument): would need type_index to accept function-like type expressions; we couldn't disambiguate cleanly.

Happy to revise

If you'd prefer this split into smaller PRs, we have natural groupings (the section headers above are good cut-lines) and can re-organize. Equally happy to revise individual commits, drop any you'd reject, or rework the approach in any other way. Just let us know what works for your review style.

Thanks for the great parser!

devdanzin and others added 20 commits April 27, 2026 05:51
The committed src/parser.c at HEAD was generated against ABI 15, but
package.json pinned tree-sitter-cli ^0.24.3 (which emits ABI 14).
That mismatch made `npm test` fail on a fresh checkout with
"Incompatible language version 15. Expected minimum 13, maximum 14".

Bumping the dev pin to ^0.25.10 aligns the tooling with the parser
already in the tree. No grammar or behavioral change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython's C tuple types ``(T1, T2, ...)`` were rejected anywhere a c_type
appears: as cdef/cpdef return types and parameter types, in ctypedef
declarations, in cast expressions, in sizeof, and in cdef extern from
blocks. The fix adds a new c_tuple_type rule and threads it through the
positions that need it.

c_tuple_type is *not* folded into c_type itself, because that puts it on
the path of typed_parameter (Cython's override sitting in Python's
parameter choice), where (a, b) collides with tuple_pattern in cases
like ``lambda (a, b): ...`` and ``def f((a, b)=v):``. Instead it's added
as an explicit alternative to:

  * maybe_typed_name — cdef return type, parameter type, variable decl
  * cvar_decl — ctypedef and cdef extern from declarations
  * cast_expression — ``<(T1, T2)>x``
  * sizeof_expression — ``sizeof((T1, T2))``

This matches the positions where real Cython code uses tuple types
(verified against h3-py and the cext-review-toolkit calibration corpus).

Real-world impact: closes the parse cascade in h3-py's _cy/{vertex,
util,edges,latlng,cells}.pyx, which collectively went from 47 ERROR
nodes to 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython 3 supports Python-style ``x: T = default`` parameters in def
functions, but Cython's override of typed_default_parameter only
defined the C-style ``T x = default`` form, dropping the Python
annotation form from the parent grammar.

Mirrors the structure of typed_parameter, which already has both forms
as a choice. Either form is accepted; the Python-style branch produces
the same AST shape as the original tree-sitter-python grammar.

Real-world impact: closes lxml/etree.pyx:1382-style declarations like
``def index(self, child: _Element, start: int = None, stop: int = None):``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``ctypedef public T (*name)(...)`` failed to parse because
c_function_pointer required a c_type at its head, with no slot for the
public/api/inline storageclass that Cython accepts on ctypedefs.
Other ctypedef forms accepted post-ctypedef storageclasses through
cvar_decl, but the function-pointer branch was missing it.

Adding ``repeat($.storageclass)`` mirrors the existing prefix on
cvar_decl and on struct/enum.

Real-world impact: closes lxml/etree.pyx:481-style declarations like
``ctypedef public xmlNode* (*_node_to_node_function)(xmlNode*)``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython tolerates a C-style trailing semicolon on declarations, but the
grammar required none. Added ``optional(';')`` before ``$._newline`` in
the variable-declaration branches of cvar_def and cvar_decl, and on the
declaration-only branch of c_function_definition.

This handles three real patterns:

  * ``cdef int64_t x;`` inside a def body (cvar_def)
  * ``int func(int a);`` inside cdef extern from (c_function_definition)
  * ``cdef int x; cdef int y;`` two cdef on one line (works as a
    side effect — the optional ';' eats the separator)

Real-world impact: closes blosc2/blosc2_ext.pyx-style declarations with
trailing semicolons (excerpts that contain a complete extern block now
parse cleanly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``const char* const* X "alias"`` failed because the parser greedily
took the second ``*`` as a c_name type_modifier prefix instead of
extending the c_type's modifier list. After committing to c_name, no
required identifier remained and the declaration errored out.

The c_name rule's ``optional($.type_modifier)`` prefix was unused —
no corpus test exercised it, and any modifier can equivalently appear
as a trailing modifier on c_type. Removing it (c_name is now just
$.identifier) makes c_type's ``repeat($.type_modifier)`` consume the
entire modifier chain, leaving the identifier slot for the actual
declared name.

Real-world impact: closes lxml/etree.pyx:349-style declarations like
``const char* const* _LXML_LIB_FEATURES "_lxml_lib_features"``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adding ``type`` to keyword_identifier alongside match/async/await/api
makes it usable as a regular identifier when the surrounding context
isn't a type-alias statement (Python 3.12's ``type X = T`` form).

Without this, statements like ``type, value, traceback = self._exc_info``
(common in Python-2-compatible exception handling) and plain
``type = something`` failed: the grammar's filter dropped the
[type_alias_statement, primary_expression] conflict from the parent
Python grammar without compensating with a soft-keyword fallback, so
the parser deterministically committed to type_alias_statement and
errored when the syntax didn't fit.

Both ``type`` as identifier and ``type X = int`` as type-alias parse
correctly with this change.

Real-world impact: closes lxml/etree.pyx:472-style tuple unpacks like
``type, value, traceback = self._exc_info``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython function-pointer types can carry the same trailing clauses as
function definitions: ``except``/``except?``/``noexcept`` and
``nogil``/``with gil``, in either order. The grammar's c_function_pointer
rule was missing these slots, so common patterns like

  ctypedef int (*execute_fn)(...) except? -1

failed to parse.

Adding the optional clauses creates an LR(1) ambiguity (the parser
can't tell if a trailing ``with`` extends c_function_pointer or starts
the next statement), so c_function_pointer now also requires an
explicit ``$._newline`` terminator. This is consistent with how cvar_def
and cvar_decl already terminate their declaration branches, and matches
how struct_suite separates fields anyway.

Real-world impact: closes the last remaining error in the cext-review
calibration corpus (msgpack-python/_unpacker.pyx:49). Aggregate corpus
now parses with 0 ERROR/missing nodes (was 60 at HEAD 575484a).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython's cppclass templates allow default types like
``cdef cppclass Map[K, V=int]:`` (the V parameter defaults to int when
not explicitly specified). The existing template_default rule only
accepted ``=*`` (Cython's "infer default" sentinel), rejecting any
concrete type as a default.

Extending the choice to include c_type covers the common case while
preserving the ``=*`` sentinel form.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several patterns common in Cython .pxd headers wrapping C++ libraries
were rejected by the grammar. This single commit groups the related
fixes because they all enable parsing of C++ extern blocks (the
characteristic content of cppclass-heavy .pxd files):

  * **enum aliases**: ``cdef enum Type "ns::Type":`` — the C-name alias
    syntax that struct/union/cppclass already supported was missing on
    enum.

  * **cppclass inheritance**: ``cdef cppclass Derived(Base):`` and
    multiple/template inheritance — the rule had no parent-class slot.

  * **destructor names**: ``~ClassName()`` inside cppclass bodies —
    added a destructor_name rule (~+identifier) and threaded it into
    maybe_typed_name's name slot alongside identifier and operator_name.

  * **method aliases in cppclass bodies**: ``int method "renamed"(args)``
    — cvar_def now accepts an optional alias string between the typed
    name and the function definition, mirroring cvar_decl.

  * **decorators on cppclass members**: ``@staticmethod`` before a
    method declaration — the cppclass body now accepts a sequence of
    decorators followed by cvar_def.

Real-world impact: pyarrow's libarrow.pxd dropped from 1627 to 0
ERROR/missing nodes; the broader pyarrow .pxd suite from 2068 to 3
errors. The remaining 3 errors are unrelated patterns to investigate
separately.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython supports the ``except +*`` shorthand on C++ extern functions —
combining ``except +`` (translate C++ exceptions to Python) with the
``except *`` semantics (probe for any exception state on every return).
The grammar's exception_value rule allowed ``except +`` followed by an
optional identifier (for ``except + CustomException``), but not the
``*`` form.

Extending the choice to ``optional(choice($.identifier, "*"))`` covers
the documented patterns without affecting existing ones.

Real-world impact: closes the last 3 errors in pyarrow's
libparquet_encryption.pxd (and other arrow .pxd files using the same
pattern).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three related extensions for code generated by modern Cython projects:

1. **Hybrid C-type + Python annotation on parameters**: forms like
   ``def fn(Comm comm: Comm | None = None)`` combine Cython's type-first
   declaration with a Python-style annotation. typed_parameter and
   typed_default_parameter each gained an optional
   ``": " <annotation>`` slot between the C-typed name and any default.

2. **Soft-keyword type names**: ``cdef type cls = Comm`` and similar
   uses of soft keywords (type, match, etc.) as type names. The
   maybe_typed_name rule's "type" and "name" slots now accept
   keyword_identifier alongside identifier — matching how the parent
   Python grammar treats these positions.

3. **Constant expressions as array sizes**: ``cdef int arr[N+1]``
   inside extern blocks and cdef bodies. type_index previously accepted
   only ``$.integer`` as the bracket contents; expanded to
   ``$.expression`` (a strict superset).

Two new conflicts were declared
([typed_default_parameter, typed_parameter] and [typed_parameter])
to resolve the optional-annotation ambiguity at the LR boundary
between the new annotation slot and what follows it.

Real-world impact: mpi4py/Comm.pyx dropped from 446 to 0 errors;
similar improvements in mpi4py's other modules (File, Win, Datatype,
Request) and in Cython's own MemoryView utility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two related extensions for code in pyhmmer (244 → 0 errors after fix):

1. **``not None`` / ``or None`` constraint with default value**:
   forms like ``Alphabet alpha not None = Alphabet.dna()`` combine the
   non-None constraint (already supported on typed_parameter) with a
   default value (only on typed_default_parameter). The constraint was
   only honored when no default was present.

2. **``new`` as soft identifier**: Cython uses ``new`` as a keyword for
   the C++ ``new`` expression (``new vector[int]()``), but real-world
   code also uses ``new`` as a variable name (e.g.,
   ``cdef Foo new = Foo(); new.method()``). Adding ``new`` to
   keyword_identifier alongside ``type``/``match``/``api`` lets it serve
   both roles — the new_expression rule still wins in its specific
   context, and the soft-keyword fallback handles plain identifier use.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython lets a cast expression spell out a function-pointer type with
its calling-convention clauses, e.g.

  (<float(*)(float, float) noexcept nogil>fn_ptr)(x, y)

The c_function_pointer_type rule only had ``c_type (* ) c_parameters``
and rejected anything after — so noexcept/nogil at the tail produced
ERROR nodes inside the cast.

Adding ``optional($.exception_value)`` and ``optional($.gil_spec)``
mirrors the structure of c_function_pointer (recently extended for
ctypedef) without the trailing _newline that the standalone form
requires (here the cast's ``>`` delimits the type).

Real-world impact: scipy/special/cython_special.pyx dropped from 67 to
0 errors; similar patterns clear in scipy's _ufuncs.pyx.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython lets cppclass bodies declare nested types directly (without
prefixing each with ``ctypedef``):

  cdef cppclass Outer:
      struct Inner:    # nested struct
          int x
      union U:         # nested union
          int i
      enum E:          # nested enum
          VAL_A

The _cppclass_suite rule already accepted ctypedef_statement,
cvar_def, cppclass, and decorated cvar_def — but not bare struct or
enum (which the standalone struct/enum rules already define). Adding
``$.struct`` and ``$.enum`` to the body's choice closes the gap.

Real-world impact: closes Cython's own cpp_nested_classes.pyx test;
fixes similar nested-type patterns in any cppclass-heavy .pxd.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython supports the 1-element ``(T,)`` form (analogous to Python's
1-tuple syntax) and trailing commas in multi-element tuple types.
The original c_tuple_type rule required ``c_type "," commaSep1(c_type)``,
which forced at least 2 elements (no way to get a 1-tuple) and didn't
naturally permit a trailing comma after the last element.

Restructured to ``c_type (',' c_type?)+`` — at least one comma, with
the c_type after each comma being optional. This admits:

  (T,)        — 1-tuple
  (T1, T2)    — 2-tuple
  (T1, T2,)   — 2-tuple with trailing comma
  (T1, T2, T3, ...)

while still excluding ``(T)`` (which is paren-wrap, not a tuple).

Real-world impact: closes the MISSING-node errors in Cython's own
ctuple.pyx test (line 143-144 ``cdef (Union,) a = ...``).

Note: tuple types in Python-style ``def`` parameter lists (e.g.
``def f((int, double) xy)``) remain unsupported — adding them here
would conflict with tuple_pattern in ``lambda (a, b): ...`` and
``def f((a, b)=v)`` cases that pass corpus tests today. The cdef/
cpdef route already supports tuple-typed parameters via maybe_typed_name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
C++ libraries declare free-function operator overloads in extern
blocks like:

  cdef extern from "lib.h":
      bint operator==(const string_view& lhs, const string_view& rhs)

The cvar_decl rule expected a plain identifier or function name in the
c_name slot. Extending c_name from ``$.identifier`` to
``choice($.identifier, $.operator_name)`` lets ``operator==`` (and the
full set of operator names already defined for member functions) serve
as the function name for top-level extern declarations.

Real-world impact: closes resiliparse_inc/string_view.pxd (20 → 0
errors), and similar patterns in any .pxd wrapping C++ free-function
operator overloads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The ``[type T, object O]`` external_definition specifier (used on
``ctypedef api class`` declarations to bind C type / object type names)
permitted no trailing comma. Real Cython code (mpi4py, vtk-mpi4py)
formats this list across lines with a trailing comma, e.g.

  ctypedef api class Datatype [
      type   PyMPIDatatype_Type,
      object PyMPIDatatypeObject,
  ]:
      cdef MPI_Datatype ob_mpi

Adding ``optional(",")`` after the commaSep1 mirrors how Cython itself
permits trailing commas in argument lists, tuple types, and parameter
lists.

Real-world impact: mpi4py/MPI.pxd 20 → 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython's .pxd declaration files use ``= *`` and ``= ?`` as
"default-value-elided" sentinels — the actual default lives at the
.pyx definition. The grammar accepted ``= *`` already but rejected
``= ?``, even though both are documented Cython.

Real-world impact: pyhmmer/plan7.pxd (17 → 0 errors), and any other
.pxd that elides default values with ``=?``.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Cython's external type spec ``[object T, type T2]`` also accepts
``check_size <value>`` (where value is one of ``min``, ``max``,
``ignore``, ``error``, ``warn``) — Cython 3 uses this to control struct
size compatibility checking against the wrapped C type. The grammar
only allowed the ``object`` and ``type`` directives.

Real-world impact: hunter/_event.pxd 16 → 0 errors, plus other .pxd /
.pxi that wrap numpy/cpython internals using check_size.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@Vizonex Vizonex left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This looks really well thought out. I thought I help with the reviewing process incase needed. Seems to be untouched for some strange reason. I would say write a test with a pytest workflow of some kind so that the reviewer is more likely to accept this but all in all this change looks fantastic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants