A poor mans analysis
This lecture is about Syntactic Analysis, a static analysis which only analyzes programs at the structure level. We'll see that it allows us to build some analyses relatively easily, but fails at others.
As mentioned in the introduction, syntactic analysis analyzes the structure of a program instead of looking at its meaning. For example, in syntactic analysis 2 + 1 is different from 1 + 2. This approach is ideal for capturing the intent of the developer, but not great for detecting complex logical bugs.
Many static analyses
in the wild, often called linters, are simply syntactic analyses, matching patterns in the code. They do this for two reasons. The first is that it is easier to develop and maintain, and the second is that it gives better feedback to the developer, as you can highlight the pattern in the source.
The levels of syntactic matches can range from matching the bits, to matching the final compiled bytecode.
When matching syntax, it is important to match at the right level of abstraction. Initial levels contain more of the developer's intention, but later levels give us more structure, making the analysis more robust and easier to write.
We can start by analyzing the text directly; this allows us to extract information about the white-space of the program. However, if we don't think that the white-space is important for the analysis, we can look at the tokens instead. The tokens are a list of elements matched by the parser to group patterns like integer literals 1232 and 232, so that they appear the same to the parser.
The parser converts the list of tokens into a Concrete Syntax Tree. A CST is the collection of all the tokens of the program, but placed in a tree-like structure. For example 10 + (5 + 3) is turned into a tree-like (parenthesis (+ 10 (parenthesis (+ 5 3)))) instead. This deals with precedence and other headaches we don't want to think about. E.g ., is 10 + 3 * 5 equal to 10 + (3 * 5) or (10 + 3) * 5?
In contrast to CST, (Abstract Syntax Tree) does not have to follow directly from the structure of the text. This allows the AST to remove seemingly unimportant information from the tree. E.g ., parenthesis: (( 10 )) and ( 10 ) are both stored as 10.
The compiler then converts the AST into some kind of Intermediate Representation; this can be the LLVM-IR, ClangIR, or RTL. At this level, a lot of work has been done by the compiler to disambiguate references (making all variables fully qualified) and optimize some cases. x + 2 + 4 might now be mypackage.Class.x + 6, which makes some kinds of analysis easier.
Finally, the IR is compiled to Bytecode, now the style from the original language is gone, and only pure functionality is left.
While every step down the ladder makes it easier to write analyses, we lose a little of the context and intention of the developer.
Say we want to warn users about using || and && in code because developers often do not know the precedence. A good level to approach this is at the CST level, as we can differentiate between a && b || c and (a && b) || c. The developer would get a warning for the first example but not for the second.
Come up with information which is best retrieved at every level.
bits: The encoding of the file.
text length: The number of characters or unknown tokens.
tokens: The number of tokens.
CST: Syntax level confusions, like the example above.
AST: Dangerous code constructions, like query("SELECT FROM db WHERE name=" + user_input)
IR: Usage of deprecated functions.
bytecode: Detection of unoptimized code.
Since a syntactic analysis can never be sound (accepting only good programs), we often have to aim for completeness (rejecting no good programs). This is in line with a study by Christakis (2016) that shows that most developers would rather have an unsound analysis than too many warnings. Here are two quotes from the findings:
Program analysis design should aim for a false-positive rate no higher than 15–20%.
— Christakis and Bird
When forced to choose between more bugs or fewer false positives, [developers] typically choose the latter.
— Christakis and Bird
Since most things we do in program analysis are undecidable, it is nice to have a basic understanding of what kinds of patterns we can match. The set of all words or programs which match a pattern is in formal language theory called a language.
In Computer Science, we refer to a language as a set of sequences of symbols from the alphabet. If a string is in a language, we refer to it as a word.
A language over the alphabet is a subset of all words , such that .
The goal is to automatically figure out if a given word is in the language or not. We differentiate between Recognizers and Deciders. A decider is a machine (or Automaton), which can determine if a string is in a language or not. A recognizer does the same thing, but is allowed to never give an answer, essentially running forever.
The halting problem is undecidable, but it is recognizable. We can run the program and if it terminates, we know it terminates. If it does not terminate ..., well, we have to wait to see if it does.
We can describe every programming analysis problem as a sub-language problem: For example, the language of all terminating Java programs can be described like so:
It is also because of language theory that we use sound and complete for static analyses as we do. In fact, we can see a static analysis as a decider for a sublanguage.
Sound:
Complete:
In practice, we define languages using grammars. A grammar is a collection of production rules of the form , where are sequences of symbols (represented by , , and ) and nonterminals (represented by , , and ). They are called non-terminals because we will keep applying the production rules, rewriting strings that look like into until there are no non-terminals left. Any string in a language is called a word. If the language is a programming language, we call it a program.
We can now see if a word is in a language by generating all possible words in the language, and then checking if is in that set. This algorithm can run forever, so more efficient solutions are needed.
A grammar is a quadruple , where is a set of non-terminals (, , ), is the alphabet, is a set of production rules, and is the start symbol.
The language of the grammar is:
Where denotes applying the rules in until no non-terminal remains.
This is best illustrated by an example.
Consider the grammar where is defined like this:
It can generate the following word in the language
Some languages actually work like this, think about LaTeX and the C macro system.
Since syntactic analysis is simply matching strings, which languages can we efficiently match? This is where the Chomsky hierarchy comes in.
The Chomsky hierarchy, with production rules.
It separates languages into four categories of decreasing expressive power. On the top of the hierarchy, we have type 3 languages. The type 3 language category is the most restrictive, and contains all languages definable by regular expressions. In practice, this only allows for repeats of small languages and cannot match nested parentheses. Figuring out if a string is in a type 3 language is decidable.
In a type 2 language, we can have words defined by nesting. This is very useful if you want nested parentheses. This language is the basis of the syntax of most programming languages (except C: typedef I'm looking at you!). Type 2 languages can be recognized using a push down automaton.
In a type 1 language, we can define our language recursively, but we are also allowed to give context to the recursion. For example, checking a program to see if a variable is in scope, is context-sensitive because it depends on the declarations and how nested we are in the curly parentheses:
int hello(int x) {
// Context { x , y }
int y;
{
int z;
// Context { x , y, z} is z in scope? Yes
}
// Context { x , y} is z in scope? No
}Finally, we have type 0 languages, is a set of words which can be recognized by a Turing machine. One example is all programs that terminate.
In practice we use four kinds of matching systems when writing syntactic analyses. Regular expressions, Grammars and Parsers, Folds and Traversals and bespoke matching.
To match regular languages we can use regular expressions, they are extremely useful, common, and very fast (They can be recognized in ):

XKCD, source: https://xkcd.com/1171
The grammar for defining regular expressions.
The programs from this language can be executed to form sets of accepted words. We can easily define the meaning of the language using denotational semantics. Denotational semantics just means, giving an mathematical object meaning by mapping it to one we already know about.
where is the smallest solution to the equation.
The denotational semantics for regular expressions
In practice, this language is extended quite heavily to be able to easily write better matches. You can explore different flavors of the language and how it matches on https://regex101.com/. Here | is often used instead of +, and + is used to indicate one or more matches: .
First, we need to get a hold of the information about the method, we need to analyse.
You might already have recognized that the method names form jpamb.cases.Simple.assertPositive(I)V a little language: jpamb.cases.Simple . assertPositive : (I) V where each part represent important information. The class, the method, the arguments, and the (return type).
Use https://regex101.com/ to try to match on the method. Here are some tricks:
remember to pick the flavor that matches your implementation language (in the menu).
use the cases from https://github.com/kalhauge/jpamb/blob/main/stats/cases.txt as test cases (you do that by inserting it in the big box).
you can use the quick references to help you write the expression.
remember that . means everything, so use \. to match ..
use match groups to extract each part in your analysis.
Then, we'll need to actually inspect the code. First we need to figure out the correct file. We do that by replacing . with / or \ depending on the platform. We can also use regular expressions to match patterns which is not nested. Luckily, cases in our benchmark suite are never nested, so we can use regular expression to quite effectively find them.
Insert the code of jpamb.cases.Simple into the Test String
field, and try to extract the content of the methods.
r"public\W+static\W+(?P<retype>void)\W+(?P<mname>\w*)()\W*{(?P<code>[^}]*)}"Finally, take a step back and think about what kinds of patterns which would be hard to match using this technique.
Think about if your solution still works:
on other files,
if you change the indentation or insert newlines,
change the methods from static to non-static, and
without the case statement.
At this point, it should be clear that using regular expressions for matching a Context Free language is a bad idea. The problem, of course, arises when we want to match items nested within other items.
Instead, we want to use a Parser. A parser is a structured program whose goal is to take in a stream of tokens and turn them into an CST.
Parsers are often generated automatically from grammars, and grammars are often written in what is known as Backus-Naur form. In programming language theory, this is the primary way to define the syntax of a programming language.
It is defined by a set of productions (as with our grammar), but we allow each production to have multiple matches, separated by |: and B := a. The BNF is often extended with syntax for many and some . Furthermore, we often use first-match semantics where we match on the first production we can. This makes the language deterministically context-free, which changes the complexity of parsing a program from to .
There are many categories of parsers, and to learn more about them you should take the 02247 Compilers course.
Tree-Sitter §3.2.1In practice, we can take a look at the grammars written for the Tree-Sitter generator.
To get an idea of how it works, we can encode the lambda calculus, as a tree sitter grammar.
We can see it contains the same elements as the grammar above but written in Javascript:
{
rules: {
source_file: $ => $._exp,
_exp: $ => choice($.abs, $.app, $.var, $._parens),
abs: $ => prec.right(seq('λ', $.var, '.', $._exp)),
app: $ => prec.left(seq($._exp, $._exp)),
var: $ => /[a-zA-Z]+/,
_parens: $ => choice("(", $._exp, ")"),
}
}In the above text, prec is used to define the precedence, meaning if the syntax is read left to right or right to left. E.g ., is a b c the same as (a b) c or a (b c).
The cool thing about Tree-Sitter is that there are defined grammars for most language you would like to analyse, and has bindings to many languages including Java, Python, Go and Rust. In this example we are going to use Python (but try your own).
syntaxer-treesitter to workTry to get syntaxer-treesitter to work. At this point you should be able to run:
$ jpamb -vv analyse syntaxer-treesitterThe result of parsing a program is a parse tree, or a CST. You can experiment with seeing different parse trees in the tree-sitter playground.
At this point, we would like to use this technology to match patterns in the code, and we have two options. One solution is to extend the grammar to explicitly add rules that detect common errors. E.g ., we can write a parse rule that matches x / 0, and assigns it to a div_error group. However, this is cumbersome and not easy to maintain. Our second option is to match patterns using Tree-sitter's support for Queries
, a built-in language for matching elements in the syntax tree. These queries are essentially regular expressions, but are matched at every level of the syntax tree.
Go to tree-sitter Playground and insert the code from jpamb.cases.Simple
Enable Query and try the following query:
(method_declaration name:
((identifier) @method-name (#eq? @method-name "assertBoolean"))
body: (_) @body
) @methodYou can write your own queries using the Query Syntax.
syntatic-treesitterExtend syntatic-treesitter to also warn about diving by 0 errors, you can do this by slightly increasing the probability that the code contains an devide by 0 error if you see / in the method, and slightly decrease it if you don't.
To get started with tree-sitter you can add a detection for a divide by zero.
You do this by adding the following to the end of the solutions/syntactic/src/syntactic_treesitter.py file.
divide_q = tree_sitter.Query(
JAVA_LANGUAGE,
"""
(binary_expression
operator: "/"
) @divide
""",
)
divide_found = any(
capture_name == "divide"
for capture_name, _ in tree_sitter.QueryCursor(divide_q).captures(body).items()
)
if div_found:
log.debug("Found divide")
print("divide by zero;found")
else:
log.debug("No divide")
print("divide by zero;not-found")
for q in jpamb.QUERIES:
if q != "assertion error" and q != "divide by zero":
print(f"{q};skip")After which you should be able to test it on the simple cases like so:
$ jpamb -vv analyse --filter Simple syntactic-treesitterOne big limitation of Tree-sitter queries is that they do not currently support nested queries, and we need those if we want to match patterns with context.
The context of the match is important, in the following code, we want to see if assertFalse contains a divide by 0 exception. But because our match matches every thing without context, we match the 1/0 in divideByZero by mistake.
public class Simple {
public static int assertFalse() {
assert False;
}
public static int divideByZero() {
return 1 / 0;
}
}In this case you have to write your own.
We call the recursive matching of a pattern on a tree structure a fold. Actually, there is a whole discipline in mathematics devoted to this problem called Abstract Algebra. Researchers have spent a lot of effort categorizing all kinds of folds (and unfolds). In this context, we refer to patterns as Initial Algebras, and we can see them as the nodes of the tree, where each edge is replaced by a hole. For example, the initial algebra of a list of x's is . The most common type is called a catamorphism. This implies that I can reduce any structure $, with initial algebra $, given a function that determines your replacement value in a parent node, provided the algebra already contains the computed values for all of your children.
In practice, general recursion is often expensive in terms of speed and memory; luckily, all recursions can be made into iterative traversals by mimicking the stack manually. When we traverse, we differentiate between pre - and post-orders, i.e ., do we match patterns on the way down or on the way up.
Pre- (red), In- (green), and Post-order (blue) traversal of tree. source
In our case, we can use the built-in Tree-sitter cursor to traverse the tree.
Consider the following traversal of the tree, which of the yield points yield a postorder and which yield a preorder?
def traverse(item: ts.Tree) -> Iterator[ts.Node]:
cursor = item.walk()
godeeper = True
while True:
node = cursor.node
if godeeper:
yield node # Pre or Post
if not cursor.goto_first_child():
godeeper = False
elif cursor.goto_next_sibling():
godeeper = True
elif cursor.goto_parent():
yield node # Pre or Post
godeeper = False
else:
breakTo match languages beyond Context-Insensitive languages, there exist some tools, but it becomes harder and harder to match full languages efficiently. In this case you have to write bespoke analyses in your favorite language.
Almost all syntactic static analyses or linters
falls into this category. Here are some examples:
There is still interesting work in building an easy and intuitive system for pattern match normal programming constructs with recent work like Rafnsson (2020) and Tomasdottir (2020).
Another approach is to use LLMs. LLMs, due to their fixed input window, can only recognize regular languages; however, in practice, they tend to do well on simple analysis tasks because of their ability to recognize common patterns.
Until next time, write the best analysis you can and upload the results to Autolab, and ponder the following:
What are the limitations of a syntactic analysis?
When is syntactic analysis a good tool?
Trick Question: Is language of Java programs a regular language, context free or recursive enumerable?
Christakis, Maria; Bird, Christian (2016). What developers want and need from program analysis: an empirical study.
doi:10.1145/2970276.2970347 link
Meijer, Erik; Fokkinga, Maarten; Paterson, Ross (1991). Functional programming with bananas, lenses, envelopes and barbed wire.
doi:10.1007/3540543961_7 link
Rafnsson, Willard; Giustolisi, Rosario; Kragerup, Mark; Høyrup, Mathias (2020). Fixing Vulnerabilities Automatically with Linters.
doi:10.1007/978-3-030-65745-1_13 link
Tomasdottir, Kristin Fjola; Aniche, Mauricio; van Deursen, Arie (2020). The Adoption of JavaScript Linters in Practice: A Case Study on ESLint.
doi:10.1109/TSE.2018.2871058 link
Van Wyk, Eric R .; Schwerdfeger, August C . (2007). Context-aware scanning for parsing extensible languages.
doi:10.1145/1289971.1289983 link
Wagner, Tim Allen (1997). Practical algorithms for incremental software development environments.
link
Wagner, Tim A .; Graham, Susan L . (1998). Efficient and flexible incremental parsing.
doi:10.1145/293677.293678 link