Skip to main content

Adding a new linter

ELP provides a flexible framework for adding custom linters (diagnostics) to detect issues in Erlang code. This guide walks you through the process of creating a new linter.

Overview

Linters in ELP are implemented as diagnostics that analyze Erlang code and report issues. Today ELP counts over 50 linters, and the number is growing. It is usually a good idea to look at existing linters to ensure a similar functionality is not already provided and to get inspiration for a new linter.

At high level, each linter consists of:

  1. A DiagnosticCode - A unique identifier for the diagnostic
  2. A linter module - A file where the linter is implemented
  3. Tests - A number of test cases for the diagnostic
  4. Documentation - A user-facing explanation of the diagnostic

Example: Detecting unsafe function calls

Let's create a linter that warns about calls to a hypothetical unsafe:operation/1 API that should be avoided in production code.

Step 1: Add the Diagnostic Code

First, add a new variant to the DiagnosticCode enum in crates/ide_db/src/diagnostic_code.rs:

pub enum DiagnosticCode {
// ... existing codes ...
UnsafeFunctionCall, // This is the new code

// Wrapper for erlang service diagnostic codes
ErlangService(String),
// Wrapper for EqWAlizer diagnostic codes
Eqwalizer(String),
// Used for ad-hoc diagnostics via lints/codemods
AdHoc(String),
}

Ensure the code name is unique and descriptive. Then add a clause for each of the required methods:

impl DiagnosticCode {
// The `as_code` method returns a string representation of the code.
// This is used to identify the diagnostic in the UI and in the CLI.
// Each code emitted by ELP starts with a `W` prefix, followed by a 4-digit number.
// The number is assigned by the linter author and should be unique within the linter.
// Use the next available number in the sequence for your new linter.
pub fn as_code(&self) -> &'static str {
match self {
// ... existing cases ...
DiagnosticCode::UnsafeFunctionCall => "W0055", // Use next available number here

DiagnosticCode::ErlangService(c) => c.to_string(),
DiagnosticCode::Eqwalizer(c) => format!("eqwalizer: {c}"),
DiagnosticCode::AdHoc(c) => format!("ad-hoc: {c}"),
}
}

// The `as_label` method returns a human-readable label for the diagnostic.
// This is used to identify the diagnostic in the UI and in the CLI.
// The label should be unique and descriptive.
pub fn as_label(&self) -> &'static str {
match self {
// ... existing cases ...
DiagnosticCode::UnsafeFunctionCall => "unsafe_function_call", // Use snake_case here

DiagnosticCode::ErlangService(c) => c.to_string(),
DiagnosticCode::Eqwalizer(c) => c.to_string(),
DiagnosticCode::AdHoc(c) => format!("ad-hoc: {c}"),
}
}

// The `allows_fixme_comment` method determines if it should be possible to temporarily
// suppress the diagnostic by a `% elp:fixme` annotation.
pub fn allows_fixme_comment(&self) -> bool {
match self {
// ... existing cases ...
DiagnosticCode::UnsafeFunctionCall => false,

DiagnosticCode::ErlangService(_) => false,
DiagnosticCode::Eqwalizer(_) => false,
DiagnosticCode::AdHoc(_) => false,
}
}

}

Step 2: Create the linter module

Create a new file elp/crates/ide/src/diagnostics/unsafe_function_call.rs, with the following content. The code has comments to explain each part.

/*
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is dual-licensed under either the MIT license found in the
* LICENSE-MIT file in the root directory of this source tree or the Apache
* License, Version 2.0 found in the LICENSE-APACHE file in the root directory
* of this source tree. You may select, at your option, one of the
* above-listed licenses.
*/

use elp_ide_db::DiagnosticCode;

use crate::diagnostics::Linter;
use crate::diagnostics::SsrPatternsLinter;

// We define a new linter, named `UnsafeFunctionCallLinter`
pub(crate) struct UnsafeFunctionCallLinter;

// We implement the `Linter` trait. This is common to all kinds of ELP linters.
impl Linter for UnsafeFunctionCallLinter {
// A unique identifier for the linter. This is the `DiagnosticCode` we just added.
fn id(&self) -> DiagnosticCode {
DiagnosticCode::UnsafeFunctionCall
}
// A human-readable description for the linter, which will be displayed in the IDE
fn description(&self) -> String {
"Do not use unsafe:operation/1 in production code.".to_string()
}
// By default test files (i.e. test suites and helpers) are processed by linters.
// Since we only care about "unsafe calls" from production code,
// here we override the default behaviour by skipping test files.
fn should_process_test_files(&self) -> bool {
false
}
}

// We will write our linter using the so-called "SSR" syntax.
// SSR is a convenient way to match on parts of the Erlang AST.
// Because of this, we decide to implement the additional `SsrPatternsLinter` trait.
impl SsrPatternsLinter for UnsafeFunctionCallLinter {
// Linters can specify a custom context type. For now, we ignore it.
type Context = ();

// Here we specify which patterns we want to match.
// The function returns a vector of tuples, each one containing
// a SSR pattern and a context.
// For the time being, let's ignore the context.
// For SSR syntax, please refer to the `ide_ssr` crate.
// Here, we are matching on calls to the fully-qualified `unsafe:operation/1`
// function, ignoring its only argument.
fn patterns(&self) -> Vec<(String, Self::Context)> {
// We use the SSR mechanism to match calls to `unsafe:operation/1`.
vec![("ssr: unsafe:operation(_@).".to_string(), ())]
}
}

Step 3: Register the diagnostic

Add your diagnostic module to elp/crates/ide/src/diagnostics.rs:

// Add the module declaration
mod unsafe_function_call;

// Register the linter in the `ssr_linters` function
pub(crate) fn ssr_linters() -> Vec<&'static dyn FunctionCallLinter> {
vec![
// ... existing linters ...
&unsafe_function_call::LINTER, // We add the linter here
]
}

Step 4: Add tests

At the bottom of the unsafe_function_call.rs file, add the following:

#[cfg(test)]
mod tests {
use crate::tests::check_diagnostics;

#[test]
fn test_unsafe_function_call() {
check_diagnostics(
r#"
//- /src/main.erl
-module(main).
-export([warn/0]).
warn() ->
unsafe:operation(data),
%% ^^^^^^^^^^^^^^^^^^^^^^ 💡 warning: Do not use unsafe:operation/1 in production code.
safe:operation(data).
//- /src/unsafe.erl
-module(unsafe).
-export([operation/1]).
operation(_) -> ok.
//- /src/safe.erl
-module(safe).
-export([operation/1]).
operation(_) -> ok.
"#,
);
}
}

ELP uses convenient snapshot tests for checking diagnostics. The test above checks that the diagnostic is correctly reported when calling unsafe:operation/1. It also checks that the diagnostic is not reported when calling safe:operation/1.

Always include comprehensive tests in your diagnostic module:

  1. Positive cases - Code that should trigger the diagnostic
  2. Negative cases - Similar code that should NOT trigger the diagnostic
  3. Edge cases - Boundary conditions and unusual syntax

You can run the tests with the following command:

cargo test --package elp_ide --lib -- diagnostics::unsafe_function_call::tests::test_unsafe_function_call --exact --show-output

Step 5: Create documentation

Each new linter must be accompanied by a documentation file in Markdown format. Create the documentation file corresponding to your linter in: elp/website/docs/erlang-error-index/w/W0055.md (where W0055 is the code we introduced):

---
sidebar_position: 55
---

# W0055 - Unsage Function Call

This diagnostic warns about calls to `unsafe:operation/1`, which should be
avoided in production code due to potential security or stability risks.
`safe:operation/1` is a safer alternative that should be used instead.

## Example

```erlang
-module(example).
-export([do/1]).

do(Data) ->
unsafe:operation(Data).
%% ^^^^^^^^^^^^^^^^^^^^^^ warning: Do not use unsafe:operation/1 in production code.
```

## Recommended fix

Replace calls to `unsafe:operation/1` with a safer alternative, such as `safe:operation/1`.

```erlang
-module(example).
-export([do/1]).

do(Data) ->
safe:operation(Data).
```

Step 6: Try the new linter from the CLI

After building ELP, you can try the new linter against a hypothetical my_module Erlang module:

cargo run --bin elp -- lint --project /path/to/your/project --module my_module --diagnostic-filter unsafe_function_call
Diagnostics reported in 1 modules:
my_module: 1
29:4-29:25::[Warning] [W0055] Do not use unsafe:operation/1 in production code.