jsfx-lint is a static analyzer and linter for JSFX, REAPER's amazing language to create audio and MIDI plugins. This linter is written in Rust and uses the grmtools library to generate a parser for EEL2. WDL is used to handle preprocessing.
- jsfx-lint
- Features
- Motivation
- Installation
- Usage
- Development
- Lints
- arg_must_be_namespace
- arg_never_read
- discarded_param
- duplicate_argument
- duplicate_modifier
- empty_modifier
- fully_shadowed_argument
- global_never_read
- global_never_written
- implicit_parens
- implicitly_passing_zero
- import_not_found
- inaccessible_global
- incompatible_contexts
- inconsistent_casing
- invalid_lhs_assignment
- looks_like_slider_n
- loop_as_r_value
- object_access_unused
- overwriting_arg
- param_count
- parse_error
- partially_shadowed_argument
- read_unreadable
- ref_arg_in_incompatible_modifier
- sample_section_without_audio_io
- shadowed_slider_n
- slider_invalid_identifier
- slider_labels
- slider_out_of_range
- slider_over_max_id
- slider_parser
- slider_without_description
- sprintf_params
- string_arg_in_local_mod
- too_many_params
- unknown_function
- unknown_section
- unknown_slider
- unnecessary_comma
- unnecessary_semicolon
- unreachable_function
- unused_function
- unused_modifier_arg
- unused_slider
- useless_expression
- useless_object
- useless_ref_arg
- write_to_non_writable
- wrong_context
- wrong_section_param
- Known issues, limitations and TODOs
- License
- Supports EEL2 preprocessor blocks (
<? ?>
,_suppress
, etc.) - Parses meta information (sliders, options, etc.)
- Provides various style and correctness lints
- Reports syntax errors
JSFX, the language used in REAPER for creating audio plugins, is a fantastic learning tool and a great prototyping sandbox. However, its design can be quirky and prone to errors. This project aims to provide a linter that helps catch these errors, allowing you to spend less time scratching your head and more time experimenting.
jsfx-lint aspires to be a comprehensive linter for JSFX, addressing a wide range of issues from simple style problems to more complex and less obvious issues. The goal is to align closely with the official JSFX implementation.
At the moment, jsfx-lint is a CLI tool. However, work on an LSP server for integration with text editors is planned.
Pre-built binaries are available on the releases page.
Please note that the eel_pp
binary must be located in the same directory as the jsfx-lint
binary for the program to
function correctly.
Prerequisites: Rust needs to be installed.
git clone https://github.com/Souk21/jsfx-lint.git
cd jsfx-lint
cargo build --release
eel_pp
binaries can be
built from the WDL/EEL2 repo.
During the build process, the appropriate eel_pp
binary from the eel_pp/
directory is automatically copied to
the target/debug
or target/release
directory. This ensures that the eel_pp
binary is in the correct location
for jsfx-lint
to operate and for GitHub Actions to package the binaries correctly.
jsfx-lint [filepath | -]
filepath
is the path to the JSFX file to lint. If -
is provided, the program reads from stdin
.
Example usage:
# From file
jsfx-lint path_to/effect.jsfx
# From stdin
echo "@init\na = 1;" | ./jsfx-lint -
To customize the linter, add a config.toml
file in the same directory as the jsfx-lint
binary. This file lets you override the default severity for specific lints. For example:
arg_never_read = "silent"
# ...
Any lints not listed in the config file will use their default severity. Valid lint names can be found in the Lints section of this README.
In JSFX, a program is composed of multiple sections, each containing a chunk of EEL2 code. The sections must be of one
of these types: @init
, @block
, @sample
, @gfx
, @serialize
or @slider
.
A file can import other files using the import
directive. Quoting the JSFX docs:
Importing files via this directive will have any functions defined in their @init sections available to the local effect. Additionally, if the imported file implements other sections (such as @sample, etc), and the importing file does not implement those sections, the imported version of those sections will be used.
One thing this doesn't mention is that not only the function definitions in @init are imported, the code is also imported. It's as if the code of the imported section was pasted in the importing file, before its first @init section.
If a file contains multiple @section
of
the same type, they are merged into a single section, in order of their definition in the source file.
In jsfx-lint
, the Program
struct is the main data structure representing a JSFX program. It contains the parsed AST,
metadata, and symbols.
It is structured as follows:
- Metadata
- Metadata includes sliders and their parameters (min, max, default, etc.), options, imports, pins, etc
- Sections:
- Chunks
- Corresponds to a single @section block in one of the source files.
- Function definitions
- Arguments
- Scope
- Function calls
- Prefix
- Parameters
- Chunks
The linter operates in three passes:
- Parsing and Metadata Collection: The entry file source is first preprocessed by
eel_pp
if necessary. Each@section
EEL2 source is then parsed, and metadata (everything that's not EEL2:sliders
,import
, etc.) is collected. At this stage, imports are resolved recursively: every imported file also undergoes optional preprocessing and EEL2/meta parsing. This process continues until all imports are handled. The result of this pass is a low-level structure containing the AST nodes of each sections chunk (a section can be defined multiple times!) along with the collected metadata. - Symbol Collection: During this pass, the AST is traversed to collect symbols, which include functions, variables, scopes, and more. The outcome of this pass is a high-level representation of the program, complete with the symbols and their respective scopes.
- Linting: In the final pass, lints are run using both the low-level and high-level representations of the program.
The first step of creating a new lint is to add an entry to the data/config.default.toml
file. The key should be the
lint name (in snake_case), and the value should be the default severity of the lint. The severity can be one of the
following: error
, warning
, style
, or silent
.
# ...
"my_lint" = "warning"
# ...
Tip
- Use
error
only for lints that trigger an error with the official JSFX implementation. - Use
warning
for lints that are likely to be mistakes or lead to confusing code. - Use
style
for more opinionated or cosmetic lints.
The lint will then automatically be added to the IssueKind
enum as a PascalCase variant (e.g. IssueKind::MyLint
).
This process takes place in the build.rs
file.
A new module needs to be added to the src/lints/
directory. Usually, the module has the same name as the config entry. (e.g. my_lint.rs
for the my_lint
config entry).
The module should contain a public function of the type:
type Lint = fn(&Program, &mut Issues);
This function's role is to add the issues found in the program to Issues
, using the IssueKind
generated above.
The Program
struct contains the parsed AST and other information about the file.
pub fn lint(program: &Program, issues: &mut Issues) {
// ...
issues.add(IssueKind::MyLint, ...);
}
It is recommended to create tests for each lint. The tests should be in the same file as the lint, inside a mod tests
module.
#[cfg(test)]
mod tests {
// `indoc!` removes indentation from multiline string
use indoc::indoc;
use crate::file::File;
use crate::IssueKind;
#[test]
pub fn test_name() {
// Minimal JSFX source code that triggers `IssueKind::MyLint`
let source = indoc! {"
@init
function foo() (
0;
);"
};
let (_, issues) = File::lint_with_default_config(source);
assert!(issues.has(&IssueKind::MyLint));
}
}
The module then needs to be imported into lints/mod.rs
as follows:
// ...
mod my_lint;
// ...
pub fn get() -> Vec<Lint> {
vec![
// ...
my_lint::lint,
// ...
]
}
Lastly, you need to document the lint in the README.md
file, in the Lints section.
Default severity: error
Argument must be a namespace (ref
).
function foo(ref*) ( ref.bar = 1; );
foo(0); // 0 is not a namespace
Default severity: warning
Argument is never read.
function foo(bar) ( 0 );
Default severity: warning
A function with no arguments can also be called with one argument, which ends up being discarded.
function foo() (0);
foo(1); // 1 is discarded
Default severity: warning
Argument appears multiple times in function definition.
function foo(bar, bar) (0);
function foo() local(bar) local(bar) (0);
Default severity: style
A modifier is defined multiple times.
function foo() local(a) local(b) (0);
Default severity: silent
Empty modifier (other than global
) in a function definition.
function foo() local() (0);
Default severity: warning
Modifier argument is fully shadowed by a function argument.
Note: Function arguments that are named this
or that start with this.
are not reachable as they are shadowed by the this
keyword.
function foo(a) local(a) (0);
Default severity: warning
A variable is never read. Note that a variable defined as _
will be ignored by this lint.
Default severity: warning
A variable is never written. Note that a variable defined as _
will be ignored by this lint.
Default severity: style
Function definition with implicit parenthesis.
function foo local() (
0
);
Default severity: warning
Functions with one argument can be called with zero parameters, and the argument end up being 0.
function foo(bar) (0);
foo(); // Implicitly calls foo(0)
Default severity: error
The import was not found.
import non_existing.jsfx-inc
Default severity: error
Accessing a variable from a function with a global
modifier that is empty or that doesn't contain said variable.
function foo() global() local(a) (
a = beat_position;
);
function foo() global(a) (
a = beat_position;
);
Default severity: warning
Function uses variables or calls functions with incompatible contexts.
function foo() (
spl0 = 1; // only valid in @sample
gfx_rect(0, 0, 100, 100); // only valid in @gfx
);
Default severity: style
A variable or function called with different casing throughout the file.
function foo() (0);
FOO();
bar = 1;
baz = BAR;
Default severity: warning
The left-hand side of the assignment is not assignable.
(1 + 1) = 1;
floor(1) = 1;
Default severity: warning
A sliderN
variable with N
greater than 256 or N
equal to 0.
a = slider0;
a = slider257;
Default severity: warning
A loop used as the right-hand side of an assignment.
a = while(i < 10) (
i += 1;
i
);
// While always return 0, Loops always return 1.
Default severity: warning
A value accessed on a instance
/this
/ref
is not accessed anywhere else.
function foo() ( thi
5D32
s.bar = 1; );
object.foo(); // Sets object.bar to 1
// ... code not using object.bar
function foo(ref*) ( ref.bar = 1; );
foo(object); // Sets object.bar to 1
// ... code not using object.bar
function foo() instance(bar) ( bar = 1; );
object.foo(); // Sets object.bar to 1
// ... code not using object.bar
Default severity: warning
An argument is assigned before being read.
function foo(a) (
a = 1;
);
Default severity: error
A function is called with too many or too few parameters.
foo = abs(1, 2);
gfx_rect(1);
Default severity: error
The parser encountered an error or unexpected token. Example:
a (= 1;
Default severity: warning
An argument present both in the arg list and in a modifier is partially shadowed.
function foo(a) instance(a) (
_ = a; // Here the argument is used
_ = a.bar; // Here the instance is used
0
);
Default severity: warning
Default severity: error
Ref args (arg*
) are only allowed in a global
or globals
modifier.
function foo() local(bar*) (0);
Default severity: warning
A @sample section without audio input/output. The code should be moved to the @block section.
@sample
gain = slider1;
Default severity: warning
Sliders that are bound to a variable can't be accessed using sliderN variables.
slider1:foo=0<0,1,1>Foo
@init
slider1 = 2; // Will not set slider1 to 2
Default severity: warning
Slider identifier is not a valid EEL2 identifier. It must start with an alphanumeric character or '_', and can only contain letters, numbers, commas and underscores.
slider1:gain-db=0<0,1,1>Gain dB
Default severity: warning
Sliders with labels should have their minimum equal to 0, their step equal to 1, and their maximum equal to the number of labels.
slider1:0<0,3,1{One,Two}>Foo // Maximum should be 2
Default severity: warning
A slider is set to a value above its maximum or below its minimum.
slider1:0<0,1,1>Foo
@init
slider1 = -1; // Below minimum
slider1 = 2; // Above maximum
Default severity: error
A slider is defined with an id greater than 256.
slider257:1<0,1,1>foo
Default severity: warning
The slider parser encountered an error. This can happen if the slider definition is malformed.
slider1:0<0,text,1>Foo
slider1:0<0,1,1{One,Two>Foo
Default severity: warning
A slider without a description. It will not be displayed in the default UI and will not be automatable.
slider1:0<0,1,1>
Default severity: error
sprintf
is called with the incorrect number of arguments.
sprintf(#str, "%d %d", 1);
Default severity: error
#strings
are not allowed in the local()
modifier.
function foo() local(#str) (0);
Default severity: error
A function is defined with too many parameters. The maximum is 40.
function foo(p1, p2, ... p42, p43) (0);
Default severity: error
The called function doesn't exist.
Default severity: error
@section
other than @init
, @sample
, @serialize
, @block
, @gfx
, or @slider
.
Default severity: error
Accessing a slider that is not defined.
Default severity: style
Unnecessary comma(s) in function/modifier argument list. Note: trailing commas are allowed.
function foo(bar,, baz) (0);
Default severity: style
Unnecessary semicolon(s) in a function body.
@init
a;;
b;
Default severity: error
A function is unreachable because it is shadowed by the loop
or while
keywords.
function loop(foo, bar) (0);
function while() (0);
function while(foo) (0);
Default severity: warning
A function is never called.
Default severity: warning
A modifier argument is never used.
function foo() local(unused) ( 0 );
function foo() global(unused) ( 0 );
Default severity: warning
A slider is never read or written to.
Default severity: warning
An expression doesn't have any side effects.
@init
function foo() (
1 + 1; // This expression does nothing
0;
);
Default severity: warning
A function does not need to be called on an pseudo-object.
function foo() ( 0; );
bar.foo();
Default severity: warning
An argument does not need to be ref
.
function foo(a*) ( b = a; );
Default severity: warning
Writing to a non-writable variable.
beat_position = 10;
Default severity: warning
A variable or function is used in the wrong context.
@init
spl0 = 1; // spl0 can only be used in @block or @sample
Default severity: error
@section
(except @gfx
) with parameters. @gfx
with more than 2 parameters (or only 1).@gfx
with non-integer params.
@gfx 10 20 30
@init 10 20
@gfx 10 a+b
-
When a file contains multiple
@section
of the same type, JSFX combines them into a single section by concatenating their text. This means the following is allowed:@init a = @block b = 2; @init 1;
and is equivalent to:
@init a = 1; @block b = 2;
This behavior of joining section text is not yet implemented in
jsfx-lint
, which currently keeps multiple@section
blocks of the same type separate. Work is planned to address this in the future. -
JSFX looks in the
REAPER/Effects
directory when resolving imports. Right now,jsfx-lint
only knows about the defaultEffects
path (~/.config/REAPER/Effects
on Linux,%APPDATA%\REAPER\Effects
on Windows, and~/Library/Application Support/REAPER/Effects
on macOS). It is currently not possible to specify additional or alternative paths for imports. This is planned for a future release. -
Currently,
jsfx-lint
does not show style or warning lints that are located in the output of a preprocessor block (i.e.<? ?>
). This is to avoid flooding the output with lints that are not relevant to the user. This should be a flag/config option in the future. -
There are still many simple optimizations that can be done to the linter.
-
Lint ideas:
- Warn about unknown
option
, or incorrect parameters foroption
- Track shadowed state for functions to be able to report when a function is unused because it is shadowed
- Warn when a variable does not need to be an
instance
, and could be alocal
slider()
function lints- If an argument is overwritten before being read, it might be a
local()
- Warn about unknown
This project is licensed under the MIT License - see the LICENSE file for details. This project uses the EEL2 library, which is BSD-licensed. For more details, please refer to the BSD license text included in the THIRD_PARTY_LICENSES file.