Build a Typed Visitor
Walking the AST with walk() (Kotlin) or Traversing (Java) works well for simple filters.
When analysis grows across many node types, needs accumulated context, a typed visitor might be the better solution. The visitor pattern is a common design pattern that will be familiar to many developers.
The SAS parser ships with SASBaseVisitor, a visitor base class with one visit method per AST node type.
When to use a visitor
| Approach | Good for |
|---|---|
walk() / filterIsInstance (Kotlin) or Traversing.walk + instanceof (Java) | Working on single types, quick scripts |
SASBaseVisitor | Multi-pass analysis, lint rules, basic transpiler back-ends |
Add Dependencies
- Kotlin
- Java
repositories {
mavenLocal()
mavenCentral()
flatDir {
dirs("deps")
}
}
dependencies {
implementation(files("deps/sas-parser-with-dependencies-1.6.5-all.jar"))
}
repositories {
mavenLocal()
mavenCentral()
flatDir {
dirs("deps")
}
}
dependencies {
implementation(files("deps/sas-parser-with-dependencies-1.6.5-all.jar"))
implementation "com.strumenta.kolasu:kolasu-javalib:1.5.96"
}
A simple max length visitor
This visitor counts the max length of an identifier in the code file. The default SASBaseVisitor implementation dispatches to the correct visit overload for each node type. The visitor comes with default method to aggregate results (aggregateResult) of different visitors and a default result (defaultResult). We override these default implementation, because there cannot be a sensible default (e.g., how do you aggregate two custom classes?).
- Kotlin
- Java
import com.strumenta.kolasu.parsing.ParsingResult
import com.strumenta.sas.ast.SourceFile
import com.strumenta.sas.ast.Identifier
import com.strumenta.sas.ast.VariableRangeByName
import com.strumenta.sas.ast.VariableRangeByNumber
import com.strumenta.sas.ast.VariableRangeWithPrefix
import com.strumenta.sas.ast.Variables
import com.strumenta.sas.ast.datastep.DataStep
import com.strumenta.sas.ast.sql.ColumnRef
import com.strumenta.sas.ast.sql.TableRef
import com.strumenta.sas.traversing.SASBaseVisitor
class CountVisitor : SASBaseVisitor<Int>() {
override fun visit(node: Identifier) : Int {
return node.name.length;
}
override fun visit(node: TableRef) : Int {
return (if(node.schema != null) visit(node.schema!!) else 0) + node.table.length
}
override fun visit(node: ColumnRef) : Int {
return (if(node.table != null) visit(node.table!!) else 0) + node.column.length
}
override fun visit(node: Variables) : Int {
return node.variables.map {
visit(it)
}.max()
}
override fun visit(node: VariableRangeByName) : Int {
return Math.max(visit(node.from), visit(node.to))
}
override fun visit(node: VariableRangeByNumber) : Int {
return Math.max(visit(node.from), visit(node.to))
}
override fun visit(node: VariableRangeWithPrefix) : Int {
return visit(node.prefix)
}
override fun visit(node: DataStep) : Int {
return node.statements.map {
visit(it)
}.max()
}
override fun defaultResult() : Int {
return 0
}
override fun aggregateResult(aggregate: Int?, nextResult: Int?): Int? {
return if(aggregate != null && nextResult != null)
aggregate + nextResult
else
null
}
}
fun countIdentifierLength(result: ParsingResult<SourceFile>): Int {
val visitor = CountingVisitor()
val maxLength = visitor.visit(result.root!!)
return maxLength
}
import com.strumenta.kolasu.parsing.ParsingResult;
import com.strumenta.sas.ast.Identifier;
import com.strumenta.sas.ast.SourceFile;
import com.strumenta.sas.ast.VariableRangeByName;
import com.strumenta.sas.ast.VariableRangeByNumber;
import com.strumenta.sas.ast.VariableRangeWithPrefix;
import com.strumenta.sas.ast.Variables;
import com.strumenta.sas.ast.datastep.DataStep;
import com.strumenta.sas.ast.sql.ColumnRef;
import com.strumenta.sas.ast.sql.TableRef;
import com.strumenta.sas.traversing.SASBaseVisitor;
import java.util.Comparator;
public class CountVisitor extends SASBaseVisitor<Integer> {
@Override
public Integer visit(Identifier node) {
return node.getName().length();
}
@Override
public Integer visit(TableRef node) {
return (node.getSchema() != null ? visit(node.getSchema()) : 0) + node.getTable().length();
}
@Override
public Integer visit(ColumnRef node) {
return (node.getTable() != null ? visit(node.getTable()) : 0) + node.getColumn().length();
}
@Override
public Integer visit(Variables node) {
return node.getVariables().stream()
.map(this::visit)
.max(Comparator.naturalOrder())
.orElse(defaultResult());
}
@Override
public Integer visit(VariableRangeByName node) {
return Math.max(visit(node.getFrom()), visit(node.getTo()));
}
@Override
public Integer visit(VariableRangeByNumber node) {
return Math.max(visit(node.getFrom()), visit(node.getTo()));
}
@Override
public Integer visit(VariableRangeWithPrefix node) {
return visit(node.getPrefix());
}
@Override
public Integer visit(DataStep node) {
return node.getStatements().stream()
.map(this::visit)
.max(Comparator.naturalOrder())
.orElse(defaultResult());
}
@Override
protected Integer defaultResult() {
return 0;
}
@Override
protected Integer aggregateResult(Integer aggregate, Integer nextResult) {
if (aggregate != null && nextResult != null) {
return aggregate + nextResult;
}
return null;
}
public static int countIdentifierLength(ParsingResult<SourceFile> result) {
CountVisitor visitor = new CountVisitor();
return visitor.visit(result.getRoot());
}
}
Bottom-up processing with a child index
Some analyses need children before parents — for example, aggregating dependencies from inner statements before handling the enclosing step. Build a child index once, then visit children explicitly inside each visit method:
- Kotlin
- Java
import com.strumenta.kolasu.model.Node
import com.strumenta.kolasu.traversing.walk
import com.strumenta.sas.ast.datastep.DataStep
import com.strumenta.sas.ast.datastep.SetStatement
import com.strumenta.kolasu.parsing.ParsingResult
import com.strumenta.sas.ast.SourceFile
class BottomUpSetCounter(result: ParsingResult<SourceFile>) :
SASBaseVisitor<Int?>() {
private val children = HashMap<Node, MutableSet<Node>>()
init {
result.root!!.walk().forEach { node ->
children.putIfAbsent(node, HashSet())
val parent = node.parent ?: return@forEach
children.putIfAbsent(parent, HashSet())
children[parent]!!.add(node)
}
visit(result.root!!)
}
fun getChildren() : HashMap<Node, MutableSet<Node>> {
return children
}
override fun visit(node: DataStep): Int {
var setCount = 0
children[node]?.forEach { child ->
setCount += visit(child) ?: 0
}
return setCount
}
override fun visit(node: SetStatement): Int {
return 1
}
override fun defaultResult() : Int {
return 0
}
}
import com.strumenta.kolasu.javalib.Traversing;
import com.strumenta.kolasu.model.Node;
import com.strumenta.kolasu.parsing.ParsingResult;
import com.strumenta.sas.ast.SourceFile;
import com.strumenta.sas.ast.datastep.DataStep;
import com.strumenta.sas.ast.datastep.SetStatement;
import com.strumenta.sas.traversing.SASBaseVisitor;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class BottomUpSetCounter extends SASBaseVisitor<Integer> {
private final Map<Node, Set<Node>> children = new HashMap<>();
public BottomUpSetCounter(ParsingResult<SourceFile> result) {
Traversing.walk(result.getRoot()).forEach(node -> {
children.putIfAbsent(node, new HashSet<>());
Node parent = node.getParent();
if (parent == null) {
return;
}
children.putIfAbsent(parent, new HashSet<>());
children.get(parent).add(node);
});
visit(result.getRoot());
}
public Map<Node, Set<Node>> getChildren() {
return children;
}
@Override
public Integer visit(DataStep node) {
int setCount = 0;
Set<Node> kids = children.get(node);
if (kids != null) {
for (Node child : kids) {
Integer n = visit(child);
setCount += n != null ? n : 0;
}
}
return setCount;
}
@Override
public Integer visit(SetStatement node) {
return 1;
}
@Override
protected Integer defaultResult() {
return 0;
}
}
Override only the node types you care about; the generated base class provides default implementations for everything else.
Next steps
Visitors pair naturally with DATA step I/O analysis and lint-style rules that emit structured findings per node type.