STRUMENTA LANGUAGE ENGINES · SAS

A SAS program is three languages. This engine reads all three.

The DATA step, twenty-three PROC steps, and the macro layer that writes both of them at run time. 343 typed AST node declarations, 151 automated tests, eleven releases, and a regression corpus of real published SAS.

The macro facility is why free SAS tooling does not exist. It generates source text, so a faithful SAS parser would have to be a SAS interpreter. We recognize macros instead of expanding them — and keep both in the tree.

The engine at a glance
SCOPEDATA step · 23 PROCs · macro layer
AST343 type declarations
TESTS151 automated
RELEASES11 · latest 1.7.1, July 2026
OUTPUTAST · JSON · EMF/Ecore · LionWeb
RUNTIMEJVM library · CLI · offline

The layer that writes the program

SASParser.g4 · DataStep.g4 · OtherProc.g4 · ProcSQL.g4 · ANTLR 4.13.2

SAS is not one language. It is a row-at-a-time imperative language, a family of procedure sub-languages, and a text generator that runs before either of them and produces their source. The third one is why this is hard.

The SAS macro processor generates the text that the DATA step and PROC compilers then read Source text enters the macro processor, which resolves ampersand references and percent-sign control flow and emits generated program text. Only that generated text reaches the DATA step compiler and the PROC compilers. Because the generated text depends on values known at run time, the program that will be compiled is not fully determined by the file on disk. .sas source text macro processor DATA step compiler PROC compilers %MACRO · &var emits generated text THE FILE ON DISK IS NOT THE PROGRAM THAT GETS COMPILED. HOW MANY STATEMENTS IT BECOMES CAN DEPEND ON A VALUE READ FROM A DATASET.

The macro facility is not a macro system in the Lisp or even the C sense. %LET, %MACRO, %IF, %DO and &variable references are resolved by a separate processor that emits character text, and that text is what the DATA step and PROC compilers actually see.

A macro can emit any fragment at all: half a statement, a bare option, a list of variable names, an unbalanced quote. %if &prod %then %do; where region='EU'; %end; emits a clause, not an expression. A grammar that insists on a well-formed statement before it will accept anything cannot read a file like that.

&&name&i goes further: the name of the variable being referenced is itself computed. And %STR() and %NRSTR() change how the following characters are tokenized, including masking semicolons and unbalanced parentheses.

Follow that to its conclusion and you get the honest sentence: a fully faithful SAS parser would have to be a SAS interpreter. So we do not pretend to expand. We recognize the macro layer as structure, keep it in the tree beside the code it generates, and re-parse macro bodies only when something asks for them.

Input — a macro that generates syntax

extract.sas

%macro extract(region=, months=12);
   %let cutoff = %sysfunc(intnx(month,
       %sysfunc(today()), -&months., E), date9.);
   %let n = 3;
   %do i = 1 %to &n.;
      %let tbl&i. = sales_&region._&i.;
   %end;
   proc sql;
      create table work.summary_&region. as
      select t.customer_id, sum(t.amount) as total
      from connection to oracle
           (select customer_id, amount from ORDERS)
      %do i = 1 %to &n.;
         union all select customer_id from &&tbl&i.
      %end;
      group by t.customer_id
      having calculated total > 0;
   quit;
%mend extract;
The number of union all branches in that query is not knowable without evaluating &n. &&tbl&i. computes the name of the variable it reads. calculated total is a PROC SQL extension no ANSI parser accepts, and the parenthesized text is Oracle SQL, not SAS.

Output — AST, by node class

intermediate nodes elided

SourceFile
└── MacroDefinition extract, 2 MacroArgument
    ├── VariableDeclaration cutoff
    │   └── SystemFunctionCallExpression %sysfunc
    │       └── SystemFunctionCallExpression %sysfunc
    ├── DoStatement i = 1 %to &n
    │   └── VariableDeclaration tbl&i
    └── ⋯ PROC SQL step
        ├── DatasetSpec work.summary_&region
        ├── VariableExpression &&tbl&i — indirect
        └── nativeSQL verbatim, not parsed as SAS
These are real node classes from the shipped AST, not a diagram of one. The macro body is a lazily-evaluated property: it is re-parsed on demand rather than eagerly, because the same body can legitimately be read in more than one way.

Four ways to get SAS wrong

Each of these is a place where a lexer, not a parser, has to make the decision — and each one appears in the regression suite because it appeared in a client’s code first.

Double-ampersand indirection

The name of the macro variable is itself assembled from another macro variable. You cannot resolve the reference by reading the source; you can only record that the reference is computed. The engine has a direct test for &&&foo.

%let tbl1 = sales_eu;
%let i = 1;
%put &&tbl&i;   * reads tbl1;

%STR() masking

Quoting functions change how the characters after them are tokenized: slashes stop being operators, semicolons stop ending statements. Note the space between %STR and its parenthesis, and the double dot — the first ends the variable name, the second is a literal.

%Let SaveLog = %STR (/ccrm/RM/&env
      /Bankcard/&ProgName..log);

Pass-through SQL is not SAS

Inside connection to oracle ( … ) the text is Oracle’s dialect, not SAS’s. We capture it verbatim as a nativeSQL node rather than pretending to understand it — and you can hand it to whichever of our SQL engines matches the database.

proc sql;
  connect to oracle as ora ('&cred');
  select * from connection to ora
    (select * from dual);
quit;

INPUT, in four different modes

Column, formatted, list and named input are four distinct grammars sharing one keyword, plus column and line pointer controls. They are modeled as four separate node kinds, which is what lets an inventory tool tell you the record layout a program expects.

data orders;
  input sku $ 1-8 @10 qty 3. #2 note $40.;
  datalines;
…

Case is handled by a dedicated case-insensitive fragment grammar rather than by lowercasing the input, so original casing survives into tokens and positions — which matters the moment you want to print a report that quotes the source. DATALINES pushes its own lexer mode, so inline data is never mis-tokenized as code. And a real client export once arrived carrying U+FFFD replacement characters; the lexer has handled that since release 1.5.1.

Coverage, in four tiers

PARSED DEEPLY RECOGNIZED SHALLOWLY PRESERVED VERBATIM NOT COVERED

Most vendor pages have one tier and call it “supported”. SAS does not permit that, because the honest answer differs by construct. Here is the whole surface, sorted by how much structure you actually get.

TIER 01

Parsed deeply — full typed AST

The DATA step, with around thirty statement kinds — SET, MERGE, UPDATE, ARRAY, RETAIN, KEEP/DROP/RENAME, DO/IF/SELECT, and INPUT in four modes. PROC SQL, the largest sub-grammar in the engine. PROC DATASETS, FREQ, MEANS, SORT, SUMMARY, TRANSPOSE, TIMESERIES, EXPORT and IMPORT. Macro definitions, parameters and macro control flow. And the global statements — LIBNAME, OPTIONS, FILENAME, TITLE, ODS.

Evidence. 343 type declarations across the AST source, 104 of them for PROC SQL alone. Dedicated test classes for the DATA step, PROC SQL, the PROC SQL AST and the macro layer.

TIER 02

Recognized and structured, shallowly

PROC PRINT, MODEL, HTTP, SGPLOT, CONTENTS, APPEND, COMPARE, FORMAT and PRINTTO are modeled as a procedure node carrying a generic option list rather than a typed option for every keyword. You get the step, its dataset references and its options as name/value pairs — enough for inventory and lineage, not enough to reason about the semantics of an individual option.

Evidence. Generic option productions such as procContentsOption : identifier (EQUAL dataset)?; the changelog entry adding minimal support for PROC PRINT.

TIER 03

Preserved verbatim, not interpreted

Pass-through SQL bound for a foreign database is kept as a nativeSQL node and can be re-parsed with the matching Strumenta engine. Macro text that cannot be given structure survives as a PlainTextStatement rather than being dropped. Date and datetime constants that do not conform become explicit unparsable-constant nodes, so you can find them. DATALINES blocks are held as data.

Evidence. The nativeSQL production in the PROC SQL grammar; PlainTextStatement, UnparsableDateConstantExpression and UnparsableDateTimeConstantExpression in the AST; dedicated datalines lexer modes.

TIER 04

Not covered today

Procedures outside the twenty-three named ones. SAS/IML, SAS/OR, SAS Component Language, and SAS/GRAPH beyond SGPLOT. The top-level rule is a closed alternation of the PROCs we model, so an unmodeled procedure is a parse failure with a positioned issue, not a silent shrug — and additional PROCs are added on request. That is ordinary work for us: the pattern is established, and the twenty-three we ship were added the same way.

Evidence. The 23 PROC_* lexer tokens across the procedure grammars; absence of any catch-all alternative in the top-level rule.

The twenty-three procedures

Every procedure with a dedicated token in the grammar we ship. Depth is the tier above, not a marketing grade — PRINT and MODEL are deliberately listed as shallow.
Group Procedures Depth
Query and data manipulation SQL, FEDSQL, DATASETS, SORT, TRANSPOSE, APPEND, DELETE Typed AST
Summary and statistics MEANS, SUMMARY, FREQ, TIMESERIES, EXPAND Typed AST
Import, export and transport IMPORT, EXPORT, HTTP Mixed — IMPORT and EXPORT typed, HTTP shallow
Reporting and metadata PRINT, CONTENTS, COMPARE, FORMAT, PRINTTO, SGPLOT Shallow — procedure node plus generic options
Modeling MODEL, TMODEL Shallow — minimal option grammar
Anything else Not covered — added on request

On versions we would rather be exact than impressive. There is no SAS standard to conform to: SAS is proprietary and the vendor documentation is the specification, so the engine is modeled on the documented SAS 9.4 language. The language is strongly backward compatible, which means the useful question is not “which release” but “which procedures does this shop actually use”. Run grep -io '^ *proc [a-z]*' over your estate, count the results, and you will know in ten minutes whether the twenty-three cover you.

One file that no free tool reads

This is a fair sample of a production SAS program: a macro whose body is not valid SAS on its own, wrapped around a PROC SQL query whose shape is decided at run time. Every construct marked below is exercised by a test in the suite.

Input — a macro-generated PROC SQL step

extract.sas · every marked line has a test behind it

%macro extract(region=, months=12);
   %let cutoff = %sysfunc(intnx(month, %sysfunc(today()), -&months., E), date9.);
   %let n = 3;
   %do i = 1 %to &n.;
      %let tbl&i. = sales_&region._&i.;
   %end;

   proc sql;
      create table work.summary_&region. as
      select  t.customer_id,
              sum(t.amount) as total,
              count(*)      as n_orders
      from    connection to oracle
              (select customer_id, amount from ORDERS where dt >= to_date('&cutoff.'))
      %do i = 1 %to &n.;
         union all select customer_id, amount from &&tbl&i.
      %end;
      group by t.customer_id
      having calculated total > 0;
   quit;
%mend extract;
Six marked constructs, six reasons a general-purpose SQL parser stops here. There is no free SAS grammar to fall back on and no open specification to write one from — SAS Institute’s documentation is the specification, and it is prose. The alternatives are to build this yourself, or to license it.
RECOGNITION WITHOUT EXPANSION

We do not expand macros. We keep them.

Expanding requires evaluating, and evaluating requires being SAS. Every design decision below follows from refusing that trade.

Macros are first-class nodes. Definitions, parameters, %IF/%DO control flow, %LET, %SYSFUNC, %STR/%NRSTR and %INCLUDE are all AST classes — thirty-seven of them. The macro survives as structure instead of being flattened into whatever text it happened to produce on one particular run.

Bodies are parsed lazily, from the tree. A macro body, and the two branches of a macro %IF, are lazily-evaluated properties. When something asks for one, it is re-parsed through a token source that replays tokens out of the parse tree that already exists rather than re-reading and re-tokenizing the input. The same body can legitimately be read more than one way; deferring the choice is the only correct answer.

Text that resists structure is kept anyway. Anything the macro grammar cannot shape becomes a PlainTextStatement, verbatim and positioned. Nothing is silently dropped, which is the property you need when the deliverable is an inventory that has to be complete.

%INCLUDE is modeled, not resolved. The directive is an AST node and its target is an expression — because the path can itself be built from macro variables. That is exactly why include resolution is a codebase-level problem rather than a parse-level one: the engine gives you every directive with its target so you can build the dependency graph; it does not guess which file a computed path meant.

The command-line tool
parse       check a file or a tree of files
jsonast     write the AST as JSON
model       write the EMF/Ecore model
metamodel   export the metamodel itself
downloadLicense  refresh the license file

$ sas-engine jsonast --destination out/ estate/
$ sas-engine model --include-metamodel out/

The same artifact is the JVM library. In process you get the AST and the positioned issue list directly; on the command line you get it serialized, which is usually what a pipeline step wants. Nothing calls SAS and nothing calls us: the engine reads exported .sas text offline.

343
TYPE DECLARATIONS IN THE AST
104
OF THEM FOR PROC SQL ALONE
151
AUTOMATED TESTS
11
RELEASES, THE LATEST IN JULY 2026

What you receive

A jar, the documents that explain the tree it produces, a documentation site, and a person to write to. No SAS installation, no server connection, no code leaving your network.

01 The engine

A JVM library and a command-line tool

Kotlin on the JVM, Java 8 or later, callable from Java or Kotlin, plus a fat jar with Windows and Linux launch scripts. A license file is registered at start-up; licenses are refreshed from a license service, so tell us early if the engine has to run air-gapped.

library · CLI · offline parsing
02 The model

A typed AST, a metamodel, and four ways out of the process

A typed AST rooted at a source file, with a position on every node and comments retained as nodes rather than discarded. Serialize to JSON, to EMF/Ecore XMI or EMF-JSON, or export the AST and the full metamodel to LionWeb, so the model travels to tooling we did not write. Bindings from Java, Kotlin, Python, TypeScript and C#.

JSON · EMF/Ecore · LionWeb · five bindings
03 The documents

A versioned AST reference and a cookbook

The delivery package carries HTML documentation and worked examples, and the engine has its own documentation site at sas.strumenta.com: a versioned AST reference, an interactive AST viewer, and cookbook recipes for data lineage, program inventory, macro analysis and cross-file dependencies.

AST reference · AST viewer · recipes
04 The license

Standard, Distribution or Service — support included

Standard for use inside your own organization, Distribution if the engine ships inside a product you sell, Service if it runs behind a service you operate. Support is part of the license, not an upsell. The guarantee runs a year and is extendable, or can be taken pay-as-you-go.

three tiers · support included
What the model is good for

Every declaration, every dataset read and write, every branch and every macro call as a typed, positioned node.

Which is what an inventory, a lineage graph or a program-complexity report is built from — the traversal is a day of work, and the cookbook has recipes for the common ones. We do not ship a metrics dashboard or a lineage product, and we would rather say so than let you find out in week three.

THE REST OF THE ESTATE

A SAS exit is never SAS-only.

The pass-through SQL is Oracle’s or Teradata’s. The scheduler is somewhere else. The target is Python or Spark. Three of those four are also things we parse.

Every Strumenta engine is built on Starlasu, so a SAS tree, a PL/SQL tree and a COBOL tree have the same shape, the same traversal model and the same API. One visitor walks all of them. That is the thing you cannot assemble by gluing unrelated open-source parsers together: five parsers means five models, five idioms and five sets of edge cases to reconcile before you have written a line of analysis.

Every engine also supports LionWeb, so the models interchange with LionWeb-compliant tooling instead of being locked inside one process — and every engine is reachable through bindings from Java, Kotlin, Python, TypeScript and C#. The host application picks the language; the parser does not.

For SAS this is not decoration. The nativeSQL node is a hole in the SAS tree that another engine fills exactly, and the usual destination of a SAS exit — Python, PySpark, Databricks — is a language we generate. Transpilers built on this engine have processed tens of millions of lines.

Engines in the same estate

one model, one traversal

Oracle PL/SQL engine →
What the pass-through block usually contains.

Python engine →
The target side of a SAS exit, with a code generator.

COBOL engine →
The batch that feeds the datasets.

The whole catalog →
15+ engines, one AST framework underneath.

Why license this rather than build it

On the Java or Python pages we would send you to the free parser and mean it. Here there is nothing to send you to, and pretending otherwise would be the easiest claim on this page to check.

The three ways to get a SAS AST. We are the third; the first two are real options and it is worth knowing why they usually stall.
Option What you actually get Where it stops
A public grammar There is not one SAS is proprietary, the grammar is unpublished, and the vendor documentation is prose. Unlike PL/SQL or Java, there is no community grammar to start from.
Write your own Feasible, and expensive The macro layer defeats the naive approach, and the work is not the first ninety per cent. Our changelog is what the remaining ten per cent looks like: replacement characters in client exports, PROC SQL comment smuggling, unbalanced quoting inside %STR.
SAS Institute’s own tooling Excellent, inside SAS It is built to run SAS, not to give you a portable AST you can transform and emit somewhere else. If the project is a migration off SAS, the vendor’s tooling is the wrong shape for it.
This engine A typed AST, positioned, serializable With the limits printed further down this page, and someone to call about them.

The method behind the work has a name: read the Chisel Method for how we make parser work estimable, or Parsers and transpilers for the three ways into it.

What to discuss before you license

Six things we would rather tell you now than have you find in week three.

  • Twenty-three procedures, and no catch-all. The top-level rule is a closed list. A procedure we do not model is a parse failure with a position, not a node you can ignore. Additional PROCs are added on request and that is normal work — but it is work, so send us a frequency count of the proc keywords in your estate before you plan around a date.
  • Macros are recognized, not expanded. You get the macro as structure and the code around it, positioned. You do not get the program that a particular run would have produced, because producing it means evaluating SAS. If your analysis genuinely needs the expanded text, that is a different tool and a different conversation.
  • No symbol resolution. Variable and dataset references are AST nodes, not resolved references. There is no cross-file index, and %INCLUDE targets are captured as expressions rather than followed. Building the dependency graph on top is a documented recipe, not a shipped feature.
  • Pass-through SQL is preserved, not parsed. That is the correct behavior — the text belongs to another dialect — but it means a lineage analysis that crosses into the foreign database needs a second engine.
  • The code formatter is early access. There is a code-generation module, and it is not the mature part of this product. This engine reads SAS very well; do not buy it on the strength of writing SAS back out.
  • Licenses are refreshed from a service. The engine registers a license file at start-up and the license expires on the order of a month. In a connected environment that is invisible; in an air-gapped one it needs designing for, so raise it in the first call rather than the last.

What we will ask you

  1. How many .sas files, how many lines, and can you export them to a filesystem?
  2. Which procedures appear, and in what proportion?
  3. What fraction of the code is macro — and do you have the source for your autocall libraries and stored compiled catalogs?
  4. Is there pass-through SQL, and to which database?
  5. Are your %INCLUDE targets file paths, filerefs, or paths built from macro variables?
  6. Is the goal inventory, lineage, documentation, or transpilation? They need different depth.
  7. Does the engine have to run air-gapped?
  8. What language is the tool you are building written in?

Straight answers

Do you expand macros?

No, and it is deliberate. The macro processor generates text from values that may only exist at run time, so expanding faithfully would mean interpreting SAS. Instead macros are parsed as first-class nodes — definitions, parameters, %IF, %DO, %LET, %SYSFUNC, %STR, %INCLUDE — and their bodies are re-parsed lazily, on demand, from the parse tree that already exists. Text that cannot be given structure is preserved verbatim rather than dropped.

What happens to a procedure you do not support?

It fails to parse, and you get a positioned issue saying where. We are not going to tell you it is quietly preserved, because the top-level rule is a closed alternation of the twenty-three procedures we model and there is no catch-all in it. Additional procedures are added on request — that is how the current twenty-three got there. Send us a frequency count of the proc keywords in your estate and we will tell you what is missing.

Which SAS version does it target?

There is no SAS standard to conform to: the language is proprietary and SAS Institute’s documentation is the specification. The engine is modeled on the documented SAS 9.4 language, and SAS is strongly backward compatible, so in practice the question that decides a project is which procedures your code uses rather than which release you run.

Do we need SAS installed, or send you our code?

Neither. The engine reads exported .sas text offline, on a laptop or a CI server, with no SAS installation and no connection to a SAS server. It runs inside your own network. The one caveat is the license file, which is refreshed from a license service — if the machine is air-gapped, tell us early.

What has it actually been run against?

151 automated tests, plus a regression corpus of published open-source SAS that runs in CI: the Cleveland Clinic covid-19-sas model with its JHU, NYT and IHME data-import programs, and the SAS certification prep guides. Eleven releases since the first tagged one, the latest in July 2026.

Can we use it from Python or C#?

Yes. The engine is built on Starlasu and is reachable through bindings from Java, Kotlin, Python, TypeScript and C#, and it supports LionWeb, so the model interchanges with LionWeb-compliant tooling. If you would rather not embed anything, the command-line tool serializes the AST to JSON or EMF and any language can read that.

Send us a program the one with the macros in it

Send the macro nobody wants to touch.

A representative sample — a macro library, a PROC SQL step with pass-through in it, whatever your worst program is — and we will come back with what the engine reads, which procedures are missing, and what adding them would take. If SAS is not the hard part of your exit, we will say that too. If you would rather look before you write to anyone, the Strumenta Playground runs the engine in your browser — pick SAS from the language list.

Scroll to Top