In this second case study of the ephp dependency audit series, we analyze zucchini, the Erlang INI file parser used by ephp to load php.ini configuration files. While small in module count (3 BEAM modules: zucchini, zucchini_lexer, and zucchini_parser), zucchini exposes a classic blind spot in Erlang static analysis: code generated by leex (lexical analyzer generator) and yecc (LALR-1 parser generator).
1. The leex / yecc Blind Spot in Erlang Type Checkers
Virtually every parser in the Erlang ecosystem—including ephp's own PHP parser (zucchini_lexer.xrl and zucchini_parser.yrl in zucchini)—relies on OTP's leex and yecc generators.
However, inspect the standard yeccpre.hrl prelude included by OTP in every generated yecc parser:
-type yecc_ret() :: {'error', _} | {'ok', _}.
-spec parse(Tokens :: list()) -> yecc_ret().
Because yecc compiles grammar rules into a mutual-recursion state machine (yeccpars2_0/7 through yeccpars2_N/7 and yeccgoto_*/7) operating over an untyped heterogeneous list Stack :: [term()]:
-
Type Erasure at the Parser Boundary: The generated
-spec parse/1returns{'ok', any()} | {'error', any()}. -
Dialyzer Goes Blind: Under Success Typing,
any()is compatible with every possible return specification in caller modules (any() ∩ DeclaredType ≠ ∅). If a wrapper module declares a completely broken-specfor the AST returned byyecc, Dialyzer silently passes with 0 warnings. -
Skeleton Noise: Both
leexinc.hrlandyeccpre.hrlinject unused template helper functions and catch-all state clauses with ignored parameters (_S,_Cat,_Ss,_Stack,_T,_Ts,_Tzr). A naive analyzer either drowns the user in false-positive warnings on generated skeleton code or skips generated modules altogether.
flowchart LR
A["zucchini_lexer.xrl"] --> B["yyaction/5 token shapes"]
B --> C["zucchini_parser.yrl LR stack"]
C --> D{"Static Analyzer"}
D -- "Dialyzer (yeccpre.hrl)" --> E["Erases AST to {ok, any()} — misses broken specs"]
D -- "Prisma (LR Stack Inference)" --> F["Infers exact AST type & suppresses skeleton noise"]
2. How Prisma Solves leex and yecc Analysis
In Prisma, we designed a dedicated two-stage analyzer for leex and yecc modules that operates directly on standard .beam abstract code (Dbgi):
-
Zero Skeleton Noise (
compiler_generatedpropagation): Prisma honors-compile({nowarn_unused_function, ...})and-dialyzer({nowarn_function, ...})attributes emitted byleexandyecc, as well as DFA/LR transition functions (yystate/*,yyaction/*,yeccpars2_*,yeccgoto_*), suppressingoxbow_codeanddead_codechecks on generated template code while still analyzing user-written helper functions in the.xrland.yrlErlang code sections. -
Exact Token Union Extraction from
leex: Instead of running general fixpoint iteration over giantyystate/7character transition tables, Prisma inspects the semantic token actions inyyaction/5, inferring the exact union of token tuples produced by the lexer ({key, integer(), binary()},{value, integer(), binary() | integer() | float()},{'[', integer()}, etc.) and refiningzucchini_lexer:string/1,2. -
LR Automaton Stack Simulation for
yecc: Foryeccparsers, Prisma extracts the shift (yeccpars1/7) and goto (yeccgoto_*/7) transition graph fromyeccpars2_*/7and walks the LR state predecessor graph of each reduction actionyeccpars2_*_/1([__N, ..., __1 | __Stack] = __Stack0) to bind every stack slot__kto its exact terminal token or nonterminal AST type. Evaluating the inline semantic actions to a fixpoint yields the exact AST type returned at the accept state ('$end').
For zucchini_parser:parse/1, Prisma automatically infers:
{error, { term(), atom(), [any(), ...] }} |
{ok, [{binary(), []} |
{binary(), [{binary(), undefined} |
{binary(), float() | integer() | binary()}, ...]}, ...]}
3. Performance & Resource Benchmark (OTP 29)
We benchmarked Prisma (prisma check and prisma build) against Dialyzer (rebar3 dialyzer with a warm OTP 29 PLT) on zucchini using /usr/bin/time -l on Erlang/OTP 29 (ERTS 17.1.1, Apple Silicon).
Execution Metrics Table
| Metric |
Prisma (check) |
Prisma (build .pplt) |
Dialyzer (check) |
Prisma Advantage |
|---|---|---|---|---|
| Wall-Clock Time (Real) | 0.18 s | 0.18 s | 2.00 s | 11.1x faster |
| Total CPU Time (User + Sys) | 0.26 s | 0.28 s | 2.43 s | 9.35x less CPU |
| User CPU Time | 0.14 s | 0.14 s | 1.87 s | 13.4x less User CPU |
| System CPU Time | 0.12 s | 0.14 s | 0.56 s | 4.67x less Sys CPU |
| Peak Memory Footprint | 76.6 MB | 75.5 MB | 344.5 MB | 4.50x less RAM |
| Instructions Retired | 1.88 × 10⁹ | 1.83 × 10⁹ | 21.37 × 10⁹ | 11.4x fewer instructions |
| CPU Cycles Elapsed | 0.76 × 10⁹ | 0.79 × 10⁹ | 7.14 × 10⁹ | 9.39x fewer cycles |
| Page Reclaims | 7,292 | 7,136 | 39,149 | 5.37x less paging |
Cache Artifact Size (.pplt vs .plt) |
5.2 KB | 5.2 KB | 1,446 KB | 277x smaller |
Wall-Clock & CPU Time Comparison
xychart-beta
title "Execution Time in Seconds (Lower is better)"
x-axis ["Prisma Wall", "Prisma CPU", "Dialyzer Wall", "Dialyzer CPU"]
y-axis 0 --> 3
bar [0.18, 0.26, 2.00, 2.43]
Processor Workload (Instructions Retired in Billions)
xychart-beta
title "CPU Instructions Retired (Billions, Lower is better)"
x-axis ["Prisma (.pplt)", "Prisma (check)", "Dialyzer (check)"]
y-axis 0 --> 22
bar [1.83, 1.88, 21.37]
Peak Memory Footprint (MB)
xychart-beta
title "Peak Memory Footprint in MB (Lower is better)"
x-axis ["Prisma (.pplt)", "Prisma (check)", "Dialyzer (check)"]
y-axis 0 --> 360
bar [75.5, 76.6, 344.5]
4. What Prisma Found in zucchini (and Why Dialyzer Missed It)
Running prisma check on zucchini 0.1.1 immediately reported a gradual_mismatch warning on zucchini:parse_string/1, while rebar3 dialyzer reported 0 warnings:
[gradual_mismatch] zucchini:parse_string/1 (line 7)
src/zucchini.erl:7
Declared: {error, any()} | {ok, [{binary(), [{binary(), term}]}]}
Inferred: {error, any()} | {ok, [{binary(), [{binary(), term}]}]} | {ok, [{binary(), nil}, ...] | [{binary(), nil} | {binary(), [{binary(), undefined}, ...]}, ...] | [{binary(), nil} | {binary(), [{binary(), undefined}, ...]} | {binary(), [{binary(), undefined}, ...] | [{binary(), undefined} | {binary(), float() | integer() | binary()}, ...]}, ...]}
Bug 1: The Bare Atom 'term' vs. Type term() in -spec (src/zucchini.erl:5,13)
Look closely at the -spec declarations added in zucchini 0.1.1:
-spec parse_string(binary() | string()) -> {ok, [{binary(), [{binary(), term}]}]} | {error, term()}.
-spec parse_file(binary() | string()) -> {ok, [{binary(), [{binary(), term}]}]} | {error, term()}.
In Erlang type specifications, term() with parentheses is the top type (any()), whereas bare term without parentheses is the atom literal 'term'!
-
Why Dialyzer missed it: Because
yeccpre.hrltypeszucchini_parser:parse/1as returning{ok, any()} | {error, any()}, Dialyzer intersectsany()with[{binary(), [{binary(), 'term'}]}], sees a non-empty intersection, and assumeszucchini_parser:parse/1can legitimately return{ok, [{<<"k">>, [{<<"v">>, 'term'}]}]}. Worse yet, any downstream caller ofzucchini:parse_file/1orzucchini:parse_string/1gets poisoned by a contract claiming that every INI property value is the atom'term'! -
Why Prisma caught it: Prisma propagated the token types from
zucchini_lexerthroughzucchini_parser's LR stack reductions and inferred that property values are actuallybinary() | integer() | float() | undefined—none of which is a subtype of the atom literal'term'.
Bug 2: Unhandled Lexer Error Crash (src/zucchini.erl:10)
Inspecting zucchini:parse_string/1 alongside its contract revealed a second bug:
parse_string(String) when is_list(String) ->
{ok, Tokens, _} = zucchini_lexer:string(String),
zucchini_parser:parse(Tokens).
Although -spec parse_string/1 promises {ok, ...} | {error, term()}, when zucchini_lexer:string/1 encounters an illegal token (such as an unclosed double quote in [sec]\nkey = "unclosed\n), it returns {error, ErrorInfo, EndLine}, causing parse_string/1 to crash with a badmatch exception instead of returning {error, ErrorInfo}.
Bug 3: Regressions in zucchini_lexer.xrl Breaking the README.md Example
Because zucchini previously had no test/ suite, adding an EUnit suite starting from zucchini's own README.md example uncovered two lexer regressions introduced in 2017:
-
Quoted values kept literal quotes: Rule
{S}*".+"calledto_binary/1instead of stripping the surrounding quotes (to_string/1), turningfortune = "Pumpkin carving = Awesome"into<<"\"Pumpkin carving = Awesome\"">>instead of<<"Pumpkin carving = Awesome">>. -
Inline comments after section headers caused a syntax error: On
[yellow] ; It's just called Yellow!, the fallback value rule{S}*[^=\[;""\n]+did not exclude]or leading whitespace before;. Underleexlongest-match semantics,yellow](8 chars including the trailing space before;) beat the section key rule{S}*{A}+{S}*\](yellow], 7 chars), tokenizing the section header as{value, 9, <<"yellow]">>}and failing with a syntax error!
5. Modernizing zucchini 0.1.2 and Shipping zucchini_0.1.2.pplt
In zucchini 0.1.2, we resolved all of these issues:
-
Exported exact domain types in
zucchini.erl:
With this specification, Prisma's inferred AST type from-type value() :: binary() | integer() | float() | undefined. -type property() :: {binary(), value()}. -type section() :: {binary(), [property()]}. -spec parse_string(binary() | string()) -> {ok, [section()]} | {error, term()}. -spec parse_file(binary() | string()) -> {ok, [section()]} | {error, term()}.zucchini_parser:parse/1matches the declared[section()]contract cleanly (Prisma: 0 warnings found.). -
Handled
{error, Error, _}fromzucchini_lexer:string/1inzucchini:parse_string/1. -
Modernized
zucchini_lexer.xrlfor OTP 20+: Replaced all deprecatedstring:strip/1,2andstring:substr/3calls withstring:trim/1,2andlists:sublist/2,3, restored quote stripping on{S}*"[^"\n]*", and fixed the unquoted value and comment rules so inline comments after section headers parse cleanly. -
Added EUnit test suite (
test/zucchini_tests.erl) and shippedzucchini_0.1.2.pplt(5.2 KB) in the package so downstream consumers likeephpget instant, exact type checking forzucchini:parse_file/1andzucchini:parse_string/1.