ASR text format

ASR text is a lossless representation of an ASR translation unit. It is intended for compiler tests, generated-input reduction, and direct experimentation with ASR without going through a language frontend.

The format is an EDN data subset, defined by the grammar below. A standard EDN reader can read it once handlers for the namespaced #asr/* tags are registered; tests/asr/check_edn.py enforces that on every build by reading printed ASR back with a third-party EDN implementation. LFortran reads the format as data only; it does not evaluate Clojure code.

A document is a single ASR constructor, normally a TranslationUnit. There is no envelope and no version field: the format is defined by this document, and a future incompatible encoding would announce itself, with its absence meaning the encoding described here.

Printing ASR text

The named form is the default for interactive dumps:

lfortran input.f90 --show-asr --clojure

It prints every non-location field using the exact field names in src/libasr/ASR.asdl:

(Var
  :v (SymbolRef 1 "x")
)

The positional form omits field names but preserves ASDL declaration order. asr_clojure reference tests use this form so the stored dumps stay small:

lfortran input.f90 --show-asr --clojure --no-member-names
(Var (SymbolRef 1 "x"))

--no-indent emits either form on one line. Printed ASR text never contains color escape sequences, elides intrinsic modules, or truncates constant data.

The legacy output of --show-asr without --clojure remains available during the format migration.

Reading ASR text

Files ending in .asr are read as ASR text:

lfortran program.asr

--from-asr selects ASR text explicitly when a different extension is used. The first implementation supports one ASR translation unit with the default LLVM backend, including --show-asr, --show-llvm, --pass, --skip-pass, -S, -c, and executable linking. When linking an executable, the ASR translation unit itself must contain exactly one main program; additional object files and libraries provide dependencies rather than the entry point.

The parser accepts named and positional constructors. The two forms can appear in one file, but a single constructor cannot mix positional members with keyword/member pairs. Committed .asr fixtures use the positional form, the same form as asr_clojure reference output: a corpus is expected to grow large, and a fixture written positionally is a fraction of the size. A valid fixture can therefore be regenerated with --show-asr --clojure --no-member-names.

--verify-asr parses a standalone ASR file and runs only its initial verifier. It does not execute ASR passes or code generation, so corpus runners can distinguish an accepted initial verifier rejection from any later compiler failure.

Grammar

The reader accepts exactly the following. It is a subset of EDN: everything here reads identically under a conforming EDN reader, but the reverse does not hold, since EDN forms with no meaning in ASR text (sets, characters, ratios, namespaced keywords, metadata) are rejected.

document    = value ;

value       = list | vector | map | tagged
            | keyword | symbol | string | integer | float
            | "nil" | "true" | "false" ;

list        = "(" , { value } , ")" ;          (* constructors and products *)
vector      = "[" , { value } , "]" ;          (* ASDL sequences *)
map         = "{" , { value , value } , "}" ;  (* symbol table entries *)
tagged      = "#" , tag , value ;
tag         = "asr/bytes" | "asr/float64" | "asr/real128" | "asr/loc" ;

keyword     = ":" , name ;                     (* member names, enum values *)
symbol      = name ;                           (* constructor names *)
name        = ( letter | "_" ) , { letter | digit | "_" } ;

string      = '"' , { char | escape } , '"' ;
escape      = "\\t" | "\\r" | "\\n" | '\\"' | "\\\\" | "\\u" , 4 * hex ;

integer     = [ "-" ] , digit , { digit } ;
float       = [ "-" ] , digit , { digit } , [ "." , { digit } ]
            , [ ( "e" | "E" ) , [ "+" | "-" ] , digit , { digit } ] ;

hex         = digit | "a".."f" | "A".."F" ;

Whitespace, commas and ; line comments separate values and are otherwise insignificant. A constructor is a list whose first element is a symbol naming an ASR constructor; its remaining elements are either all positional values in ASR.asdl declaration order, or :member value pairs. The two forms may appear in the same document but not within one constructor.

Encoding and text semantics

  • A document is UTF-8. This is the only encoding the format is defined in, and it is what makes the EDN claim meaningful.

  • Fortran character values are byte arrays, not text. A character constant can hold bytes that are not valid UTF-8, such as achar(200). Those have no string spelling, so they are written as #asr/bytes instead. Writing them raw would produce a document no reader can decode, and escaping them as \u00XX would silently turn one byte into a two-byte character. Wherever a string may appear, #asr/bytes is therefore also accepted.

  • Only portable escapes are emitted. \t, \r, \n, \", \\ and \uNNNN. Other control characters, including backspace and form feed, are written as \uNNNN rather than \b and \f, which are not part of EDN.

  • Round-tripping is byte-exact. Printing ASR, reading it back and printing it again yields identical bytes, including for values that take the #asr/bytes path.

  • Floating point is exact. Finite values print with enough digits to round-trip; infinities and NaNs print as #asr/float64 bit patterns, since EDN has no literal for them. real(16) uses #asr/real128.

Data forms

ASR text uses:

  • lists for constructors and ASDL products;

  • keywords for member names and enum values;

  • vectors for sequences;

  • maps for symbol table entries;

  • strings for identifiers and string values;

  • nil for absent optional fields;

  • true and false for logical fields;

  • EDN integers and finite floating-point values;

  • semicolon comments and optional commas.

All non-location fields are explicit in canonical named output, including absent optional fields, empty sequences, and false logical values.

SymbolRef is a reserved text-format form that identifies a symbol by its document-local symbol table ID and exact symbol-table key:

(SymbolRef 1 "x")

Other ASR-specific values use namespaced tagged elements:

  • #asr/bytes "..." stores an exact raw constant payload;

  • #asr/float64 "..." and #asr/real128 "..." preserve values that cannot round-trip through an EDN decimal;

  • #asr/loc [[first last] value] overrides a generated text location with inclusive byte offsets in focused tests.

The compact spelling @x is not used because a Clojure reader interprets @ as the dereference reader macro.

Graph references

ASR contains a graph rather than a pure tree. Symbol tables own symbol definitions, while expressions, types, and other symbols refer back to those definitions. Canonical text assigns deterministic integer IDs such as 0 and 1 to owning symbol tables before printing any references.

The decoder first creates all symbol tables and typed symbol shells, then fills definitions and resolves references. This permits forward references and cycles while preserving pointer identity.

Locations and diagnostics

Original Fortran byte offsets are not included by default. Each parsed ASR node is assigned the location of its constructor token in the .asr file. ASR parser and verifier diagnostics therefore highlight the direct ASR input:

ASR verify pass error: ...
 --> example.asr:12:4

Explicit location tags are reserved for tests that need a specific span.

Parser and verifier boundary

The text parser rejects malformed EDN, unknown constructors or fields, missing required fields, wrong field categories, duplicate definitions, and unresolved textual references.

It does not enforce semantic ASR invariants. Once decoding succeeds, asr_verify is responsible for type, rank, symbol, ownership, and other ASR requirements. Consequently:

  • malformed text produces an ASR syntax diagnostic;

  • structurally decoded but invalid ASR produces an ASR verifier diagnostic;

  • verifier-valid standalone ASR proceeds through the normal pass and LLVM pipeline.

Regression corpus

A minimized ASR graph is checked in under tests/asr/ and registered in tests/tests.toml like any other reference test, so the ordinary reference suite runs it:

  • tests/asr/compile/ holds graphs the verifier accepts, registered with llvm = true so they must lower to LLVM IR. A registered CTest also links every one of them into an executable, which is the property the fuzzer’s contract turns on. They are not run: a generated program may fault at runtime for reasons no ASR verifier could predict;

  • tests/asr/verify/ holds graphs the verifier rejects, registered with asr = true so the stored reference captures the exact diagnostic, its stable code, and the span it points at.

Because the diagnostic code is part of the rendered message, a fixture pins the specific verifier rule it exercises rather than the wording alone.

Fixtures are stored in the printer’s indented form, so a diagnostic points at a single short line rather than at one long one, and the caret identifies the offending constructor. Regenerate a valid fixture with --show-asr --clojure --no-member-names.

--verify-all-passes runs the verifier after every ASR pass regardless of the build type, so a pass that corrupts a previously valid graph is reported against that pass instead of surfacing later as an unrelated failure.

The reusable APIs in src/lfortran/pipeline.h own Fortran-or-ASR loading and the phase-aware ASR-to-default-passes-to-LLVM-to-object path, so the CLI and the direct-ASR tools share one implementation.

Deterministic mutation fuzzing

tests/asr/fuzz.py dynamically prints the verified pre-pass ASR produced from registered integration-test seeds, applies one field-aware mutation, and runs the two-outcome oracle in isolated compiler subprocesses. It also has deterministic schema-generated modes that construct small verifier-valid integer programs or intentionally invalid ASR directly, without using the Fortran frontend.

The invalid generators cover three families, chosen because they are the two things a frontend most often gets wrong and the one thing that makes separate compilation worth having:

  • references that do not resolve: a name read from a scope that does not contain it, a call to something that is not a procedure, a type-bound procedure that names a variable, an import from a module that is not there. This is the ASR a frontend produces when a symbol was never imported;

  • calls that disagree with the procedure they call: actual arguments whose type, kind, rank or count the callee never declared, including through a type-bound call, and a call statement naming a function;

  • type-bound procedure overrides that do not conform: an extending type whose binding takes different arguments, or returns differently, from the binding it overrides.

python tests/asr/fuzz.py \
  --lfortran src/bin/lfortran \
  --seed 1234 \
  --cases 1000 \
  --generator all

Each case first runs initial verification. A verifier rejection is accepted; verifier-valid ASR must then emit both an object and an executable with post-pass verification enabled. Timeouts, signals, parser failures, pass verification failures, LLVM failures, object failures, and link failures are persisted under asr-fuzz-artifacts/ as the exact ASR input plus JSON metadata. The metadata records the source integration test, random seed, case index, mutation, input hashes, failing phase, commands, and output. Each campaign also writes coverage.json containing the ASR constructors, enum values, verifier rules, passes, mutation classes, seed sources, outcomes, and failure phases observed during the run.

Persisted failures can be replayed without regenerating or mutating the seed:

python tests/asr/fuzz.py \
  --lfortran src/bin/lfortran \
  --replay asr-fuzz-artifacts/failure-000000-....json

The structural reducer greedily removes vector elements and symbol-table entries, replaces optional fields with nil, and shrinks numeric values while requiring the same initial-verification status and normalized failure phase:

python tests/asr/reduce.py \
  --lfortran src/bin/lfortran \
  --metadata asr-fuzz-artifacts/failure-000000-....json

A verifier rejection is an accepted outcome for the fuzzer, but it is worth reducing too, since that is what a tests/asr/verify/ fixture pins. Pass --input a .asr document instead of a fuzzer artifact and the reducer keeps the rejection’s diagnostic code fixed rather than a failure phase:

python tests/asr/reduce.py \
  --lfortran src/bin/lfortran \
  --input rejected.asr

It writes a canonical .min.asr file and reduction journal without modifying the original failure artifact. The reduced file is written in the indented positional form the committed fixtures use, so it can be added to tests/asr/verify/ directly.

Running a campaign locally

The registered CTest cases are smoke tests: a handful of cases, so the harness cannot rot as the passes change. A real campaign is run by hand, and is worth running against a sanitizer build, where a mutation that corrupts memory is reported at the point of corruption rather than as a later crash:

cmake -S . -B build-asan -G Ninja -DCMAKE_BUILD_TYPE=Debug -DWITH_LLVM=ON \
    -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined"
cmake --build build-asan -j
python tests/asr/fuzz.py \
  --lfortran build-asan/src/bin/lfortran \
  --seed 0 \
  --cases 1000 \
  --generator all \
  --strategy mixed

Campaigns are deliberately not scheduled in CI: a finding needs a person to triage it, and a scheduled job with no owner turns red and stops meaning anything. Run one when the ASR, the passes or the verifier change in a way worth stress testing.

tests/asr/check_llvm_coverage.py compares every constructor generated from ASR.asdl with the LLVM visitor and tests/asr/llvm_constructor_coverage.toml. A constructor must either have a direct LLVM visitor or an explicit classification naming its lowering pass, helper path, metadata role, or non-executable status. New unclassified constructors fail the registered CTest.