Skip to content
sasjsPublic

About

Linting and formatting for SAS® code

Topics

Resources

Contributing

Security policy

Stars

32 stars

Watchers

2 watching

Forks

Repository files navigation

SAS Code linting and formatting

GitHub top language GitHub closed issues GitHub issues OpenSSF Scorecard

Our goal is to help SAS developers everywhere spend less time on code reviews, bug fixing and arguing about standards - and more time delivering extraordinary business value.

Note: The SASjs project and its repositories are not affiliated with SAS Institute.

Linting

@sasjs/lint is used by the following products:

  • @sasjs/vscode-extension - just download SASjs in the VSCode marketplace, and select view/problems in the menu bar.
  • @sasjs/cli - run sasjs lint to get a list of all files with their problems, along with line and column indexes.
  • SASjs Server - the Studio editor lints and formats its buffer through POST /SASjsApi/code/lint and POST /SASjsApi/code/format, resolving the .sasjslint nearest to the file so each folder in the drive can carry its own rules.

Configuration is via a .sasjslint file with the following structure (these are also the defaults - a setting that is absent, and a project with no .sasjslint file at all, uses the value below):

{
  "allowedGremlins": [],
  "bannedStrings": ["error ", "error:", "warning ", "warning:"],
  "defaultHeader": "/**{lineEnding}  @file{lineEnding}  @brief <Your brief here>{lineEnding}{lineEnding}  <h4> SAS Macros </h4>{lineEnding}{lineEnding}**/",
  "hasDoxygenHeader": true,
  "hasMacroNameInMend": true,
  "hasMacroParentheses": true,
  "hasRequiredMacroOptions": false,
  "ignoreList": [],
  "ignoredLibnames": [],
  "ignoredMacros": ["webout"],
  "indentationMultiple": 2,
  "lineEndings": "off",
  "lowerCaseFileNames": true,
  "lowerCaseMacroNames": true,
  "maxDataLineLength": 80,
  "maxHeaderLineLength": 80,
  "maxLineLength": 80,
  "minMacroNameLength": 3,
  "noBannedStrings": true,
  "noEncodedPasswords": true,
  "noGremlins": true,
  "noMacroIfInOpenCode": false,
  "noMalformedMacroCalls": false,
  "noNestedCommentsInHeader": true,
  "noNestedMacros": true,
  "noSingleAsteriskComments": false,
  "noSpacesInFileNames": true,
  "noTabs": true,
  "noTrailingSpaces": true,
  "noUndeclaredMacros": true,
  "noUnusedLibnames": false,
  "noUnusedMacros": true,
  "noXcmd": true,
  "requiredMacroOptions": [],
  "severityLevel": {},
  "strictMacroDefinition": true
}

Two keys do not have a section of their own. requiredMacroOptions is documented with hasRequiredMacroOptions, the rule it configures. The deprecated alias noTabIndentation is accepted in place of noTabs, and is therefore not listed above - it is an alternative spelling rather than a setting of its own.

The file is read as strict JSON, so it cannot carry comments: a file that does not parse is reported as an error, naming the file, rather than being treated as though no configuration were present. Add "$schema": "https://cli.sasjs.io/sasjslint-schema.json" to get completion, hover text and validation for every setting in an editor that understands JSON schemas - sasjs lint init and a SASjs Server's seeded drive-root file both write it.

SAS Lint Settings

Each setting can have three states:

  • OFF - usually by setting the value to false or 0. In this case, the rule won't be executed.
  • WARN - a warning is written to the log, but the return code will be 0
  • ERROR - an error is written to the log, and the return code is 1

For more details, and the default state, see the description of each rule below. It is also possible to change whether a rule returns ERROR or WARN using the severityLevel object.

Configuring a non-zero return code (ERROR) is helpful when running sasjs lint as part of a git pre-commit hook. An example is available here.

Diagnostic columns

A diagnostic carries lineNumber, startColumnNumber and endColumnNumber. Both columns are 1-based, and endColumnNumber is the column of the span's LAST character - a one-character span starts and ends on the same column. A consumer that highlights startColumnNumber through endColumnNumber inclusive therefore covers exactly the text the rule reports.

Per-file overrides

A file can adjust the configuration for itself, with a @sasjslint block in its Doxygen header:

/**
  @file
  @brief Calls the settlement API

  @sasjslint {"maxHeaderLineLength": 200, "noUndeclaredMacros": false}
**/

The block holds a JSON object that is merged over the resolved .sasjslint:

  • scalars replace, so a file can switch a rule off, switch one on, or raise a limit for itself;
  • arrays are additive, so allowedGremlins and requiredMacroOptions gain entries rather than replacing the project's;
  • objects merge key by key, so a file can set one severityLevel without dropping the others.

The override applies to the whole file, and to the formatter, so sasjs lint fix and format-on-save honour it - a file that switches noUnusedMacros off keeps the entries it would otherwise lose. Only the header is read, so an override cannot be hidden in the body of a file, and a block that is malformed is ignored rather than raised. Place it outside the <h4> SAS Macros </h4> section, which the formatter rewrites.

One exception: ignoreList is evaluated before a file is read, so an entry a header adds cannot exclude the file it sits in.

allowedGremlins

An array of hex codes that represents allowed gremlins (invisible / undesirable characters). To allow all gremlins, you can also set the noGremlins rule to false. The full gremlin list is here.

Example:

{
  "noGremlins": true,
  "allowedGremlins": ["0x0080", "0x3000"]
}

bannedStrings

An array of strings for the noBannedStrings rule to report. Matching is case insensitive, and each entry is matched literally rather than as a regular expression, so a trailing space means what it says: "error " matches error but not errors, error_flag or error:. Each default entry ends the word with the space or the colon that follows it in a log line.

The default list is ["error ", "error:", "warning ", "warning:"]. An explicit list replaces it, so "bannedStrings": [] reports nothing.

Example:

{
  "noBannedStrings": true,
  "bannedStrings": ["error ", "error:", "warning ", "warning:", "{sas001}"]
}

defaultHeader

This isn't a rule, but a formatting setting, which applies to SAS program that do NOT begin with /**. It can be triggered by running sasjs lint fix in the SASjs CLI, or by hitting "save" when using the SASjs VS Code extension (with "formatOnSave" in place)

The default header is as follows:

/**
  @file
  @brief <Your brief here>

  <h4> SAS Macros </h4>

**/

A blank line separates the <h4> SAS Macros </h4> section from the header content before it and from whatever follows the list.

<h4> SAS Macros </h4> is the compile dependency list: sasjs compile reads it and inlines each macro as precode, so an entry is a file the build resolves. It is the only header list this linter reads, and every other <h4> list in the header (SAS Includes, Binary Files, SAS Programs, Service Inputs, Service Outputs, ...) is left entirely alone.

Each entry is the tag and one file token - @li mf_trim.sas - indented to the same width as the section tag; nothing may follow the token. The extension is dropped to give the macro name, so @li mp_as is read as mp_as too. A line carrying anything after the file token is not an entry: prose belongs on its own line, and a section that carries such a line is left as its author wrote it rather than being rewritten or having the line removed. The section is machine-maintained, so text after the token would be lost on the next rewrite anyway.

A tag one slip away from @li (@il, @l1, @1i) is read as an entry, so a typo cannot hide the declarations below it - and because such a line is read, nothing reports it, so the formatter writes every entry back as @li <file> at the section's indentation. The repair edits the entry lines in place, so it adds no blank lines and moves nothing else in the header.

A line that opens with any other tag - @example, @param - ends the list rather than being read or converted. The entries above it are still readable and still rewritable, so the formatter keeps adding and removing entries above that line and leaves the rest of the header alone.

If creating a new value, use {lineEnding} instead of \n, eg as follows:

{
  "defaultHeader": "/**{lineEnding}  @file{lineEnding}  @brief Our Company Brief{lineEnding}**/"
}

hasDoxygenHeader

The SASjs framework recommends the use of Doxygen headers for describing all types of SAS program. This check identifies files where a Doxygen header does not begin on the first line, where it opens with a single asterisk, where it closes with one, where the close shares its line with the header's content, and where the block is never closed. A header is located by its close: the line that holds nothing but **/, which is the form sasjs compile and this rule set read. A block opened with /** whose close sits at the end of a content line, or which has no close at all, is therefore not yet a header.

The closing half matters beyond style. This rule set and sasjs compile both locate the header's end by its **/ terminator, so a header closed with a plain */ has no end they can find - a comment inside such a header can truncate it without being reported by noNestedCommentsInHeader. The fix widens the close marker to **/, and moves a close that shares its line onto a line of its own, which puts the header back where that check can see it. The repairs are applied in sequence inside the rule's single fix call, so a header carrying more than one defect - /* c */ needs its opener widened, its close widened and then moved - reaches the Doxygen form in one sasjs lint fix run. An unclosed block is reported but not rewritten: closing it at the end of the file would swallow whatever the author meant to keep outside it. A header whose body already holds a comment opener is left alone: the first close marker may belong to that comment, and that decision belongs to the author.

  • Default: true
  • Severity: WARNING

hasMacroNameInMend

The addition of the macro name in the %mend statement is optional, but can approve readability in large programs. A discussion on this topic can be found here. The default setting was the result of a poll with over 300 votes.

  • Default: true
  • Severity: WARNING

hasMacroParentheses

As per the example here, macros defined without parentheses cause problems if that macro is ever extended (it's not possible to reliably extend that macro without potentially breaking some code that has used the macro). It's better to always define parentheses, even if they are not used. This check reports a definition whose parameter list is absent altogether, and a definition that carries no name.

Running sasjs lint fix (or saving in the SASjs VS Code extension with formatOnSave) adds the empty parameter list to a definition that takes no parameters. The case of the keyword and the name, the spacing before the ; and any comment on that line are left exactly as they were. A declaration that carries parameter text without parentheses is left alone - an inserted () would land in front of the parameters - so that warning stays for the author to resolve.

  • Default: true
  • Severity: WARNING

hasRequiredMacroOptions

This will require macros to have the options listed as "requiredMacroOptions." This is helpful if you want to ensure all macros are SECURE.

  • Default: false
  • severity: WARNING

Example

{
  "hasRequiredMacroOptions": true,
  "requiredMacroOptions": ["SECURE", "SRC"]
}

ignoreList

There may be specific files (or folders) that are not good candidates for linting. Simply list them in this array and they will be ignored. In addition, any files in the project .gitignore file will also be ignored.

ignoredLibnames

The librefs that noUnusedLibnames leaves alone. List the librefs that a file assigns but does not use itself - because the connection is used by an autoexec, by %included code, or by another file in the same flow:

{
  "ignoredLibnames": ["outData", "TESTWORK"]
}

A single file can add to this list with the ignoredLibnames key of its @sasjslint header block, which is the per-file escape hatch for the rule.

  • Default: []
  • Used by: noUnusedLibnames

ignoredMacros

The macros that noUndeclaredMacros treats as declared. %webout is the common entry: sasjs compile injects it into every service and no macro file provides it - sasjs/core carries mv_webout / ms_webout / mm_webout, never webout - so there is nothing to declare, and sasjs lint fix would write an @li webout.sas the compiler cannot find. That is why webout is the default entry:

{
  "ignoredMacros": ["webout"]
}

The names are matched case-insensitively. A macro listed here is not reported, and sasjs lint fix does not add it to a header. An explicit list replaces the default, so "ignoredMacros": [] reports webout like any other undeclared name.

A single file can add to this list with the ignoredMacros key of its @sasjslint header block.

  • Default: ["webout"]
  • Used by: noUndeclaredMacros

indentationMultiple

This will check each line to ensure that the count of leading spaces can be divided cleanly by this multiple.

  • Default: 2
  • Severity: WARNING

lineEndings

This setting ensures the line endings in a file conform to the configured type. Possible values are lf, crlf and off (off means rule is set to be off). If the value is missing, null or undefined then the check would also be switched off (no default applied).

The last line is a line like any other: when this setting is lf or crlf, a file whose final line has no terminator is reported, and the formatter appends the missing terminator (\r\n when the setting is crlf, \n when it is lf). A project that wants the file end checked therefore sets this setting to lf or crlf; leaving it off means no line ending checks at all.

  • Default: "off"
  • Severity: WARNING

Example (to enforce unix line endings):

{
  "lineEndings": "lf"
}

lowerCaseFileNames

On *nix systems, it is imperative that autocall macros are in lowercase. When sharing code between windows and *nix systems, the difference in case sensitivity can also be a cause of lost developer time. For this reason, we recommend that sas filenames are always lowercase.

  • Default: true
  • Severity: WARNING

lowerCaseMacroNames

The companion to lowerCaseFileNames: it checks the macro names a project writes down, in the places they appear.

  • the @li entries of the <h4> SAS Macros </h4> header - the list sasjs compile reads to find each dependency
  • the %macro statement, and the name carried by %mend

lowerCaseFileNames only sees the file, and a file named mf_GetUser.sas resolves on Windows whatever the header says - then fails on any case-sensitive filesystem. With both rules on, the file and its declarations are reported together, and the fix lower cases the declarations.

sasjs lint fix rewrites the name in place wherever it appears: the header entry, the %macro name and the %mend name. Indentation, the order of the entries, and the rest of each line are left as they are.

Note the pairing: rewriting a header entry changes which file sasjs compile looks for. If the file itself is still mf_GetUser.sas, compilation reports a missing dependency until the file is renamed to match - which is what lowerCaseFileNames is reporting on that file.

  • Default: true
  • Severity: WARNING

maxDataLineLength

Datalines can be very wide, so to avoid the need to increase maxLineLength for the entire project, it is possible to raise the line length limit for the data records only. On a related note, as a developer, you should also be aware that code submitted in batch may have a default line length limit which is lower than you expect. See this usage note (and thanks to sasutils for reminding us).

This feature will work for the following statements:

  • cards
  • cards4
  • datalines
  • datalines4
  • parmcards
  • parmcards4

The maxDataLineLength setting is always the higher of maxDataLineLength and maxLineLength (if you set a lower number, it is ignored).

  • Default: 80
  • Severity: WARNING

See also:

maxHeaderLineLength

In a program header it can be necessary to insert items such as URLs or markdown tables, that cannot be split over multiple lines. To avoid the need to increase maxLineLength for the entire project, it is possible to raise the line length limit for the header section only.

The maxHeaderLineLength setting is always the higher of maxHeaderLineLength and maxLineLength (if you set a lower number, it is ignored).

  • Default: 80
  • Severity: WARNING

See also:

maxLineLength

Code becomes far more readable when line lengths are short. The most compelling reason for short line lengths is to avoid the need to scroll when performing a side-by-side 'compare' between two files (eg as part of a GIT feature branch review). A longer discussion on optimal code line length can be found here

In batch mode, long SAS code lines may also be truncated, causing hard-to-detect errors.

We strongly recommend a line length limit, and set the bar at 80. To turn this feature off, set the value to 0.

  • Default: 80
  • Severity: WARNING

See also:

minMacroNameLength

The shortest macro name the linter will treat as a macro name, and the shortest a file may define.

It exists because other languages write a percentage sign that reads as a macro call. The default of three is set by what those look like: a conversion is one character (%s(, %d(, %x(), a length modifier makes it two (%ld(, %lu(, %Lf(), and a macro name that anyone documents is longer than either - so three clears the specifiers a format string actually contains and still admits every real name. noUndeclaredMacros applies the floor when it reads invocations, so a short "call" is not reported, and this rule reports a definition that short - so a macro the scanner could not see is caught where it is written rather than silently ignored. noUnusedMacros deliberately does not apply it: over-counting a use keeps a declaration, while under-counting one has the formatter delete it, so every parsed name counts as a use.

The default covers %s( and the length-modifier forms such as %ld(. A longer conversion - %lld( - still reads as a name, and a file that formats numbers that way raises the floor for itself (@sasjslint {"minMacroNameLength": 4}), lists those names in ignoredMacros, or switches the rule off for itself. The linter does not try to parse a format string: the residue is a handful of names in a handful of files, and a specifier table would have to track every dialect that writes one.

A definition that lives inside a multi-line double-quoted string - a template the file writes out rather than code it runs - is read as a definition, because the macro parser reads lines rather than tracking quotes. A single-quoted template cannot span lines in SAS and is not read that way. That file raises the floor for itself or switches the rule off.

Short macro names are legal SAS, but they are not documentable: %macro s(); reads as a format specifier in every context a reader meets it. To turn the floor off, set the value to 1, which also returns noUndeclaredMacros to reading single-character names.

  • Default: 3
  • Severity: WARNING

noBannedStrings

Reports every line that contains one of the strings in bannedStrings, anywhere in the line - in a comment, in a string literal, or in code, because the text is read the way a person or a log scanner reads it rather than as SAS tokens.

The point is the log. A comment such as /* returns the error message as text */ contains error , and so does a real ERROR: line, so a grep of the SAS log - and any log scanner built on one, including the checks people run before they look at a job - returns the comment along with the failure. The same applies to a %put that copies a sentence out of the documentation. Keeping those words out of the source means a match in the log is a message that came from somewhere real.

Three boundaries are deliberate. The line that carries a @sasjslint override is configuration rather than prose, so a file that adds a banned string does not report its own setting. Source data is left alone: a datalines payload may hold the text error in a value, and that is data rather than something a developer can reword. And matching is case insensitive, so Error, ERROR and error are the same banned string; the diagnostic quotes the text as written. There is no setting to make it case sensitive: the words being caught are log lookalikes, and the log does not preserve the case an author typed either.

The entry is matched as text, so it is not anchored to a word boundary. saserror = 1 therefore satisfies "error ", because the identifier ends with the word and the space before the = is the trailing space the entry asks for. Listing "error " is what keeps errors and error_flag out, and every default entry is written the same way for that reason.

The rule is a warning by default. A single file can switch it off or downgrade it with a @sasjslint block in its header, which is the usual way to keep a legacy program out of scope:

/**
  @file
  @brief Legacy job, comments left as written
  @sasjslint {"noBannedStrings": false}
**/
  • Default: true
  • Severity: WARNING

noEncodedPasswords

This rule will highlight any rows that contain a {sas00X} type password, or {sasenc}. These passwords (especially 001 and 002) are NOT secure, and should NEVER be pushed to source control or saved to the filesystem without special permissions applied.

  • Default: true
  • Severity: ERROR

noGremlins

Capture zero-width whitespace and other non-standard characters. The logic is borrowed from the VSCode Gremlins Extension - if you are looking for more advanced gremlin zapping capabilities, we highly recommend to use their extension instead.

The list of characters can be found in this file: https://github.com/sasjs/lint/blob/main/src/utils/gremlinCharacters.ts

  • Default: true
  • Severity: WARNING

noMacroIfInOpenCode

%if-%then-%else in open code is a SAS 9.4M5 feature. On an earlier maintenance SAS rejects the statement outright - "ERROR: The %IF statement is not valid in open code" - so a program that uses one cannot run below that floor, while the same logic wrapped in a macro can. At M5 the %then / %else clause has to be a %do / %end block, so the construct worth removing is the %if ... %then %do; ... %end; block.

This rule reports a warning for each %if that runs in open code - that is, outside every %macro definition in the file. A %if inside a definition is silent. The scan skips the regions the other macro rules skip: block comments, macro comments (%* ... ;), comment statements, datalines / cards sections, proc lua / proc groovy submit blocks and the argument of %str / %nrstr. A %if inside a single-quoted string is not reported, because SAS resolves no macro trigger there; one inside a double-quoted string is reported, because SAS does resolve triggers there and the statement the string generates still runs on the target session.

The rule is off by default: it encodes a release floor, so it applies to the projects whose floor is earlier than 9.4M5. Enable it per project with "noMacroIfInOpenCode": true, or for one file in its @sasjslint header block:

/**
  @file
  @brief Loads the settlement feed

  @sasjslint {"noMacroIfInOpenCode": true}
**/

There is no fix. Moving a block into a macro means writing a %macro / %mend pair and an invocation around code the formatter cannot reorder, and an invented macro name is worse than the warning that points at the block.

Two boundaries are deliberate. A definition with no %mend reads to the end of the file, because SAS reads it as the macro body, so a %if after such a declaration is not reported. And a %if written on the same line as the %mend that closes a definition is read as part of that definition.

  • Default: false
  • Severity: WARNING

noMalformedMacroCalls

A macro call is read by the macro tokenizer before the engine runs it, and the tokenizer can read more than the author wrote. An unterminated quote or comment, or a parenthesis that never closes, makes the rest of the file part of the value: the engine reports ERROR: Macro parameter contains syntax error at the end of the file, or reports a different error somewhere further down, so the mistake and its symptom sit pages apart. A comma inside an unquoted value splits the parameter list instead, and the engine answers ERROR: All positional parameters must precede keyword parameters.

This rule reports two shapes, both confirmed on SAS 9.4:

  • the call never closes - unclosed quote, unclosed parenthesis, unterminated comment;
  • a parameter after the first name= one that carries no name= - text spilled at a comma inside a value, reported on the spilled text itself. When nothing was spilled (%foo(a=1, b=2,), or a parameter holding only a comment) the message is an empty parameter follows a comma, because that is what the engine makes of it: a positional parameter, and an error. A quoted name (%foo(a=1, "b"=2)) gets its own message, because the engine names that fault exactly: ERROR: Invalid macro parameter name "b". A masked name (%foo(a=1, %str(b)=2)) is refused too - the engine reads the masked text as a positional parameter, not a keyword one - so it has its own message as well.

Each reason names its own repair: spill the value inside %str() when text was spilled, remove the comma when nothing was spilled, drop the quotes from a quoted name, and drop the mask from a masked one. Masking a parameter name is the one place where %str() is the fault rather than the cure.

%put %kw(a=Check the library's lines, b=2);   /* unclosed quote */
%put %kw(a=1, wider than 80);                 /* "wider than 80" is not a name=value parameter */

The scan follows the macro tokenizer: it tracks single and double quotes, parenthesis depth and block comments, and steps over the whole argument of a masking function (%str, %nrstr, %quote, %superq, %qsysfunc and the rest of the %q family), because a comma or a quote inside one is data. A parameter therefore splits only at a comma that sits at depth zero, outside a quote and outside a mask.

The cases this rule stays silent on are worth naming, because each one is accepted by the engine and delivers its values intact: a comma inside a quoted or masked value, a positional call (%foo(one, two)), a space-separated list passed as one positional (%foo(one two three)), a comment between parameters, a value continued onto the next line, a keyword whose name arrives through a macro trigger (%foo(a=1, &k=2) with %let k=b;, which the macro processor resolves before it parses the parameter list), and a nested call used as a value.

The trigger is the one place where the rule knowingly under-reports. When the macro variable is not set, the engine leaves the text as written and refuses the call - %foo(&k=9) gives WARNING: Apparent symbolic reference K not resolved and ERROR: More positional parameters found than defined - but whether a trigger resolves depends on what ran before, and a file cannot be read for that. The rule therefore stays silent on a trigger name, and the engine's own warning is the right place to catch it.

A call that closes early - %foo(a=1), b=2) - is not reported. The closing parenthesis belongs to the call and the leftover text is an ordinary statement error, and a nested call used as a value has exactly the same shape, so there is no way to tell them apart from the text alone.

The rule is off by default. It models the tokenizer rather than the grammar, so the sensible way to adopt it is to run it over a project once, read the findings, and then turn it on.

  • Default: false
  • Severity: WARNING

noNestedCommentsInHeader

A doxygen header is a SAS block comment, and SAS block comments do not nest: the first close marker ends the comment. A /* ... */ inside the header therefore closes the header early, and the rest of it - @param lines, and any code in a usage example - is compiled as SAS. Header text that holds /*/, such as a glob like src/*/file.sas, ends the header the same way.

This rule reports such a header as a warning. sasjs compile fails on the truncation (sasjs/utils#288), so the rule surfaces it in the editor rather than in the build; set severityLevel to error to make sasjs lint fail on it as well.

Two boundaries are deliberate. Only the header a file opens with is inspected, which is the one sasjs compile reads - a file that opens with code has no header to truncate, and hasDoxygenHeader covers that shape. And the header's end is identified by its **/ terminator standing alone on its line, so a header closed with a plain */ is not inspected: nothing there says the header was meant to continue. hasDoxygenHeader reports that closing style and its fix writes the **/ form, which is what puts the header back in view here. The same bound has one cost worth knowing: a truncated header whose own text holds a line-leading /** is not reported, because that opener is indistinguishable from a second header in the same file.

  • Default: true
  • Severity: WARNING

noNestedMacros

Where macros are defined inside other macros, they are recompiled every time the outer macro is invoked. Hence, it is widely considered inefficient, and bad practice, to nest macro definitions.

  • Default: true
  • Severity: WARNING

noSingleAsteriskComments

SAS supports a comment statement that begins with a single asterisk and runs to the next semicolon, for example * some text;. This style is easy to mistype - a missing terminating semicolon turns the rest of the program into a comment - and it cannot be nested, so the SASjs framework recommends block comments (/* ... */) instead.

This rule reports a warning for each comment statement that begins with a single asterisk. It ignores asterisks inside quoted strings, %str() / %nrstr() arguments, macro comments (%* ... ;), data sections (datalines / cards), proc lua / proc groovy submit blocks and arithmetic expressions (a * b, b ** a, select * from), so it only fires on genuine comment statements.

  • Default: false
  • Severity: WARNING

Example:

{
  "noSingleAsteriskComments": true
}

noSpacesInFileNames

The 'beef' we have with spaces in filenames is twofold:

  • Loss of the in-built ability to 'click' a filepath and have the file open automatically
  • The need to quote such filepaths in order to use them in CLI commands

In addition, when such files are used in URLs, they are often padded with a messy "%20" type quotation. And of course, for macros (where the macro should match the filename) then spaces are simply not valid.

  • Default: true
  • Severity: WARNING

As an alternative (or in addition) to using a lint rule, you can also set the following in your .gitignore file to prevent files with spaces from being committed:

# prevent files/folders with spaces
**\ **

noTabs

Whilst there are some arguments for using tabs (such as the ability to set your own indentation width, and to reduce character count) there are many, many, many developers who think otherwise. We're in that camp. Sorry (not sorry).

  • Alias: noTabIndentation
  • Default: true
  • Severity: WARNING

noTrailingSpaces

This will highlight lines with trailing spaces. Trailing spaces serve no useful purpose in a SAS program.

  • Default: true
  • severity: WARNING

noUndeclaredMacros

SASjs programs declare the macros they use in the <h4> SAS Macros </h4> section of the header, so that the compiler can assemble the job before it runs. This rule reports a warning for each macro that a file invokes without declaring it - either by defining it in the file, or by listing it in the <h4> SAS Macros </h4> section. That is the only header list sasjs compile reads, so it is the only one that makes a macro available to the job; entries anywhere else in the header do not count.

The rule only looks at a file that has a Doxygen header: a block comment that opens with /** and closes with a line holding nothing but **/. A file with no header has no <h4> SAS Macros </h4> section to declare a macro in, and the formatter cannot create one, so its calls are not reported - hasDoxygenHeader reports the missing header, and that is the change the file needs first. A leading comment that is not a header is skipped rather than reported against a fix that cannot write: that covers a comment which fits on one line, one opened with a single asterisk (/* some comment */), and a /** block whose close shares its line or never arrives. hasDoxygenHeader reports each of those shapes, and its fixes put the header into the form this rule can write into earlier in the same sasjs lint fix pass; an unclosed block has no safe automatic repair, so it is left for the author and the declarations are checked once it closes.

The macros that ship with SAS (%scan, %index, %sysfunc and the other macro functions, along with the macro language keywords such as %if, %then and %do) are always considered declared, so they are never reported. That list is generated from the macroStatements and macroFunctions groups of @sasjs/sas-language (npm run generate:macros), so it tracks the SAS language data rather than being maintained here. Macros made available through the SASAUTOS system option are not visible to the linter and will be reported - list them in ignoredMacros, which carries webout by default.

An invocation written inside the argument of %str / %nrstr is not reported. A file masks a trigger to print it as often as to defer it, and nothing runs at that point, so a warning there would land on text that may never be code. noUnusedMacros counts such a name as a use, so the deferred form - %mp_abort(iftrue=%str(%mf_existds(work.x)=1)) - satisfies the declaration it depends on without demanding one.

A name written inside a single-quoted string is reported too, for the same reason as the masking case in reverse: the string is usually a program the file submits, so the macro has to be available when it runs. Text in a string is not always code, though, so two tests keep the noise down. A name shorter than minMacroNameLength is never reported (%s(, %d(, %x(), and a name inside a string counts only when an opening parenthesis follows it - a generated program calls a macro that way, while a SQL LIKE pattern ('%abc%') or a conversion specifier has nothing after the name. The parenthesis test sits with hasMacroParentheses, which requires every definition in the codebase to declare one. A template carrying the macros that its generated program declares for itself goes in ignoredMacros or has the rule switched off for that file with a @sasjslint block.

The warning is resolved by adding the macro name to the header. Running sasjs lint fix (or saving in the SASjs VS Code extension with formatOnSave) adds the missing macros automatically, as a unique list sorted alphabetically.

  • Default: true
  • Severity: WARNING

noUnusedLibnames

Assigning a libref opens a connection, and on a remote engine that costs time, so a LIBNAME statement that a file never uses is worth knowing about.

This rule reports a warning for each libref a file assigns and never mentions again. A mention is any occurrence of the libref outside the LIBNAME statement, which covers the indirect forms SAS accepts as well as two-level names:

libname outData "&dirOut.";
libname TESTWORK "&work";
libname path2 "&sasjswork/path2";

libText = pathname("outData", "L");        /* the libref as a string */
%let dir = %sysfunc(pathname(TESTWORK));   /* the libref unquoted */
proc format library=path2;                 /* the libref as an option value */
options insert=(fmtsearch=(path1 path2));

The test is deliberately generous, because a use that is missed becomes a false positive. A LIBNAME statement that deassigns (libname foo;), clears (libname foo clear;) or reports (libname foo list;) assigns nothing, so it is never reported - and it counts as a use, since it refers to a libref assigned elsewhere. The reporting form libname _all_ list; names no libref. A libref built at runtime (libname &lib;) is not a literal, so there is nothing to check.

The rule is off by default. A .sas file is not a complete job, so a libref it assigns can be used by:

  • an autoexec.sas that pre-assigns librefs
  • macros, functions or %included programs that the linter never sees
  • another file in the same flow - in the SASjs framework, a secondary artefact

Enable it per project with "noUnusedLibnames": true, and list the librefs it should leave alone in ignoredLibnames. An individual file can exempt its own librefs, or enable the rule for itself, in its @sasjslint header block:

/**
  @file
  @brief Loads the settlement feed

  <h4> SAS Macros </h4>
  @li mf_trim.sas

  @sasjslint {"noUnusedLibnames": true, "ignoredLibnames": ["outData"]}
**/

There is no fix. sasjs lint fix leaves the file alone, because the formatter cannot see the rest of the job - removing the statement could break it, and silencing every warning into the ignore list would make the rule pointless.

  • Default: false
  • Severity: WARNING

noUnusedMacros

The counterpart of noUndeclaredMacros. This rule reports a warning for each macro listed under <h4> SAS Macros </h4> that the file itself never invokes, which usually means the header is out of date.

A macro can be referenced indirectly - for example by another macro in the dependency chain, or through a dynamic %&macro call - without appearing as a direct invocation in the file, so the warning is advisory.

An invocation written inside the argument of %str / %nrstr counts as a use. That text is data while the masking function runs, but its caller normally unquotes it - %mp_abort(iftrue=%str(%mf_existds(work.x)=1)) resolves %mf_existds when %mp_abort evaluates the condition - so the declaration is a dependency of the file, and treating it as unused would have the formatter delete it.

An invocation written inside a single-quoted string counts as a use as well. SAS resolves no macro trigger there, so the text is data while the string is built - but code that generates code submits it later: call execute('%mf_mkdir('!!path!!')') runs the macro when the DATA step executes, and a program written to a fileref and %inced runs it then. Counting it as unused would report a declaration the file needs, and the formatter would delete it.

Running sasjs lint fix (or saving in the SASjs VS Code extension with formatOnSave) removes the unused entries from the header. Together with noUndeclaredMacros, this leaves the <h4> SAS Macros </h4> section listing exactly the macros the file uses. The pass that reads the list also writes each entry back as @li <macro>.sas at the section's indentation, in place and without re-spacing the header, so a mistyped tag (@il) is repaired even when no entry is removed.

  • Default: true
  • Severity: WARNING

noXcmd

This rule reports the SAS statements and functions that run an operating system command. They need the XCMD option, so on a server where it is off - a LOCKDOWN server, or a SASjs Server deployment that keeps it off - they fail at run time, and the failure arrives long after the code was reviewed. It is on by default for the same reason noEncodedPasswords is: the hazard is invisible in review and only shows up on the server that matters.

It reports:

  • the x statement, quoted (x "cmd";) or bare (x dir;), where a statement begins and a command follows - so keep x, a variable named x, and the assignment and sum statements (x = 1;, x + 1;) are not commands;
  • call system(...), with or without a space before the parenthesis;
  • %sysexec, anchored on the macro name, so a user macro such as %mf_sysexec(...) is not a match. The macro is the only form SAS has: the XCMD option's own list of what it governs names the %SYSEXEC macro, CALL SYSTEM, the SYSTEM function, PIPE and the X statement, and there is no SYSEXEC function or CALL SYSEXEC routine;
  • system(...) as a function, and %sysfunc(system(...)) through which it is usually reached - %sysfunc(sysrc()) is not a command and is not reported;
  • options xcmd; in its positive form ONLY - options noxcmd; is the hardening, and reporting it would push a project away from the setting that makes it safe;
  • filename <ref> pipe "cmd"; and infile "cmd" pipe;, where pipe stands in the option position of a filename statement or follows the quoted path of an infile - a fileref named pipe, or a variable of that name, is not reported;
  • systask command.

The text is read as code rather than as text, which is what separates this rule from noBannedStrings: comments are not searched, so a header or a note that documents options xcmd; is not reported, and a quoted value is not searched either, so put "x ""ls"";" stays clean. A datalines payload is data, so it is skipped whether or not the statement is indented. Macro definitions and open code are read the same way, because the risk is the same in both - and for the same reason a command is reported inside a macro body as it is anywhere else.

One limit is worth stating: an UNTERMINATED quote masks the rest of the file, because SAS itself reads an unterminated literal that way - the engine consumes the remaining text as string content. A stray apostrophe in open code therefore hides the constructs after it, rather than reporting the words inside a string as commands. A datalines payload cannot cause it, because the payload is blanked before the comment and quote scan runs, so a data value such as O'Brien is harmless.

There is no fix. Which command a project should run instead is a change to the job, not a formatting decision.

  • Default: true
  • Severity: WARNING

strictMacroDefinition

This will check the syntax of a macro definition, and warn about two things:

  • a parameter name that contains a space, as in %macro myMacro(my var);
  • an option that SAS does not recognise, as in %macro myMacro()/nonsense;

The options SAS accepts are CMD, DES, MINDELIMITER, MINOPERATOR, NOMINOPERATOR, PARMBUFF, SECURE, NOSECURE, STMT, SOURCE, SRC and STORE.

  • Default: true
  • Severity: WARNING

severityLevel

This setting allows the default severity to be adjusted. This is helpful when running the lint in a pipeline or git hook. Simply list the rules you would like to adjust along with the desired setting ("warn" or "error"), eg as follows:

{
  "noTrailingSpaces": true,
  "hasDoxygenHeader": true,
  "maxLineLength": 100,
  "severityLevel": {
    "hasDoxygenHeader": "warn",
    "maxLineLength": "error",
    "noTrailingSpaces": "error"
  }
}
  • "warn" - show warning in the log (doesn’t affect exit code)
  • "error" - show error in the log (exit code is 1 when triggered)

severityLevel default

To fail on every warning without listing each rule, set the default entry. It applies to every rule that has no entry of its own, including the rules added in later versions - so a new rule needs no .sasjslint change to be treated the same way as the rest.

{
  "severityLevel": {
    "default": "error"
  }
}

A rule named in the same object still wins over default, so an exception can be left as a warning:

{
  "severityLevel": {
    "default": "error",
    "maxLineLength": "warn"
  }
}

The precedence is the rule's own entry, then default, then the severity built into the rule (noEncodedPasswords is an ERROR, the others are WARNING). default accepts "warn" and "error"; any other value is an error, rather than being ignored.

Note this is the severity a violation is reported at, which is what the exit code follows. The sasjs lint --fail-on warning switch does the same thing for a single run without changing the configuration.

SAS Formatter

A formatter will automatically apply rules when you hit SAVE, which can save a LOT of time.

We've already implemented the following rules:

  • Add the macro name to the %mend statement
  • Add a doxygen header template if none exists
  • Add undeclared macros to the <h4> SAS Macros </h4> section
  • Remove unused macros from the <h4> SAS Macros </h4> section
  • Remove trailing spaces
  • Fix line endings, including adding a missing final newline when lineEndings is configured

The formatter only fixes rules that are in your configuration, so a rule that is off neither warns nor is repaired.

Contributing

Development instructions, the checklist for adding a rule, and the formatter fixes still to be built live in CONTRIBUTING.md.

SASjs is an open source framework! Contributions are welcomed. If you would like to see a feature, because it would be useful in your project, but you don't have the requisite (Typescript) experience - then how about you engage us on a short project and we build it for you?

Contact Allan Bowe for further details.

Further resources

Contributors ✨

All Contributors

Thanks goes to these wonderful people (emoji key):

Carus Kyle
Carus Kyle

🤔
Allan Bowe
Allan Bowe

💻 ⚠️ 👀 📹 📖
Yury Shkoda
Yury Shkoda

💻 ⚠️ 📆 📹 📖
Krishna Acondy
Krishna Acondy

💻 ⚠️ 👀 🚇 📦 🚧 🖋
Muhammad Saad
Muhammad Saad

💻 ⚠️ 👀 🧑‍🏫 📖
Sabir Hassan
Sabir Hassan

💻 ⚠️ 👀 🤔
Mihajlo Medjedovic
Mihajlo Medjedovic

💻 ⚠️ 👀 🚇
Vladislav Parhomchik
Vladislav Parhomchik

⚠️ 👀
McGwire-Jones
McGwire-Jones

💻

This project follows the all-contributors specification. Contributions of any kind welcome!

About

Linting and formatting for SAS® code

Topics

Resources

Contributing

Security policy

Stars

32 stars

Watchers

2 watching

Forks

Releases

Packages

Used by

Contributors

Languages