astero.scopes¶
The scope tree, and the names each block binds.
astero.scopes ¶
Scopes, derived from a declared grammar.
The grammar already says which fields bind a name. This adds the second half: which productions open a scope, and which of their fields are evaluated inside it. Between them, the scope tree and the names bound in each block follow, with nothing about lexical structure written out per production.
The declaration is small. What it buys is that Python's awkward cases become
statements rather than code: a function's name binds outside while its args
bind inside, a class body is a scope that nested functions cannot see, and a
comprehension is a scope on some versions and inlined on others.
CPython ships the oracle for this in symtable, which is what
tests/b_integration/test_scopes_symtable.py checks against.
Scope
dataclass
¶
A scope a production opens.
A production may open several, nested. def f[T]() opens a type-parameter
scope wrapping the function scope, and only when it has type parameters,
so a layer carries a condition and the layers are listed outermost first.
Block
dataclass
¶
names_bound_by ¶
names_bound_by(
node: Any, grammar: Grammar, ns: str
) -> set[str]
Every identifier node itself introduces in ns, conditions applied.
bound_names answers for one field, so a caller had to find the
production, fetch the Field and pass its sort — a five-line dance that
every consumer wrote. This is the identifier twin of Grammar.binds,
which answers the same question for slot contents.
The node only, not its children, which is the same rule Grammar.binds
follows. So a FunctionDef answers with its own name and not its
parameters, and a With answers with nothing: the binding lives on the
withitem, as import a.b as c's lives on the alias and except E as
err's on the ExceptHandler. A pass that wants everything a statement
binds walks, and asks this of each node it reaches.
target_nodes ¶
Every Name a Python binding position actually binds.
Python's, not every language's: a target may nest ((a, b), *c = ...) and
these four shapes are how it nests. It lives here rather than in
astero.python because astero.scopes may not import that package — the
Python grammar imports Scope from here, and the cycle only fires when a
consumer imports astero.scopes first, which is what examples/pl0 does.
bound_names yields the identifiers and hygiene the nodes; before this
they were the same walk written twice, the second copy captioned
"mirroring bound_names".
bound_names ¶
The identifiers a binding position introduces.
A binding field holds either a name outright (arg.arg) or an expression
that is a target (Assign.targets), and a target may nest.
binds_in_scope ¶
binds_in_scope(
node: Any,
grammar: Grammar,
ns: str,
scopes: Mapping[str, tuple[Scope, ...]],
*,
stop: Collection[str] = (),
) -> set[str]
Every name node introduces into the scope that contains it.
names_bound_by answers for one node, and a pass that wants everything
a statement binds has to walk. The walk is the part that goes wrong,
because it has to stop at a scope boundary: a function's parameters and
body bind inside the function, so def f(a, b): ... contributes f to
the scope around it and nothing else. scopes is the layer table that
says which fields those are, astero.python.BINDING_SCOPES for Python.
Use BINDING_SCOPES rather than SCOPES here. They differ from 3.12,
where PEP 709 stopped comprehensions opening a symtable block while
leaving their targets local, and this is the binding question.
stop names productions the walk will not enter, as a set of
production names. A flow analysis visiting one statement at a time
passes grammar.concrete("stmt"), so an if reports what its test
binds without reporting what its branches bind::
binds_in_scope(stmt, PY, VARS, BINDING_SCOPES, stop=PY.concrete("stmt"))
node itself is entered whatever stop says, since it is the thing
being asked about.
Reaches through anything that is not a node of its own: with a as b
binds b through a withitem, import a.b as c through an alias,
except E as err through an ExceptHandler, and case [x] through a
pattern. None of those are named here.
walk ¶
walk(node: Any, grammar: Grammar) -> Iterator[Any]
Every declared node under node, itself included, in no fixed order.
ast.walk reads node._fields, so it answers nothing for a tree the
grammar declares off dataclasses — not an error, an empty iterator, which
is how hygiene.taken_names came to report that no name was taken in a
PL/0 program. _fields_of and _is_node are what make this the same walk
for every grammar; see their notes for the same mistake made twice before.
A list is walked as a root, so a body can be passed directly.
scope_tree ¶
scope_tree(
tree: Any,
grammar: Grammar,
scopes: Mapping[str, tuple[Scope, ...]],
namespace: str,
*,
extra: Mapping[str, ExtraBinder] | None = None,
mangle: Mangler | None = None,
siblings: SiblingBlocks | None = None,
root_kind: str = "module",
root_name: str = "top",
) -> Block
Resolve tree into a block tree with the names each block binds.
tree is any node the grammar declares, not only an ast.AST: the walk
reads its fields from grammar, which is what lets a tree of plain
dataclasses resolve. The annotation said ast.AST for as long as the
implementation was Python-only, and outlived it.