Skip to content

Cyril Ferlicot-Delbecque

4 posts by Cyril Ferlicot-Delbecque

Implementing the Local Resolver of Python in FAST-Python

Implementing the Local Resolver of Python in FAST-Python

Section titled “Implementing the Local Resolver of Python in FAST-Python”

The goal of this blog post is to explain how I implemented a local resolver for FAST-Python. FAST-Python is on of the hardest project to implement this kind of algo, so reading this should help implementing any other local resolver for other languages.


Local resolution is a symbol resolution pass: it links each named entity to the entity that declares it. In other words, for every occurrence of a name — read, write, call, import — it answers the question “where does this name come from?”.

In FAST-Python, after the model is imported from source, running the local resolver produces a model where every name usage points to its local declaration:

x = 1 # this is the declaration of `x`
print(x) # this use of `x` is linked to the declaration above

It is called local because it only resolves declarations that live in the same file/model (in contrast to a global/system resolution that would also resolve calls to external libraries, standard modules, etc.). Once we went through the file, a name that is not declared anywhere in the file gets bound to a special FASTNonLocalDeclaration placeholder — it is a declaration for the model, but we know it refers to something outside.

Local resolution is the foundation on top of which our other analyses are built such as the Static Single Assignment (SSA). Without it, you cannot even tell whether two x in the code are the same variable. You can’t even say if a FASTPyIdentifier is a variable or not.

The result of the resolution is exposed through two accessors available on every FAST entity (they live in the FAST-Core-Tools package of the base FAST project, so they are reusable across languages):

  • entity localDeclaration — from any use of a name, returns the entity that declares it (the first assignment, a FASTPyFunctionDefinition, a loop variable, an import…). If the name was not declared in the file, it returns a FASTNonLocalDeclaration.
  • declaration localUses — from a declaration, returns all the entities that resolve to it (usages and other declarations that share it).

This is a bidirectional relationship: localUses is the exact inverse information of localDeclaration.

On top of those two, FAST-Python adds a set of convenience queries that only make sense once the resolution is done — and only for nodes that represent variables:

  • access allAccesses / allReadAccesses / allWriteAccesses — all the read/write accesses to the same variable.
  • access internalAccesses — the attribute accesses and subscripts on the variable (x.y, x[3]).
  • access allNodesUsingMe / access allStatementsUsingMe — the statements that use the variable.
  • variable isResolvedVariabletrue if the node resolves to a local variable declaration (and is not a function, method, import or unresolved name).
  • node usedVariables — all the entities in the subtree of the node that resolve to local variables.

The full list is documented in the analysis.md “Querying local resolver information” section.


The resolver is implemented as a Moose/FAST visitor: FASTPythonLocalResolverVisitor. It visits a FAST model (a FASTPythonVisitor built on the generated visitor trait FASTPyTVisitor) and inspects/annotates the nodes in place.

If you are not familiar with how the visitor is generated from the model, I strongly recommend reading Improving the Visitor Generator first: it explains how FASTPyTVisitor is produced and how you can override the generated visitX: methods.

The Python problem: there is no declaration of variables

Section titled “The Python problem: there is no declaration of variables”

Before we can write a single line of the algorithm, we have to deal with the first (and deepest) Python quirk: there is no declaration of variables.

In Java you get a real declaration:

int x = 1; // I declare and type `x`

In Python, an assignment is the declaration:

x = 1 # this IS the declaration of `x`

There is no keyword, no type, no way to distinguish “declare a fresh variable” from “reassign an existing one” syntactically — the same syntax x = ... does both. The resolver has to decide, and the decision is purely lexical reasoning about scopes and order, with no syntactic marker to lean on. Concretely:

  • The first assignment in a scope is the declaration. The resolver must therefore know the order in which things appear, and which construct “creates” a scope.
  • Parameters are declarations: def f(x): ... declares x as the function’s parameter. They are semantically the first assignment in the function’s scope.
  • Walrus operators (x := ...) declare x inline, even inside an expression or an if condition.
  • A for ... in ... loop declares its target variable — but only the first time; it must be a declaration when the variable is not already known in the scope.
  • Augmented assignments (x += 1) are uses + writes to the same variable.
  • For clauses in a list comprehension.

In our implementation this all boils down to a single rule expressed in visitFASTTCanBeVariable: (see below): a write access declares and bind, a read access binds. Everything else is scope management around that rule.

The whole state of the algorithm is a single instance variable — and the class itself is tiny. Here is the complete class declaration:

FASTPythonVisitor << #FASTPythonLocalResolverVisitor
slots: { #namesContext };
tag: 'CFG/LocalResolver/SSA';
package: 'FAST-Python-Tools'

The superclass is FASTPythonVisitor (from the generated FASTPyTVisitor trait), so all the visitX: methods come for free: we only override the ones where the metamodel visit order differs from the resolution order.

namesContext is a stack of scopes (a Stack of Dictionarys, mapping name -> declaration). It is initialized in initialize:

initialize
super initialize.
namesContext := Stack new

The class entry point is trivial:

FASTPythonLocalResolverVisitor class >> resolve: aModule
^ self new resolve: aModule

and the instance resolve: wraps everything in one scope and runs the visitor:

resolve: aFASTBehaviouralEntity
self useNewScopeDuring: [
"flush attributes in case this is not the first resolution"
aFASTBehaviouralEntity withAllContainedEntities do: [ :entity | entity resetLocalResolution ].
aFASTBehaviouralEntity accept: self ] "<== Launch the resolution on the tree"

Scopes are pushed/popped around the constructs that define a new scope (module, function, comprehension), through useNewScopeDuring::

useNewScopeDuring: aBlock
namesContext push: Dictionary new.
[ aBlock value ] ensure: [ namesContext pop ]

The algorithm is three small operations combined:

  1. get — look a name up in the stack of scopes (from top to bottom):
declarationNamed: aName
namesContext do: [ :scope |
scope at: aName ifPresent: [ :declaration | ^ declaration ] ].
^ nil
  1. set / ensure a declaration — put a declaration in the current scope, handling re-declaration and shadowing (see Shadowing):
ensureDeclarationOf: aName declaration: aFASTNode in: scope
scope
at: aName
ifPresent: [ :declaration |
declaration localResolverKind = aFASTNode localResolverKind
ifTrue: [ ^ declaration ]
ifFalse: [ declaration shadowedBy: aFASTNode.
aFASTNode ensureLocalUses.
scope at: aName put: aFASTNode ] ]
ifAbsentPut: [ aFASTNode ensureLocalUses; yourself ]
  1. bind — link a use to the existing declaration (creating a FASTNonLocalDeclaration if none is found):
bind: aFASTNode toDeclarationNamed: aName
^ self bind: aFASTNode toDeclarationNamed: aName
ifAbsentUse: [ self ensureNonlocalDeclarationNamed: aName ]
bind: aFASTNode toDeclarationNamed: aName ifAbsentUse: aBlock
| declaration |
declaration := (self declarationNamed: aName) ifNil: [ aBlock cull: aFASTNode cull: aName ].
aFASTNode localDeclaration: declaration.
declaration addLocalUse: aFASTNode

When a read has no matching declaration in any scope, bind:...ifAbsentUse: falls back to ensureNonlocalDeclarationNamed::

ensureNonlocalDeclarationNamed: aName
^ self
ensureDeclarationOf: aName
declaration: (FASTNonLocalDeclaration new
name: aName;
yourself)
in: namesContext first

Note the scope: namesContext first is the bottom of the stack, so a FASTNonLocalDeclaration behaves as if it were declared at module level — reads of an undeclared name in a function and at module level end up in the same place. Also note that the fallback for attribute accesses and subscripts is slightly different (see the heart): declarationForUndeclaredNode:named: creates a local declaration on the first read of a subscript when the receiving variable has a declaration, and only resorts to the non-local declaration otherwise:

declarationForUndeclaredNode: aNode named: aName
"In case of a subscript, if the receiving variable exists, we consider
that the first read access to the subscript is the declaration."
aNode isSubscript ifTrue: [
aNode value
localDeclarationifPresent: [ :decl |
^ self ensureDeclarationOf: aName
declaration: aNode
in: namesContext top ]
ifAbsent: [ "Nothing, just let the non local declaration." ] ].
^ self ensureNonlocalDeclarationNamed: aName

Everything that can be a variable in FAST-Python implements the trait FASTTCanBeVariable (identifiers, attribute accesses, subscripts, walrus…). The visitor intercepts them in one place, and this is where “write declares / read binds” lives:

visitFASTTCanBeVariable: aTCanBeVariable
aTCanBeVariable isVariableWriteAccess
ifTrue: [ self ensureDeclarationOf: aTCanBeVariable
named: aTCanBeVariable localDeclarationName
declaration: aTCanBeVariable ]
ifFalse: [ self bind: aTCanBeVariable
toDeclarationNamed: aTCanBeVariable localDeclarationName
ifAbsentUse: [ :node :name |
self declarationForUndeclaredNode: node named: name ] ].
super visitFASTTCanBeVariable: aTCanBeVariable

Two details make Python specific here:

  • localDeclarationName is the name that will be used for scope lookup. It is not always the identifier text: an attribute access and a subscript use their source code as their name, so x.y and x[3] are treated as first-class “variables” (with known limits, see Limitations). Every named entity gets a default from the base FAST project (FASTTNamedEntity>>localDeclarationName returns #name); the Python classes only override it where the name is not the identifier:
"from FAST, the default for any named entity:"
FASTTNamedEntity >> localDeclarationName [ ^ self name ]
"Python overrides:"
FASTPyAttributeAccess >> localDeclarationName [ ^ self sourceCode ]
FASTPySubscript >> localDeclarationName [ ^ self sourceCode ]
  • isVariableWriteAccess decides whether a node is a write access or a read access. It comes from FAST (FASTTEntity>>isVariableWriteAccess), but it delegates to variableDeclaration which is an explicitRequirement — every FAST project must implement it for the node kinds that can be variable write accesses. In FAST-Python, each relevant class provides its own logic to walk up the AST and check whether it sits in a write position:
"FAST core — the API:"
FASTTEntity >> isVariableWriteAccess [
^ self variableDeclaration isNotNil
]
FASTTEntity >> variableDeclaration [
"If I am a node representing a write access, I return the node
assigning me. Else I return nil."
^ self explicitRequirement
]
"FAST-Python — an identifier checks whether it is the left side of
an assignment, a for-loop target, etc.:"
FASTPyIdentifier >> variableDeclaration [
| assignedNode |
assignedNode := self selfOrTopmostAssignableCollection.
assignedNode parentAssignmentLeft ifNotNil: [ :assignment | ^ assignment ].
assignedNode parentForStatementLeft ifNotNil: [ :for | ^ for ].
assignedNode parentForInClauseLeft ifNotNil: [ :clause | ^ clause ].
^ super variableDeclaration
]
"Parameters and walrus are their own declarator:"
FASTPyParameter >> variableDeclaration [ ^ self ]
FASTPyWalrus >> variableDeclaration [ ^ self ]
"Attribute accesses and subscripts check for parentAssignmentLeft:"
FASTPyAttributeAccess >> variableDeclaration [
self selfOrTopmostAssignableCollection parentAssignmentLeft
ifNotNil: [ :assignment | ^ assignment ].
^ super variableDeclaration
]
"The default on FASTPyEntity returns nil (not a write access):"
FASTPyEntity >> variableDeclaration [ ^ nil ]

The interesting bit: for identifiers, variableDeclaration does not just check the immediate parent — it first walks through tuple/destructuring parents via selfOrTopmostAssignableCollection, so that in (a, b) = (1, 2) the individual a and b correctly return the tuple assignment as their declaration.


Reordering the visit, or dealing with Python scoping

Section titled “Reordering the visit, or dealing with Python scoping”

Because the visitor is generated (see the visitor generator blog post), it visits the children of a node in the metamodel order. For a resolver, metamodel order is not always resolution order: a construct may declare a name in a part of the syntax that the generated visitor reaches too late (or too early) relative to its uses.

Our main tool is therefore: override the generated visitX: with a manual ordering of a few visitEntity:/visitCollection: calls — and be very careful about when to open a scope, because Python does not create a scope where you would expect one.

Block scoping is inconsistent: if and while

Section titled “Block scoping is inconsistent: if and while”

In most block-structured languages you expect:

if (cond) { let y = 3; }
use(y); // Compile error: y does not exist

Python does not create a scope for blocks. An if, a while, a for body do not introduce a new scope:

if cond:
y = 3
print(y) # 3, perfectly valid Python

Combine that with “assignment is the declaration” and you get leaks: a variable assigned inside an if branch or a loop is visible after the block. For the resolver this means no useNewScopeDuring: around branches — a name assigned in the then must be visible in the else and after the whole statement. What we do control is the order, which is important for shadowing: in an if, the semantics are

  1. the condition (can use outer variables, and walrus operators can even declare variables!),
  2. the then clause,
  3. the elif clauses (in order),
  4. the else clause.
visitFASTPyIfStatement: anIfStatement
"visit then - elif(s) -> else"
self visitFASTTConditionalStatement: anIfStatement.
self visitFASTPyStatement: anIfStatement.
self visitEntity: anIfStatement thenClause.
self visitCollection: anIfStatement elifClauses.
self visitFASTPyTWithElseClause: anIfStatement

Note that we do not call super here: the point is precisely to produce our own child order instead of the metamodel one.

while statements are similar, but the else clause has to go last (it runs when the loop condition becomes false):

visitFASTPyWhileStatement: aWhileStatement
self visitFASTTConditionalStatement: aWhileStatement.
self visitFASTTStatementBlock: aWhileStatement.
self visitFASTPyStatement: aWhileStatement.
self visitFASTPyTWithElseClause: aWhileStatement
for i in range(10):
pass
print(i) # 9 — the loop variable is still there!

The for ... in ... statement declares its loop variable in the enclosing scope (there is no loop scope). i survives after the loop. So a for is simultaneously a scoping and a declaring construct: the resolver must declare the target (left) in the current scope — no push — and, since that is the declaration, visit it before the iterable and the body:

visitFASTPyForStatement: aForStatement
self visitEntity: aForStatement left.
self visitEntity: aForStatement right.
self visitFASTPyTWithElseClause: aForStatement.
self visitFASTTStatementBlock: aForStatement.
self visitFASTPyStatement: aForStatement

Function and method definitions: parameters are declarations

Section titled “Function and method definitions: parameters are declarations”

The generated visitor does not visit the parameters before the body — but the parameters are declarations that the body uses. So visitFASTPyFunctionDefinition: manually visits parameters first, wrapped in a new scope, and does not use super:

visitFASTPyFunctionDefinition: aFunctionDefinition
self ensureDeclarationOf: aFunctionDefinition
named: aFunctionDefinition name
declaration: aFunctionDefinition.
self useNewScopeDuring: [ "do not use super: parameters must be visited before the body"
self visitFASTTWithParameters: aFunctionDefinition.
self visitFASTPyTWithTypeParameters: aFunctionDefinition.
self visitFASTPyStatement: aFunctionDefinition.
self visitEntity: aFunctionDefinition returnType.
self visitFASTPyTDefinition: aFunctionDefinition ]

visitFASTPyMethodDefinition: simply delegates to the function one.

Comprehensions: a scope that leaks itself, but not its body

Section titled “Comprehensions: a scope that leaks itself, but not its body”

Comprehensions are Python 3’s attempt at “introduce an expression-level scope”, and they only partially succeed:

[x for x in coll]
print(x) # NameError in Python 3 — x does not leak OUT of the comprehension

In Python 3, the comprehension has its own scope, so the loop variable of its for clause does not escape. But the for clause(s) and the if condition(s) can still see and use variables from the enclosing scope, and the comprehension’s own variable is in scope through the whole comprehension (the body and the conditions can refer to the for clause variables).

That gives you a scope that is neither fully lexical like a function, nor absent like a block. In our implementation, visitFASTPyComprehension: opens a dedicated scope and reorders the visit so that the for clauses are processed before the conditions and the body (otherwise the conditions/body would be visited against the wrong scope):

visitFASTPyComprehension: aComprehension
self useNewScopeDuring: [
self visitFASTPyTSplatExpression: aComprehension.
self visitFASTPyExpression: aComprehension.
self visitCollection: aComprehension forClauses.
self visitCollection: aComprehension conditions.
self visitEntity: aComprehension body ]

We also had to choose a Python version:

  • Imports declare the imported name (or its alias) in the current scope — visitFASTPyImport: ensures a declaration for each imported entity (using alias if present, source code otherwise).
  • Walrus operator (x := ...) declares xvisitFASTPyWalrus: ensures its declaration (it can even appear in an if condition).

Python is one of the rare languages where a function can explicitly opt out of local scoping:

x = 1
def f():
global x # `x` is the module-level x, not a local one
x = 2
def g():
y = 1
def h():
nonlocal y # `y` is the `y` of `g`, not a new local
y = 2

These two statements redirect resolution away from the current scope:

  • a global x means: “in this scope, x is the module-scope x”. Writes here target the global declaration, they must not create a new local declaration.
  • a nonlocal x means: “x is the variable of the nearest enclosing function that defines it”. It is similar to global but with a different target scope.

In the model, a FASTPyGlobalStatement (resp. FASTPyNonlocalStatement) contains a collection of variables, and each FASTPyVariable keeps the inverse parentGlobalStatement / parentNonlocalStatement pointer. That is what the visitor checks in FASTPythonLocalResolverVisitor>>#visitFASTPyVariable: — when visiting a variable that is the target of one of these statements, we change which scope the write will land in:

visitFASTPyVariable: aVariable
"We handle two specific cases here.
- global: the variables impacted should act as if they were in the
global scope. I ensure the variable is in the bottom scope and add
a copy in the current scope (so that if we assign it, it goes in
the global and does not create a new local).
- nonlocal: the variables impacted should act as if they were in the
first parent scope defining the variable."
aVariable parentGlobalStatement ifNotNil: [
namesContext top
at: aVariable localDeclarationName
ifAbsentPut: [ self ensureDeclarationOf: aVariable localDeclarationName declaration: aVariable in: namesContext last ] ].
aVariable parentNonlocalStatement ifNotNil: [
namesContext allButFirst
detect: [ :scope | scope includesKey: aVariable localDeclarationName ]
ifFound: [ :scope | namesContext top at: aVariable localDeclarationName put: (scope at: aVariable localDeclarationName) ]
ifNone: [ self error: 'Non local statement points a variable that was never defined.' ] ].
super visitFASTPyVariable: aVariable

Let’s unpack the two branches:

  • global — we want a write x = 2 inside the function to target the module-level x. So we look at the bottom scope (namesContext last, the module scope) and, if x is not there yet, we declare it there with the current writing variable as its declaration. Then we copy a reference into the top scope (namesContext top). From then on, any write in this scope simply finds x present in the top scope — ensureDeclarationOf: sees the same kind (#variable = #variable) and keeps the same declaration, so no new local is created and the write silently targets the global one. The copy trick makes the subsequent writes “resolve” without us having to special-case every write site.
  • nonlocal — the target scope is not the module but the nearest enclosing function that already defines the name. So we scan namesContext allButFirst (everything except the bottom scope) and re-bind x in the top scope to the existing declaration found there. If no enclosing scope defines the name, this is a Python error (SyntaxError at compile time in real Python), so we raise an error too.

Both branches rely on a nice property of our data structure: the top entry of the stack is always the current scope, and putting a reference to an existing declaration (rather than a fresh node) in the current scope is exactly what redirects future uses.


Shadowing is another direct consequence of “no declarations”: in Python, anything named can shadow anything named, in the same scope:

x = 1 # Declaration 1: variable
from os import x # Declaration 2: import (shadows Declaration 1)
def x(): # Declaration 3: function (shadows Declaration 2)
pass
print(x) # links to Declaration 3

In ensureDeclarationOf: (see core operations), when a name is re-declared in the same scope, the resolver does not decide based on types (there are none) but on localResolverKind:

  • if the new entity has the same kind (e.g. re-assigning a variable): we keep the same declaration and do nothing special. Two x = ... in a row share one declaration. (This is why ensureLocalUses initializes the uses instead of resetting them: several nodes can be declarations of one shared declaration.)
  • if the kind is different (a variable shadowed by an import or a function): we create a new declaration, and link the two declarations together.

localResolverKind is not used anywhere else in the resolver — it exists only to drive this decision. Each node kind that can act as a declaration returns its kind, and the base FAST project provides no default because it is purely language-specific:

FASTPyIdentifier >> localResolverKind [ ^ #variable ]
FASTPyParameter >> localResolverKind [ ^ #variable ] "can be reassigned"
FASTPyWalrus >> localResolverKind [ ^ #variable ]
FASTPyAttributeAccess >> localResolverKind [ ^ #variable ]
FASTPySubscript >> localResolverKind [ ^ #subscript ]
FASTPyFunctionDefinition >> localResolverKind [ ^ #function ]
FASTPyMethodDefinition >> localResolverKind [ ^ #method ]
FASTPyImport >> localResolverKind [ ^ #import ]

Two subtleties we hit:

  • Parameters are #variable, not a dedicated kind, because they can be reassigned inside the body: def f(x): x = 2 must share the declaration with the parameter, not shadow it.
  • FASTPyEntity>>localResolverKind raises an error by default, which catches any node kind that is used as a declaration without having declared its kind — a cheap safety net while extending the metamodel.

To make shadowing queryable, two back-links were added on the declarations (FASTPyEntity, in FAST-Python-Model, generated via the metamodel generator):

  • declaration shadowing — returns the declaration it shadows, or nil.
  • declaration shadowedBy — returns the declaration that shadows it, or nil.

Together they form a linked list from the first declaration to the most recent one. Each declaration keeps its own localUses (the set of entities that resolve to it, not to its successors), and usages always resolve to the most recent declaration.

Going back to the example:

varDecl := model module statements first left. "FASTPyVariable"
importStmt := model module statements second. "FASTPyImportFromStatement"
funcDecl := model module statements third. "FASTPyFunctionDefinition"
varDecl shadowedBy. "=> FASTPyImportFromStatement"
importStmt shadowedBy. "=> FASTPyFunctionDefinition"
importStmt shadowing. "=> FASTPyVariable"
funcDecl shadowing. "=> FASTPyImportFromStatement"
varDecl localUses size. "1 (just the assignment)"
importStmt localUses size. "1 (just the import)"
funcDecl localUses size. "2 (the definition + print(x))"

Same-kind re-declaration shares the declaration, which also means shadowing’s chain and local uses are independent of the SSA versioning: if you need to know which assignment impacts a particular use, local resolution is not enough — combine it with the SSA pass (it produces one version per assignment). The two analyses are designed to be composed in this pipeline order: Local resolution → CFG → SSA.


We implemented local resolution for Python, but we focused on variables first, because that is what the CFG/SSA and the reachability analyses needed. Functions, methods and imports are handled in the scope bookkeeping (localResolverKind, reordering in the visitor), but if your mission is a complete Python symbol table, expect more work there (call graph resolution, self/class attributes, closures and bound variables…).

Specific known weaknesses (documented in analysis.md → Limitations):

  • Attribute access chains: x.y.z = 3; a = x.y; print(a.z)a.z should resolve to x.y.z, but it does not (only the source code of the attribute access is used as the name).
  • Subscripts are compared by source code: y[x] with different x values are conflated; x[0:4] and x[:4] (semantically equal) are seen as different. Matching the expression instead of the string would fix it.
  • Instance variables (self.x) cannot be handled correctly without knowing the order in which methods are invoked.
  • Python 2 scoping is not supported (comprehension variables leak in Python 2; we implement Python 3).
  • global/nonlocal are handled, but nonlocal errors out if the variable was never defined in an enclosing scope.

Also, one of the future step zould be to make some parts, such as the context stack, generic and push it to FAST so that it can be reused in other FAST projects.


"Import"
model := FASTPythonImporter parseFile: aFile.
"Resolve"
FASTPythonLocalResolverVisitor resolve: model module.
"Query"
(model allFunctionDefinitions first) localDeclaration. "a FASTPyFunctionDefinition"

The recommended pipeline for analysis:

model := FASTPythonImporter parseFile: aFile.
FASTPythonLocalResolverVisitor resolve: model module.
model allFunctionDefinitions first cfg. "CFG"
FASTPythonSSAVisitor resolve: model allFunctionDefinitions first. "SSA (after resolution)"

The local resolver and SSA require Python 3 scoping.


Advice for implementing it in your own FAST project

Section titled “Advice for implementing it in your own FAST project”
  1. Use the generated visitor, override visitX: selectively. You rarely need to reorder everything — only where the metamodel order differs from the declaration-before-use order (if, for, while, functions, comprehensions).
  2. Model a scope stack explicitly. One Stack of Dictionarys was enough for the whole algorithm. Wrap “new scope” sites in a useNewScopeDuring:/[ensure: pop] pair so the stack is popped even on error.
  3. Make “what declares a name” explicit and language-aware. For Python: write access declares; read access binds; for target declares in the enclosing scope; comprehensions open a scope; global/nonlocal redirect.
  4. Add a localDeclarationName per node kind (it is sourceCode for attribute accesses/subscripts, the identifier name otherwise) — do not hard-code “the name is the text” everywhere.
  5. Add a localResolverKind and use it to drive shadowing. It made the Java-free, type-free Python shadowing tractable and gave us a cheap way to keep-or-split declarations.
  6. Reset your attributes before re-resolving. We flush localDeclaration/localUses on all contained entities at the start of resolve: so the resolver is idempotent on a model.

The FAST-Python sources you will want to look at: FASTPythonLocalResolverVisitor, the localResolverKind/localDeclarationName extensions in FAST-Python-Tools.

Improving the generation of visitors

Recently, I needed to implement a feature in FAST-Python requiring a visitor for my metamodel. People knowing me knows that I if I can automatize a task to save time and to keep the code coherant with the model, I’ll go for it. Nicolas Anquetil and Clotilde Toullec recently started to implement a visitor generator, so I tried it. Since it was a POC, I encountered some problems.

I’ll explain in this blog post how to use this visitor generator, what problems I encountered and the solutions I proposed.

Once my changes will be integrated, using the visitor generator is as simple as adding one method on the class side of your generator. For example, in FAST-Python:

FASTPythonMetamodelGenerator class>>metamodelToolGenerators
^ { FamixVisitorGenerator }

This will tell the generator to also generate a visitor trait.

It is possible to also customize the package in which the visitor is generated by overridin #packageNameForVisitor:

FASTPythonMetamodelGenerator class>>packageNameForVisitor
^ #'FAST-Python-Visitor'

While using this visitor generator I faced a few problems.

The first one I encountered is that FAST-Python is depending on FAST that is depending on Famix that is depending on MooseQuery.

In order to have the FAST-Python visitor working, all dependencies needed to have a visitor themselves which was not the case. So I had to generate a FAST visitor for example. I could do it since I am part of the moosetechnology organization, but this is a hassle.

The solution I found was to generate standalone visitors. Instead of using the visitors of the sub metamodels, now we generate a visitor containing the visit of all entities of the model and the visit of the remote trait used in the model.

This can seems like a lot of useless code at first, but it has two advantages:

  • Visitors and now standalone
  • This allow to customize the visit of remote traits for the language we are currently working on. This will be important for one of the other problems I encountered.

The second problem I faced was that the generated visitor had infinit loops while I tried to use it. The reason is simple, when visiting an entity, we were visiting all its relations, but Moose is a cyclic graph since we have relations to the contained entities and relations to the containers of the entity.

Since most analysis that we are doing are “top down” (meaning that we start by the top level entities of our project and we visit their children) and we do not often need a “bottom up” visitor (for this we can just iterate on parents), I decided to exclude the visit of parent entities from the visit.

In some cases I noticed the we visited two times the same nodes. This is due to the fact that Famix model mix the usage of superclasses and traits. Here is a simple case to understant what is happening:

Double visit example

Here we see that a FASTPyReturnStatement inherits from FASTPyStatement. This class is using FASTTStatement. FASTPyReturnStatement also uses the trait FASTPyReturnStatement, but this trait also uses FASTTStatement.

Now when we visit FASTPyReturnStatement, we end up visiting two times FASTTStatement. Once via its superclass and once via its trait composition.

The solution I proposed is that when we visit a trait composition, we check the users of the trait and if some of them already visit the trait in their superclasses, we skip the visit of this relation.

With this, #visitFASTPyReturnStatement in the context of FAST-Python become:

visitFASTTReturnStatement: aTReturnStatement
<generated>
"We should visit FASTTStatement but all its users in this language already visit it in their superclasses so we skip the call here.".
self visitEntity: aTReturnStatement expression

In some cases, we need to skip the visit for some entities, but not all of them. For example, with conditional statements. In that case we generate a method like this:

visitFASTTConditionalStatement: aTConditionalStatement
<generated>
"We do not visit all behaviorals because some classes already visit it in their superclasses in this language implementation. Visiting them here also would cause a double visit of this trait."
({ FASTPyIfStatement . FASTPyWhileStatement } includes: aTConditionalStatement class)
ifFalse: [ self visitFASTTStatement: aTConditionalStatement ].
self visitFASTTWithCondition: aTConditionalStatement

This is possible only because we generate standalone visitors now.

With those changes I’m hoping it will be easier to generate and use visitors with Famix. The big advantage is that if we do not need to touch the visitor by hand, it will follow all the evolutions of the metamodel.

Have fun with this :)

Speed up models creation: application to JSON/MSE parsing

In order to be able to work with Moose there is a prerequisite we cannot avoid: we need a model to analyze. This can be archieved in 2 principal ways:

  • Importing an existing JSON/MSE file containing a model
  • Importing a model via a Moose importer such as the Pharo importer or Python importer

While doing this, we create a lot of entities and set a lot of relations. But this can take some time. I found out that this time was even bigger than I anticipated while profiling a JSON import.

Here is the result of the profiling of a JSON of 330MB on a Macbook pro M1 from 2023:

Image of a profiling

Form this profiling we can see that we spend 351sec for this import. We can find more information in this report:

Image of a profiling 2

On this screenshot we can see some noise due to the fact that the profiler was not adapted to the new event listening loop of Pharo. But in the leaves we can also see that most of the time is spent in FMSlotMultivaluedLink>>#indexOf:startingAt:ifAbsent:.

This is used by a mecanism of all instance variables that are FMMany because those we do not want duplicated elements. Thus, we check if the collection contains the element before adding it.

But during the import of a JSON file, we should have no duplicates making this check useless. This also explains why we spend so much time in this method: we always are in the worst case scenario: there is no element matching.

In order to optimize the creation of a model when we know we will not create any duplicates, we can disable the check.

For this, we can use a dynamic variable declaring that we should check for duplicated elements by default, but allowing to disable the check during the execution of some code.

DynamicVariable << #FMShouldCheckForDuplicatedEntitiesInMultivalueLinks
slots: {};
tag: 'Utilities';
package: 'Fame-Core'
FMShouldCheckForDuplicatedEntitiesInMultivalueLinks>>#default
^ true

And now that we have the variable, we can use it:

FMSlotMultivalueLink >> unsafeAdd: element
(self includes: element) ifFalse: [ self uncheckUnsafeAdd: element ]
FMShouldCheckForDuplicatedEntitiesInMultivalueLinks value
ifTrue: [ (self includes: element) ifFalse: [ self uncheckUnsafeAdd: element ] ]
ifFalse: [ self uncheckUnsafeAdd: element ]
FMMultivalueLink >> unsafeAdd: element
(self includes: element) ifFalse: [ self uncheckUnsafeAdd: element ]
FMShouldCheckForDuplicatedEntitiesInMultivalueLinks value
ifTrue: [ (self includes: element) ifFalse: [ self uncheckUnsafeAdd: element ] ]
ifFalse: [ self uncheckUnsafeAdd: element ]

And the last step is to disable the check during the MSE/JSON parsing:

FMMSEParser >> basicRun
self Document.
self atEnd ifFalse: [ ^ self syntaxError ]
FMShouldCheckForDuplicatedEntitiesInMultivalueLinks value: false during: [
self Document.
self atEnd ifFalse: [ ^ self syntaxError ] ]

Now let’s try to import the same JSON file with the optiwization enabled:

Image of a profiling

Image of a profiling 2

We can see that the import time went from 351sec to 113sec!

We can also notice that we do not have one bottleneck in our parsing. This means that it will be harder to optimize more this task (even if some people still have some ideas on how to do that).

This optimization has been made for the import of JSON but it can be used in other contexts. For example, in the Moose Python importer, the implementation is sure to never produce a duplicate. Thus, we could use the same trick this way:

FamixPythonImporter >> import
FMShouldCheckForDuplicatedEntitiesInMultivalueLinks value: false during: [ super import ]

Testing your algo on a java project

When developping algorithm on top of the Moose platform, we can easily hurt a wall during testing.

To do functional (and sometimes unit) testing, we need to work on a Moose model. Most of the time we are getting this model in two ways:

  • We produce a model and save the .json to recreate this model in the tests
  • We create a model by hand

But those 2 solutions have drawbacks:

  • Keeping a JSON will not follow the evolutions of Famix and the model produce will not be representative of the last version of Famix
  • Creating a model by hand has the drawback of taking the risk that this model will not be representative of what we could manipulate in reality. For example, we might not think about setting the stubs or the source anchors

In order to avoid those drawbacks I will describe my way of managing such testing cases in this article. In order to do this, I will explain how I set up the tests of a project to build CallGraph of Java projects.

The idea I had for testing callgraphs is to implement real java projects in a resources folder in the git of the project. Then, we can parse them when launching the tests and manipulate the produced model. This would ensure that we always have a model up to date with the latest version of Famix. If tests breaks, this means that our famix model evolved and that our project does not work anymore for this language.

Parse the project
Parse the project
Create java project
Create java project
Import the model
Import the model
Run tests on the model
Run tests on the model
Text is not SVG - cannot display

The first step to build tests is to write some example java code.

I will start with a minimal example:

public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

I’ll save this file in the git repository of my project under Famix-CallGraph/resources/sources/example1/Main.java.

Now that we have the source code, we need a way to access it in our project.

In order to access our resources, we will use GitBrigde.

You can install it by executing:

Metacello new
githubUser: 'jecisc' project: 'GitBridge' commitish: 'v1.x.x' path: 'src';
baseline: 'GitBridge';
load

But we should add it to our baseline:

BaselineOfFamixCallGraph >> #gitBridge: spec
spec baseline: 'GitBridge' with: [ spec repository: 'github://jecisc/GitBridge:v1.x.x/src' ]
BaselineOfFamixCallGraph >> #baseline: spec
<baseline>
spec for: #common do: [
"Dependencies"
self gitBridge: spec.
"Packages"
spec
package: 'Famix-CallGraph';
package: 'Famix-CallGraph-Tests' with: [ spec requires: #( 'Famix-CallGraph' 'GitBridge' ) ]. "<== WE ADD GITBRIDGE HERE!"
].
spec for: #NeedsFamix do: [
self famix: spec.
spec package: 'Famix-CallGraph' with: [ spec requires: #( Famix ) ] ]

Now that we have the dependency running, we can use this project. We will explain the minimal steps here but you can find the full documantation here.

The usage of GitBridge begins with the definition of our FamixCallGraphBridge:

GitBridge << #FamixCallGraphBridge
slots: {};
package: 'Famix-CallGraph-Tests'

Now that this class exists we can access our git folder using FamixCallGraphBridge current root.

Let’s add some syntactic suggar:

FamixCallGraphBridge class >> #resources
^ self root / 'resources'
FamixCallGraphBridge class >> #sources
^ self resources / 'sources'

We can now access our java projects doing FamixCallGraphBridge current sources.

This step is almost done, but in order for our tests to work in a github action (for example), we need two little tweaks.

In our smalltalk.ston file, we need to register our project in Iceberg (because GitBridge uses Iceberg to access the root folder).

SmalltalkCISpec {
#loading : [
SCIMetacelloLoadSpec {
#baseline : 'FamixCallGraph',
#directory : 'src',
#registerInIceberg : true "<== This line"
}
]
}

Also, in our github action we need to be sure that the checkout action will get enough info for git bridge to run and not the minimal ammount (which is the default) adding a fetch-depth: option.

steps:
- uses: actions/checkout@v4
with:
fetch-depth: '0'

Now we need to be able to parse our project. For this, we will use a Java utility thaht is directly in Moose: FamixJavaFoldersImporter.

We can parse and receive a model doing:

model := (FamixJavaFoldersImporter importFolders: { FamixCallGraphBridge sources / 'example1' }) anyOne.

Now that we can access the model it is possible to implement our tests.

I’m starting by an abstract class:

TestCase << #FamixAbstractJavaCallGraphBuilderTestCase
slots: { #model . #graph };
package: 'Famix-CallGraph-Tests'

Now I will create a TestCase that needs my java model

FamixAbstractJavaCallGraphBuilderTestCase << #FamixJavaCHAExample1Test
slots: {};
package: 'Famix-CallGraph-Tests'

And now I will create a setup importing the model and creating a call graph:

FamixAbstractJavaCallGraphBuilderTestCase >> #setUp
super setUp.
model := (FamixJavaFoldersImporter importFolders: { self javaSourcesFolder }) anyOne.
graph := (FamixJavaCHABuilder entryPoints: self entryPoints) build
FamixJavaCHAExample1Test >> #javaSourcesFolder
"Return the java folder containing the sources to parse for those tests"
| folder |
folder := FamixCallGraphBridge sources / 'example1'.
folder ifAbsent: [ self error: 'Folder does not exists ' , folder pathString ].
^ folder

And now you have your model available for the testing!

I am using this technic to tests multiple projects such as parsers or call graph builders. In those projects I do touch my model and the setup can take time. So I optimize this setup in order to build a model only once for all the test case using a TestResource.

In order to do this we can remove the slots we added to FamixAbstractJavaCallGraphBuilderTestCase and create a test resource that will hold them

TestResource << #FamixAbstractJavaCallGraphBuilderTestResource
slots: { #model . #graph };
package: 'Famix-CallGraph-Tests'

Then we can move the setup to this class

FamixAbstractJavaCallGraphBuilderTestResource >> #setUp
super setUp.
model := (FamixJavaFoldersImporter importFolders: { self javaSourcesFolder }) anyOne.
graph := (FamixJavaCHABuilder entryPoints: self entryPoints) build

Personally I’m also adding a tearDown cleaning the vars because TestResources are singletons and I do not want to hold a model in memory all the time.

Then I’m creating my test resource for the example1 project.

FamixAbstractJavaCallGraphBuilderTestResource << #FamixJavaCHAExample1Resource
slots: {};
package: 'Famix-CallGraph-Tests'
FamixJavaCHAExample1Resource >> #javaSourcesFolder
"Return the java folder containing the sources to parse for those tests"
| folder |
folder := FamixCallGraphBridge sources / 'example1'.
folder ifAbsent: [ self error: 'Folder does not exists ' , folder pathString ].
^ folder

And now we can declare that the TestCase will use this resource:

FamixJavaCHAExample1Test class >> #resources
^ { FamixJavaCHAExample1Resource }

The model then become accessible like this:

FamixJavaCHAExample1Resource >> #model
^ self resources anyOne current model

Here is a few tricks I use to simplify even better the setting of my tests cases

The first one is to make automatic the detection of the java source folder by using the name of the test cases:

FamixAbstractJavaCallGraphBuilderTestResource >> #javaSourcesFolder
^ self class javaSourcesFolder
FamixAbstractJavaCallGraphBuilderTestResource class >> #javaSourcesFolder
"Return the java folder containing the sources to parse for those tests"
| folder |
folder := FamixCallGraphBridge sources / ((self name withoutPrefix: 'FamixJavaCHA') withoutSuffix: 'Resource') uncapitalized.
folder ifAbsent: [ self error: 'Folder does not exists ' , folder pathString ].
^ folder

We can now remove this method from all subclasses! But makes sure the name of your source folder matches the name of the tests ressource ;)

Automatic test resource detection and access

Section titled “Automatic test resource detection and access”

We can do the same with the detection of the test resource in the test case.

FamixAbstractJavaCallGraphBuilderTestCase class >> #resources
^ self environment
at: ((self name withoutSuffix: 'Test') , 'Resource') asSymbol
ifPresent: [ :class | { class } ]
ifAbsent: [ { } ]
FamixAbstractJavaCallGraphBuilderTestCase class >> #sourceResource
^ self resources anyOne current
FamixAbstractJavaCallGraphBuilderTestCase >> #sourceResource
"I return the instance of the test resource I'm using to build the sources of a java project"
^ self class sourceResource
FamixAbstractJavaCallGraphBuilderTestCase >> #model
^ self sourceResource model

Et voila ! Now adding a test case ready to use on a new java project is equivalent to create a test case:

FamixAbstractJavaCallGraphBuilderTestCase << #FamixJavaCHAExample2Test
slots: {};
package: 'Famix-CallGraph-Tests'

And the resource associated!

FamixAbstractJavaCallGraphBuilderTestResource << #FamixJavaCHAExample2Resource
slots: {};
package: 'Famix-CallGraph-Tests'

Nothing much.

Easily find the sources of the tested project

Section titled “Easily find the sources of the tested project”

A last thing I am doing to simplify thing is to implement a method to access easily the sources.

FamixJavaCHAExample1Test >> #openSources
<script: 'self new openSources'>
self resources anyOne javaSourcesFolder openInOSFileBrowser

It is possible to do the same thing for other languages than java but maybe not exactly in the same way than in this blogpost for the section “Parse and import your model”. But this article is meant to be an inspiration!

I hope this helps improve the robustness of our projects :)