Complete support for Starlark type annotations - #1488
Conversation
This change introduces parsing, AST, formatting, and linting support for experimental Bazel Starlark type annotations (bazelbuild/bazel#27370) and Buck2 type syntax: - Type alias statements (`type OptionalDict[T, U] = dict[T, U] | None`). - Generic `def` statements (`def foo[T, U](x: T) -> U`). - Var statements (`x: list[int]`) - `cast` and `isinstance` expressions. - `type_*` AST nodes to cleanly distinguish type syntax from standard expressions. In particular, this allows us to improve formatting for complex type applications, and to allow the ellipsis (`...`) only in type expressions. Major wart: due to the limitations of yacc's LALR(1) grammar (1 token lookahead is very limiting), we cannot easily distinguish cast/isinstance expressions from ordinary function calls at the level of the yacc grammar. We're thus forced to initially parse cast/isinstance as calls and then transform into type-syntax expressions; and we're forced to detect the usage of `...` outside of type syntax by walking the AST after parsing has finished. Fixing this would require switching to a hand-rolled parser, like the ones used by Bazel or by starlark-go. Disclosure: Gemini-assisted. TAG=agy CONV=ae2ff586-d57d-4fd4-a22a-883aec5d0802
|
Note that this PR is required to unblock dogfooding of Starlark types in Bazel. |
|
Cleaning up past reviewers due to lack of capacity, and buildtools were now moved to be |
|
cc @fmeum |
|
cc @alexeagle also |
There was a problem hiding this comment.
[🤖 Claude] This review was performed by Claude (Claude Code), at fmeum's request; fmeum triaged the findings but Claude wrote every comment. Each finding was reproduced locally at head 80211fc against base 5d08cfd — none are speculative. Inline comments cover the findings that anchor to diff lines; the ones below touch code this PR doesn't modify but whose behavior it changes.
build/parse.y:1630 — Tuples in isinstance are silently rewritten to lists
toTypeExpr rewrites every non-empty TupleExpr into a TypeListExpr, so a tuple literal passed to cast/isinstance is silently reformatted into a list, changing program semantics:
if isinstance(x, (int, str)):formats to
if isinstance(x, [int, str]):For a user-defined isinstance helper doing membership/type checks, a tuple argument becomes a list.
warn/warn_control_flow.go:729 — Bare x: int statement treated as a use, not a declaration
A bare var statement x: int (a TypedIdent used as a statement) is treated as a use rather than a declaration: findUninitializedVariables reports the annotation itself as reading an uninitialized variable, and the unused-variable collector marks the name as used.
def f():
x: int
x = 1
return x→ false Variable "x" may not have been initialized. on the annotation line; conversely, x: int marking x as used suppresses legitimate unused-variable findings.
warn/warn_control_flow.go:563 — Type alias names are never treated as definitions by the variable checks
The redefined-variable and unused-variable checks never treat TypeAliasStmt as defining its Name (their statement scans only match AssignExpr):
type A = intfollowed bytype A = strandA = 5produces no redefined-variable finding.- In
def f():
a = 1
type a = int
return 2no warning is emitted at all: WalkOnce exposes the alias Name as an ordinary Ident, which counts as a use of a and hides the genuinely unused a = 1.
bzlenv/bzlenv.go:181 — bzlenv never declares type parameters or bare annotated variables
declareLocalVariables handles only AssignExpr/IfStmt/ForStmt, so a bare x: int statement inside a function declares nothing — while the same statement at top level is declared via declareGlobals, an asymmetric resolution. And DefStmt/TypeAliasStmt TypeParams are never declared in any scope, so def foo[T](x: T) -> T leaves T unresolvable for every bzlenv-based consumer (warn_bazel_api, type inference, naming checks), which misclassify it as an undeclared global.
| if v.Args != nil { | ||
| in.order(v.Args) | ||
| } | ||
| case *TypeListExpr: |
There was a problem hiding this comment.
[🤖 Claude] Comments before a closing type bracket detach and corrupt the file
in.order() for TypeListExpr/CastExpr/IsInstanceExpr registers no closing-bracket End anchor (unlike CallExpr, which does in.order(&v.End)), so a comment before the closing bracket re-attaches to the next pre-order node. In an annotated assignment this splits the statement and produces unparseable output while buildifier exits 0:
x: dict[
str,
# keep me
] = {}is rewritten to
x: dict[
str,
] =
# keep me
{}and the next parse fails at 4:1. Similarly, in
def f(x: list[
int,
# c
]): passthe comment silently migrates into the function body, and comments before the ) of a multiline cast/isinstance move out of the expression. Base handles the equivalent inputs correctly.
There was a problem hiding this comment.
I think the conclusion is that TypeList and TypeDict were a bad idea: they don't add any value in the current state of the code, and are a route for bugs to enter due to differences in behavior from normal ListExpr/DictExpr. So I am removing them and using normal ListExpr/DictExpr instead.
And I'm updating TypeApplication to print like a CallExpr.
Fixed by a179a58
| case *AssignExpr: | ||
| f(&v.LHS) | ||
| f(&v.RHS) | ||
| case *TypeAliasStmt: |
There was a problem hiding this comment.
[🤖 Claude] WalkOnce drops Edit() results on TypeAliasStmt; unchecked assertions panic
Two issues in the new traversal code:
- The
TypeAliasStmtcase passes the callback pointers to local copies ofNameandTypeParamswithout writing the results back (unlike the siblingDefStmtcase), soEdit()replacements are silently dropped: renaming everyIdentontype Foo[T] = dict[T, Foo]renames only the right-hand side and leaves the declaration untouched. - The new write-backs elsewhere use unchecked type assertions: an
Editondef foo(T: int)that substitutes aStringExprfor the parameterIdentpanics withinterface conversion: build.Expr is *build.StringExpr, not *build.Identatv.Ident = ident.(*Ident)— a crash class that didn't exist before, becauseTypedIdentchildren weren't walked pre-PR.
There was a problem hiding this comment.
For the type params of TypeAliasStmt and DefStmt, I think to make them editable, we have to pass them as a single list node (otherwise, the editing function cannot add/remove type params). This means, naturally, storing them as a ListExpr. (Which incidentally helps with fixing the formatting of comments in type params!)
As for the identifier of TypeAliasStatement or TypedIdent, an editor function that changes the node to a non-identifier breaks Starlark syntax, and we absolutely should panic here, although of course with a nicer message. (Better would be to report a nice structured error, but alas WalkOnce is a stable API as far as I can tell, and has no error reporting provision...)
| case *DefStmt: | ||
| p.printf("def ") | ||
| p.printf(v.Name) | ||
| if len(v.TypeParams) > 0 { |
There was a problem hiding this comment.
[🤖 Claude] Comments inside type parameter lists are displaced
DefStmt/TypeAliasStmt print type parameters via a bare printf loop instead of p.seq, so comments attached inside [T, U] have no anchor and are displaced:
def foo[T, # comment(withU](a):on the next line) → the comment moves inside the parens, abovea.- A multiline type parameter list with a leading
# a commentline → the comment lands at column 0 inside the brackets and the parameter is misindented to 8 spaces. type Foo[T, # comment→ the comment moves to the end of the statement.
All mangled forms persist on reformatting.
| ident ident type_params_opt '=' type_expr | ||
| { | ||
| if $1.(*Ident).Name != "type" { | ||
| yylex.Error("syntax error near " + $1.(*Ident).Name) |
There was a problem hiding this comment.
[🤖 Claude] Misparsed alias statements report the wrong error position
The manual validation in the type_alias_stmt action (yylex.Error("syntax error near " + $1.(*Ident).Name)) fires only after the whole rule is reduced, so a misparse reports a later position and a different token than before: foo bar = baz now reports 2:1: syntax error near foo (past the end of the line, naming the first token) where base reports 1:8: syntax error near bar. Editor and CI diagnostics point at the wrong place for every such error.
| } | ||
| p.printf("]") | ||
| } | ||
| p.printf(" = ") |
There was a problem hiding this comment.
[🤖 Claude] Multi-line type aliases format with a double space before the backslash
TypeAliasStmt printing emits " = " and breakline() then adds another space before the backslash continuation, so every multi-line alias formats to = \ with a double space — and since type_expr accepts neither a newline after = nor parentheses, every alias too long for one line hits it:
type T = \
int | floatformats (idempotently, verified byte-for-byte) to
type T = \
int | float... to match Bazel's Starlark parser
Fixing it properly will take too much code; deferring to follow-up PR.
Claude has a point. However, fixing it properly is a fair bit of additional code. For ease of review, I will remove support for |
…sed variable checks
Arguably it should be a hard failure; but if static type checking is disabled, in theory we may want to tolerate it...
Fixed by 12fce3f
We only want to allow type aliases at top level; I added a check to the parser to enforce this. As for redefinition of type aliases, I'm a bit on the fence whether that should be a fatal error or a lint warning. (Redefinition of type aliases is incompatible with the static type checking algorithm!) In Bazel at the moment, it's fatal only if type checking is enabled but non-fatal if when parsing type annotations, so I suppose we could make it a warning for now in Buildifier too - but a more strongly-worded one that for redefinition of globals.
Good call, fixed by b4c3ac8 |
…ct nodes As pointed out by Claude review, TypeList/TypeDict were mishandling comment attachment. Let's remove them since they add no value and merely cause bugs. As for TypeApplication, reimplement following the model of CallExpression; this also gives us nicer formatting and proper comment handling.
In order to make type params editable, we have to store them as an Expr - a ListExpr is the natural fit. Printing that ListExpr using `seq` also fixes comment printing comments inside type parameters.
Makes it possible to edit it.
This reverts commit 7c8cfb4. On second thought, it's silly to attempt to support syntatically invalid edits. We should panic - but with a clear error message.
… statement to be something syntactically invalid A better solution might have been to report an error message, but walk.WalkOnce is a stable API, and has no provision for error reporting...
|
@fmeum - ready for re-review! |
This change introduces parsing, AST, formatting, and linting support for experimental Bazel Starlark type annotations
(bazelbuild/bazel#27370) and Buck2 type syntax:
type OptionalDict[T, U] = dict[T, U] | None).defstatements (def foo[T, U](x: T) -> U).x: list[int])type_*AST nodes to cleanly distinguish type syntax from standard expressions. In particular, this allows us to improve formatting for complex type applications, and to allow the ellipsis (...) only in type expressions.Not supported yet:
castandisinstanceexpressions - these will be added in a follow-up PR; supporting them is a bit tricky due to the limitations of yacc's LALR(1) grammar.Disclosure: Gemini-assisted.
TAG=agy
CONV=ae2ff586-d57d-4fd4-a22a-883aec5d0802
Buildtools PR checklist
@brandjon @susinmotion FYI