Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Fatou

Fatou logo

Fatou is a language server, formatter, and linter for the Julia language, written in Rust. It follows the rust-analyzer design (a lossless rowan CST, salsa for incremental computation, and lsp-server for the language-server transport).

Quick Start

Install with Cargo:

cargo install fatou

Format your first file:

fatou format file.jl

For full installation options (npm, PyPI, prebuilt binaries, and source builds), see Getting Started.

Where to Go Next

Getting Started

Installation

Fatou runs on Linux, macOS, and Windows (x86_64 and arm64), and is available from several sources.

Cargo

Install from crates.io with Cargo:

cargo install fatou

npm

The fatou-cli package bundles a prebuilt binary:

npm install -g fatou-cli

PyPI

Install the binary as a Python tool:

uv tool install fatou
# or
pipx install fatou

AUR

On Arch Linux, install the prebuilt fatou-bin package with an AUR helper:

paru -S fatou-bin

Prebuilt binaries

Download an archive for your platform from the releases page and put the fatou binary on your PATH.

From source

Clone the repository and build a release binary:

git clone https://github.com/jolars/fatou
cd fatou
cargo build --release

The binary is written to target/release/fatou.

First Run

Format a file in place:

fatou format file.jl

Check formatting without writing changes (prints a diff, exits non-zero if any file would change):

fatou format --check file.jl

Lint a file; exits non-zero if there are any findings:

fatou lint file.jl

Run the language server over stdio (for editor integration):

fatou lsp

See the CLI Reference for the full set of commands and options, and Editor Setup to wire the language server into your editor.

Editor Setup

Fatou includes a language server (fatou lsp, stdio JSON-RPC) that any LSP client can drive. It provides formatting (whole document and range), lint and parse diagnostics with quick fixes, completion, hover, signature help, go-to definition, references, rename (of symbols, and of files and folders — moving a file rewrites the include paths that name it), document and workspace symbols, call and type hierarchy, folding ranges, document links, selection ranges, and semantic tokens. It also checks your Project.toml and Manifest.toml themselves, navigates an open Project.toml’s dependency names with inlay hints for their resolved versions, and links an open Manifest.toml’s path entries to the packages they pin.

Prerequisites

Except in the VS Code family, where the extension bundles a binary, install Fatou (see Getting Started) and make sure the fatou binary is on your PATH, or note its absolute path.

VS Code

Install the Fatou extension (jolars.fatou) from the Marketplace, or from the command line:

code --install-extension jolars.fatou

The extension activates on Julia files, starts fatou lsp for you, and registers itself as the default formatter for [julia]. Each platform-specific build bundles a matching fatou binary, so nothing else is needed; on a platform without one, it downloads a binary from GitHub releases.

To format on save, add to settings.json:

{
  "[julia]": {
    "editor.defaultFormatter": "jolars.fatou",
    "editor.formatOnSave": true
  }
}

To use a fatou you installed yourself instead of the bundled one:

{
  "fatou.executableStrategy": "environment"
}

or point at an exact binary:

{
  "fatou.executableStrategy": "path",
  "fatou.executablePath": "/usr/local/bin/fatou"
}

The extension’s README documents the available settings and their defaults.

Using Only Some Features

The formatter, linter, and language features share one server but can be turned off independently, so you can adopt just the parts you want:

  • fatou.formatting.enable — use Fatou as a formatter.
  • fatou.diagnostics.enable — show Fatou diagnostics (the linter).
  • fatou.languageFeatures.enable — hover, completion, navigation, symbols, rename, code actions, and the rest.

All three default to true. They are client-side gates, so the server keeps running and the toggles take effect without a restart. For a formatter-only setup, turn off the other two:

{
  "fatou.diagnostics.enable": false,
  "fatou.languageFeatures.enable": false
}

Turning off fatou.diagnostics.enable suppresses every diagnostic, including the parse errors that a fatou.toml [lint] selection cannot silence. The fatou.toml route stays the right tool when you want to keep parse errors but mute specific lint rules across every editor and the CLI.

VSCodium and Other Code OSS Editors

The same extension is published to the Open VSX Registry, which VSCodium and most Code OSS builds use by default:

codium --install-extension jolars.fatou

If your build ships a different registry, download the VSIX matching your OS and architecture from the Open VSX page and install it with Extensions: Install from VSIX…, or from the command line:

codium --install-extension fatou-linux-x64.vsix

Settings are identical to VS Code’s.

Cursor

Search for Fatou in the Extensions view and install it. If your Cursor build does not list it, download the VSIX for your platform from Open VSX and install it with Extensions: Install from VSIX… from the command palette.

Cursor reads the same settings.json keys as VS Code, so the format-on-save and binary-selection snippets above apply unchanged.

Neovim

Neovim 0.11+ (built-in vim.lsp.config)

Add to your config (e.g. init.lua or a file under lua/):

vim.lsp.config("fatou", {
  cmd = { "fatou", "lsp" },              -- or the absolute path to the binary
  filetypes = { "julia" },
  root_markers = { "Project.toml", "JuliaProject.toml", ".git" },
})
vim.lsp.enable("fatou")

Format on save:

vim.api.nvim_create_autocmd("BufWritePre", {
  pattern = "*.jl",
  callback = function() vim.lsp.buf.format({ name = "fatou" }) end,
})

Older Neovim (autocmd + vim.lsp.start)

vim.api.nvim_create_autocmd("FileType", {
  pattern = "julia",
  callback = function(args)
    vim.lsp.start({
      name = "fatou",
      cmd = { "fatou", "lsp" },
      root_dir = vim.fs.root(args.buf, { "Project.toml", "JuliaProject.toml", ".git" }),
    })
  end,
})

Helix

Add to ~/.config/helix/languages.toml:

[language-server.fatou]
command = "fatou"
args = ["lsp"]

[[language]]
name = "julia"
language-servers = ["fatou"]
auto-format = true

Listing language-servers replaces Helix’s default for Julia, which is LanguageServer.jl. To keep it for the features Fatou does not cover yet while Fatou handles formatting, list both and take formatting away from the other server:

[[language]]
name = "julia"
language-servers = [{ name = "julia", except-features = ["format"] }, "fatou"]
auto-format = true

hx --health julia shows which servers Helix resolved for the language.

Other LSP clients

Any client that speaks LSP over stdio works: launch fatou lsp with no arguments for *.jl files, rooted at Project.toml, JuliaProject.toml, or .git. Nothing else is required, because Fatou discovers its own configuration from the file’s directory upward.

Fatou also checks your Project.toml and Manifest.toml themselves, and publishes those findings on the file at fault whether or not it is open. If you additionally attach the server to those files (the VS Code extension does), an open one reports its TOML errors as you type, before you save.

An open Project.toml also answers on its dependency names: go-to-definition and a document link take you to the package’s entry file, hovering reports the version, kind, and resolved path the environment gave it, and an inlay hint puts each resolved version beside its UUID, so you can read off what you are actually on without opening the Manifest.toml. In an open Manifest.toml, each path entry — a package you have dev’d — is a link to that package’s Project.toml. Everything else stays off for these files — formatting a Project.toml would mean parsing TOML as Julia.

Configuration

Fatou reads its settings from a fatou.toml next to your project (see the Configuration guide), which is the recommended way to configure it in any editor, since the whole team gets the same behavior.

A client can also push settings over LSP, as initializationOptions or workspace/didChangeConfiguration, using the same schema as the file, either bare or wrapped in a "fatou" key the way VS Code namespaces settings. In Helix, for example:

[language-server.fatou.config.format]
line-width = 100

[language-server.fatou.config.lint]
ignore = ["unused-binding"]

A discovered fatou.toml shadows editor-pushed settings entirely rather than merging with them, so a project file always wins.

Unicode Symbol Input

Completion also offers the LaTeX and emoji sequences the Julia REPL substitutes on tab, so \alpha inserts α, \_1 inserts , and \:smile: inserts 😄. Type the backslash to open the list, keep typing to narrow it, and accepting an entry replaces the whole sequence, backslash included. As in the REPL, a bare \ lists the LaTeX sequences and \: opens the emoji. The table comes from the running Julia’s REPL.REPLCompletions, so it matches what your REPL does.

Two places stay quiet, so the list does not get in the way:

  • inside a string macro or command literal (r"\d", raw"\n", `ls \d`), where every backslash belongs to the literal itself;
  • on a lone escape in a plain string, so typing "\n does not offer \nabla. A second character brings the sequences back, so \nu and \alpha still work in strings and docstrings.

Check It Works

Open a .jl file containing x=1 and format the buffer: it becomes x = 1. In Neovim that is :lua vim.lsp.buf.format(), in Helix :format, and in the VS Code family Format Document. Diagnostics appear inline, and a lint finding with a fix offers it as a quick fix (:lua vim.lsp.buf.code_action(), <space>a in Helix, or the lightbulb in VS Code).

Notes

  • Document sync is incremental, and both whole-document and range formatting are supported.
  • Multiple formatters attached? In Neovim, pass { name = "fatou" } to vim.lsp.buf.format(); in Helix, strip format from the other server as shown above; in VS Code, set editor.defaultFormatter for [julia].
  • undefined-name and call-arity need project context, so the language server enables them for workspace member files even though the command line leaves them opt-in. An ignore entry still turns them off.

Configuration

Fatou is configured with a TOML file named fatou.toml. Every key is optional, so a config only needs to mention what you want to change from the defaults. Unknown keys are rejected with an error, which means a typo never silently falls back to a default.

A minimal project config looks like this:

exclude = ["vendored/"]

[format]
line-width = 100

[lint]
ignore = ["unused-argument"]

This guide covers the common tasks. For the exhaustive list of keys, their types, and their defaults, see the configuration reference.

Where Fatou looks for a config

For a given file, Fatou walks up from the file’s directory through its ancestors, and uses the first fatou.toml it finds. The usual layout is a single fatou.toml beside .git at the root of the project.

The walk stops at the repository root, so a fatou.toml parked above your repository never governs the project inside it. The root itself is searched, and a worktree or submodule checkout, whose .git is a file rather than a directory, bounds the walk the same way. A directory with no .git ancestor keeps walking to the filesystem root.

User-wide defaults

If you want the same settings across projects, do not put a fatou.toml above your repositories; use a global config instead. When no project fatou.toml is found, Fatou looks for one in your user config directory, typically ~/.config/fatou/fatou.toml.

To keep a config on a synced drive and point every machine at it, set the FATOU_CONFIG environment variable to its path. A set FATOU_CONFIG shadows the global config entirely, and a missing or malformed file there is a hard error rather than a silent fall-through, so a typo’d path cannot go unnoticed.

Both are whole-file fallbacks, never merged with a project config: as soon as a project fatou.toml is found, it is the only file that applies. Relative exclude patterns in a FATOU_CONFIG or global file resolve against the working directory (on the command line) or the document’s directory (in the language server) rather than the config file’s own directory.

The language server uses the same resolution, so either file is a convenient way to set editor-wide defaults. Only project files are watched, so an edit to a global or FATOU_CONFIG file is picked up when the server restarts.

Bypassing discovery

On the command line, --config <PATH> loads an explicit file and skips discovery altogether, and --no-config ignores every file (project, FATOU_CONFIG, and global) and runs with the built-in defaults.

Resolution order

In full, Fatou uses the first source that applies:

  1. --config <PATH>, which loads that file and skips discovery.
  2. --no-config, which ignores every file and uses the built-in defaults.
  3. The nearest fatou.toml, found by walking up from the file’s directory. The walk stops at the repository root (the directory holding .git, whether a directory or a file), inclusive; a directory with no .git ancestor is walked to the filesystem root.
  4. $FATOU_CONFIG, when set. A missing or malformed file here is an error.
  5. The global user config: the first existing file among
    1. $XDG_CONFIG_HOME/fatou/fatou.toml, when that variable is set
    2. ~/.config/fatou/fatou.toml
    3. the platform config directory, on macOS ~/Library/Application Support/fatou/fatou.toml
  6. The built-in defaults.

Sources are never merged: exactly one file is used.

Excluding files

exclude takes gitignore-style patterns, resolved relative to the directory containing fatou.toml. Excluded directories are pruned during discovery, so fatou format src and fatou lint src never descend into them.

exclude = ["vendored/"]

Use extend-exclude when you want to keep whatever exclude already lists and add to it, which is mostly useful when the two live in different layers of your setup:

extend-exclude = ["generated.jl"]

A file named explicitly on the command line is always processed, even if it matches an exclude pattern. Pass --force-exclude to apply the patterns to explicitly named files too; this is meant for runners like pre-commit, which invoke Fatou with the staged files as arguments. Extra patterns can also be supplied per run with --exclude on fatou format and fatou lint.

Formatting

The [format] table controls the formatter. The defaults follow common Julia conventions, so most projects only set a key here to depart from them:

[format]
line-width = 92
indent-width = 4
line-ending = "auto"

line-width is the width the formatter tries to keep lines within, and indent-width is the number of spaces per indentation level. Both can be overridden per run with the --line-width and --indent-width flags on fatou format.

line-ending decides the newline style. The default, auto, mirrors the source file’s first line ending and falls back to lf when the file has none, which keeps mixed checkouts stable. Use lf or crlf to force one style, or native to follow the platform Fatou runs on.

Deprecation: the snake_case keys line_width and indent_width are still accepted but print a warning. Use the kebab-case line-width and indent-width instead; the snake_case forms will be removed in a future release.

Choosing lint rules

By default most rules run; the rule reference lists the few that are opt-in. ignore turns individual rules off, and select, when set, restricts the run to exactly the rules you list:

[lint]
select = ["unused-binding", "undefined-name"]
ignore = ["unused-argument"]

See the rule reference for the available rule IDs.

[lint.severity] changes how loudly a rule reports, without changing whether it runs. Rules you do not list keep their default severity:

[lint.severity]
unused-binding = "warning"
undefined-name = "error"

Tuning a rule

A rule with a tunable knob reads it from its own table, named after the rule ID. For example, discouraged-function ships a deny-list of Base functions with process-wide or memory-unsafe effects, and you can add your own entries to it:

[lint.rules.discouraged-function]
extend-functions = { sleep = "use a timer instead of blocking the task" }

Rules without options have no table. The configuration reference lists the rules that do.

Note that strictness here is deliberately different from select, ignore, and severity. Those are lists of IDs you typed, so an unrecognized entry is only a warning and the run continues. A [lint.rules.<id>] table is a schema, so a misspelled rule ID, or a misspelled key inside one, is a configuration parse error and the run stops.

Comparison

Fatou overlaps with several Julia tools: the language servers LanguageServer.jl and JETLS.jl, and the formatters Runic.jl and JuliaFormatter.jl.

The two servers also carry linters, JuliaWorkspaces.jl in the case of LanguageServer.jl and JET.jl in the case of JETLS.jl. Although some of the linting is done inside the servers in both cases.

The primary difference with respect to the tools above is that Fatou is a compiled Rust binary that never starts Julia. It reads your code the way rust-analyzer reads Rust, rather than loading it into a running session. Everything below follows from that. All of these tools are MIT-licensed.

Language Servers

FatouLanguageServer.jlJETLS.jl
Requires a Julia installNoYesYes
Analysis modelSyntax and name/scopeStatic analysis + runtime symbol indexType inference (JET.jl)
Type-aware diagnosticsNoBest-effortYes
FormatterBuilt inDelegates to JuliaFormatter or RunicDelegates to Runic or JuliaFormatter
LinterBuilt inBuilt in (StaticLint)Built in (lowering + type checks)
First-run costNonePrecompilation and package indexingPrecompilation
MaturityYoungMature, de-facto defaultExperimental

Not running Julia means no toolchain to install, no precompilation, and no package-indexing phase before the server is useful. It also means results depend only on the source text, not on which packages happen to be precompiled in the active environment.

The cost is types. Fatou cannot tell you the inferred type of an expression or flag anything that depends on one, and it does not load your dependencies to learn their exported symbols, methods, and docstrings.

LanguageServer.jl

The mature default, and the backend of the Julia VS Code extension. Its architecture is closer to Fatou’s than you might expect: since the v5 rewrite the analysis engine is JuliaWorkspaces.jl on top of Salsa.jl, the same incremental query model Fatou gets from Rust’s salsa, and it parses with JuliaSyntax, which Fatou’s parser is written to match.

The runtime is where they part. LanguageServer.jl discovers your dependencies’ symbols by inspecting them in a spawned Julia process and caching the results. That index is what powers completion and hover for third-party packages, and it is also why the first run on a large project can take minutes.

JETLS.jl

A language server made by the author of JET.jl, and built on the actual compiler: type inference through JET.jl, macro-aware navigation through JuliaLowering.jl. It offers what the others cannot, including types on hover, inlay type hints, and diagnostics for non-existent field access or out-of-bounds indexing. Inferred types are the dividing line: Fatou does show inlay hints, but only for facts it can read off disk, such as a dependency’s resolved version in a Project.toml.

It is also the furthest from Fatou. As of 2026 its README calls it experimental and not production-ready, it needs Julia 1.12 or newer, and it is under heavy development. It is being integrated into the Julia VS Code extension.

Formatters

FatouRunic.jlJuliaFormatter.jl
Requires a Julia installNoYesYes
ConfigurationWidth, indent, line endingNone~38 options, .JuliaFormatter.toml
Named stylesOneOneDefault, Blue, YAS, SciML, Minimal
Line-width limitYes (default 92)NoneYes (margin, default 92)
Reflow modelAlways full reflowPreserves the author’s breaksFull reflow, unless join_lines_based_on_source
Output depends on input layoutNeverYesOnly with source-honoring enabled

The interesting difference is what decides where the line breaks go. Given this input:

foo(
  a,
  b,
)
bar(aaaaaaaaaaaaaaaa, bbbbbbbbbbbbbbbb, cccccccccccccccc, dddddddddddddddd, eeeeeeeeeeeeeeee)

Fatou produces:

foo(a, b)
bar(
    aaaaaaaaaaaaaaaa,
    bbbbbbbbbbbbbbbb,
    cccccccccccccccc,
    dddddddddddddddd,
    eeeeeeeeeeeeeeee,
)

It collapses the short call the author had split, and breaks the long call the author had left on one line, purely by measuring against line-width. Runic does the opposite on both: it keeps foo expanded because the author broke it, and leaves bar alone because it has no width limit. JuliaFormatter’s defaults behave like Fatou’s here.

Equivalent code formats identically under Fatou no matter how it was laid out. There is no way to hand-arrange your source into a different result. Formatting is also idempotent, and the test suite checks format(format(x)) == format(x) over every fixture.

Runic.jl

Modeled on gofmt, with no configuration at all: indentation is four spaces and there are no style knobs. Fatou and Runic agree that formatting should not be re-litigated per project.

They disagree about reflow. Runic has no line-width limit and never breaks or joins lines for width; its own docs say “Line width limit: No. Use your Enter key or refactor your code.” It normalizes how a construct looks once you have broken it, but single-line versus multi-line stays your decision. Fatou takes that decision from the width instead.

Beyond whitespace the two overlap heavily. Both normalize numeric literals (1. to 1.0, .5 to 0.5), operator spacing, for iteration syntax (= and to in), and where clauses (where T to where {T}). Runic also inserts explicit return statements; Fatou does not.

Runic is the better fit if you want to place line breaks by hand and already run Julia.

JuliaFormatter.jl

Dominique Luna’s formatter takes the opposite approach to configuration: around 38 options read from a .JuliaFormatter.toml, plus named styles (Default, Blue, YAS, SciML, Minimal). Its width-driven reflow is the closest of the three to Fatou’s.

Where it goes further is control. It can convert between short and long function definitions, rewrite import to using, add return statements, and honor the source’s line breaks with join_lines_based_on_source. Fatou exposes line-width, indent-width, and line-ending, and nothing else.

Reach for JuliaFormatter when you want a specific named style or the AST-level rewrites.

Linters

FatouLanguageServer.jl (StaticLint)JETLS.jl
Requires a Julia installNoYesYes
Type-based diagnosticsNoLimitedYes
Cross-package symbol knowledgeBase/Core snapshot + workspaceIndexes installed dependenciesLoads code
Rule modelNamed rules, select/ignore, severityjulia.lint.* togglesDiagnostic stages
AutofixSafe and unsafe fixesSome quick fixesSome code actions
Standalone CLIfatou lintNo, runs inside the serverjetls check

Fatou’s rules resolve names and scopes over the syntax tree, so they stay silent whenever certainty runs out. undefined-name resolves an identifier against locals, file bindings, workspace siblings, whole-module using exports, and a Base/Core snapshot, and flags it only when no tier provides it; if the file calls eval, includes outside a known workspace, or usings a module Fatou cannot resolve, the rule skips the file rather than guess. call-arity treats an unknown method as a reason to say nothing.

Both rules need project context to be sound, so the CLI leaves them opt-in and the language server turns them on for workspace member files. The bargain is that Fatou misses real problems a type-aware linter would catch, and rarely cries wolf. StaticLint’s MissingReference has the opposite reputation: false positives on valid using and import code.

The check families otherwise overlap a good deal with StaticLint’s: missing references, unused bindings and arguments, incorrect call arguments, nothing comparisons, constant conditionals, include loops, module naming, and unused type parameters have counterparts on both sides. Fatou also has a type-piracy rule, which needs enough project context to tell an owned type from a foreign one and is opt-in for the same reason.

JETLS diagnoses in three stages: syntax errors from JuliaSyntax, lowering-stage checks (undefined and unused bindings, unreachable code, scope ambiguities, import issues), and type inference through JET.jl. The first two overlap with Fatou’s rules. The third does not, and cannot be replicated without running the compiler; JETLS itself defers that pass to save, while Fatou’s static rules run on every keystroke.

Fatou’s rule model is borrowed from Ruff: stable rule IDs in categories, select/ignore and per-rule severity in [lint], --fix for safe fixes with --unsafe-fixes for the rest, and pretty, concise, or json output from the CLI. See the rule reference for the catalogue.

Running Them Together

Fatou and a Julia-native server coexist happily: Fatou formats and runs its static checks instantly and everywhere, including CI via fatou-action or pre-commit with no Julia setup, and the Julia server contributes the type-aware analysis that needs a running compiler. Editor Setup shows how to give Fatou formatting while another server keeps the rest.

For formatting throughput and cold-start numbers against Runic and JuliaFormatter, see Performance.

Performance

Fatou is a compiled Rust tool where the alternatives are Julia programs running in a Julia runtime. This page measures the two things that difference shows up in: how fast it formats, against Runic and JuliaFormatter, and how much memory it holds, against the Julia language servers LanguageServer.jl and JETLS.

Every number here comes from a benchmark you can re-run: task bench for throughput and task bench-memory for memory. Both write a committed artifact that this page renders; nothing is measured at site-build time, so a figure that moves has to be re-measured and committed deliberately.

Methodology

We measure each tool in a warm loop: the tool is loaded once, run through a few warmup calls, and then timed over many iterations. This deliberately excludes process startup and first-call JIT compilation for the Julia tools, which would otherwise dominate and obscure the actual formatting cost. In other words, these numbers reflect a long-lived editor or language-server session, not the cold julia -e ... command-line invocation. That cold path is measured separately in Cold start below.

Because each tool runs in its own runtime, we report throughput in MB/s, which normalizes for byte count and stays comparable even when tools cover different files. Each tool formats with its own default style; we are measuring speed, not comparing output. A file counts for a tool only if that tool formats it without error, and any skips are reported.

Corpora

Two real-world projects, both pinned to a tag, picked to pull in opposite directions:

  • JuliaSyntax.jl, the parser Fatou targets for parity: dense branching, large token tables, and the code Fatou is best equipped to handle. Home turf.
  • DataFrames.jl, ordinary library code of the kind users actually format: docstring-heavy, macro-heavy, built around a large indexing DSL, and roughly 2.6x the size of the JuliaSyntax tree.

Scenarios

  • Single file (three of them), through each tool’s pure String -> String formatter (fatou::formatter::format, Runic.format_string, JuliaFormatter.format_text). The three targets span both size and shape: parse_stream.jl (42 KB of dense parser internals), kinds.jl (24 KB that is almost entirely one flat macro/data table), and abstractdataframe.jl (100 KB of docstring- and macro-heavy application code).
  • Project (one per corpus): the whole src/ tree, driven through each tool’s own directory entry point, so file discovery, IO, and the tool’s internal parallel scheduling all count. This is the “format my whole project” path. Fatou uses fatou::formatter::check_paths (glob/directory discovery plus rayon-parallel formatting, read-only); JuliaFormatter uses format(dir; overwrite = false) (recursive, thread-parallel, read-only). Runic is excluded from these scenarios by design: it has no in-process directory API (its format_file is single-file only, and directory walking lives solely in its CLI), so there is nothing to measure on the same terms.

Reproduce with task bench (after reloading the devenv shell so Runic is on the Julia path). Results are written to bench/results.json.

Setup

  • Corpora: JuliaSyntax.jl v0.4.10 (09576ca), DataFrames.jl v1.8.2 (946c72a)
  • Versions: Fatou 0.10.0, Runic 1.5.1, JuliaFormatter 2.4.0, Julia 1.12.6
  • Host: AMD Ryzen 9 7900 12-Core Processor (Linux x86_64)
  • Machine: terra
  • Warm-loop iterations: 50 single, 20 project; 3 warmup
  • Cold-start iterations: 5 fresh-process runs (single file)

Results

Single files and whole projects get a chart each: they measure different work at different sizes, and sharing one axis buries that. In both, Fatou is the baseline on the dashed line at 1, and every other tool’s time is plotted relative to it, so faster tools fall below the line and slower tools rise above it.

Single files

Formatting time relative to Fatou on a log scale (lower is faster). One dot per file, grouped at each tool and colored by file; Fatou sits on the dashed baseline at 1 and slower tools appear above it. Each file goes through the tool's pure String -> String formatter, in the tool's own default style. Hover a dot for the exact figures.
Data table

parse_stream.jl (JuliaSyntax/src/parse_stream.jl)

ToolFilesBytesMedian (ms)Throughput (MB/s)Relative
Fatou141,9376.26.75baseline
Runic141,93726.51.584.27x
JuliaFormatter141,9379.34.491.50x

kinds.jl (JuliaSyntax/src/kinds.jl)

ToolFilesBytesMedian (ms)Throughput (MB/s)Relative
Fatou124,4423.08.23baseline
Runic124,44217.71.385.97x
JuliaFormatter124,4423.76.641.24x

abstractdataframe.jl (DataFrames/src/abstractdataframe/abstractdataframe.jl)

ToolFilesBytesMedian (ms)Throughput (MB/s)Relative
Fatou1100,3888.212.30baseline
Runic1100,38831.93.153.91x
JuliaFormatter1100,38812.28.221.50x

Projects

Formatting time relative to Fatou on a log scale (lower is faster). One dot per project, grouped at each tool and colored by project; Fatou sits on the dashed baseline at 1 and slower tools appear above it. Each tool walks the whole source tree through its own directory entry point, so file discovery, IO, and internal parallelism all count; Runic is absent because it has no in-process directory API. Hover a dot for the exact figures.
Data table

JuliaSyntax (JuliaSyntax/src)

ToolFilesBytesMedian (ms)Throughput (MB/s)Relative
Fatou15332,78317.618.86baseline
JuliaFormatter14332,78323.414.251.32x

JuliaFormatter skipped parser.jl: processed, but the tool's own output failed its parse check, so the file was left unchanged

DataFrames (DataFrames/src)

ToolFilesBytesMedian (ms)Throughput (MB/s)Relative
Fatou36870,58115.157.80baseline
JuliaFormatter36870,58137.223.382.47x

Cold start

The warm loop above is the right model for an editor or language server that stays resident, but it hides the cost a command-line user pays on the very first run. This section measures that cold start directly: each tool is invoked as a fresh process that starts up, formats the single file once, and exits. For the Julia tools that means paying Julia’s startup, package loading, and first-call JIT compilation every time, through the same julia -e 'using ...' path a shell user would take; Fatou, a compiled binary, pays only process startup through fatou format. Only one file (parse_stream.jl) is measured, since the numbers are dominated by fixed startup and compilation cost, not by the file’s size.

Median cold-start time relative to Fatou on a logarithmic scale (lower is faster). Fatou is the dashed baseline at 1; each Julia tool sits above at its slowdown factor. Each run is a brand-new process that starts up, formats once, and exits. Fatou runs through fatou format; the Julia tools run through the same julia -e 'using ...' path a shell user takes, so Julia startup, package load, and first-call compilation all count. Hover a dot for the exact figures.
Data table
Cold start: parse_stream.jl, one fresh process per run
ToolMedian timeThroughput (MB/s)vs Fatou
Fatou7.8 ms5.38baseline
Runic192.0 ms0.2224.64x
JuliaFormatter237.7 ms0.1830.50x

Memory

Speed is only half of what a compiled tool buys. The other half is what stays resident while you edit, and there the comparison is not against the formatters but against the Julia language servers: LanguageServer.jl, which runs a Julia runtime and indexes the whole environment through a SymbolServer child process, and JETLS, which runs a Julia runtime and performs real type inference through JET.

This is not like-for-like work, and the numbers should not be read as if it were. Those two servers know things Fatou cannot: JETLS can tell you a method call will not resolve at the types it actually gets, because it ran the inference. Fatou’s semantics are static, with no Julia runtime anywhere in the pipeline, so it never pays for one. What follows measures what an editor session costs, not the price of equivalent analysis.

Methodology

Every server opens the same workspace and is driven through the same scripted session over stdio, the boring one an editor produces on open:

initialize -> initialized -> wait for the server to go quiet
  -> didOpen the largest files in the tree -> diagnostics
  -> documentSymbol and hover -> wait for it to go quiet again

What ends each phase is quiescence, not a fixed wait: a phase is over once aggregate CPU across the process tree stays under 5% of one core for five seconds. The servers here differ by two orders of magnitude in how long they take to finish thinking, and any fixed sleep would flatter one end of that range.

Sampling covers the whole process tree every 150 ms, so a server that fans work out to a helper process is charged for it — which is exactly what LanguageServer.jl’s SymbolServer pass is. Three milestones come out of each run:

  • Baseline — the handshake is done and nothing is open yet. This is the floor a server costs for existing.
  • Settled — files open, diagnostics in, the tree quiet again. This is the figure that matters: what the session holds while you work.
  • Peak — the maximum over every sample. For a server with a short-lived helper this is the only milestone that ever sees it, which is why LanguageServer.jl peaks well above where it settles.

Resident set size is what the tables report. The harness also records proportional set size, which splits shared pages between the processes mapping them; at settle the two agree within a few megabytes for all three servers, so nothing here is an artifact of double-counted shared memory.

Setup

  • Workspace: DataFrames.jl v1.8.2 (946c72a), instantiated
  • Session: 5 files opened (343 KiB of source), diagnostics, symbols, and hovers
  • Versions: Fatou 0.12.0, LanguageServer.jl 5.0.0, JETLS 7e01ca58 (2026-08-08), Julia 1.12.6
  • Host: AMD Ryzen 9 7900 12-Core Processor (Linux x86_64, 61 GB RAM)
  • Machine: terra

Language servers

ServerBaselineSettledPeakvs FatouSettled afterDoing
Fatou97 MB101 MB101 MBbaseline7 sstatic analysis, no Julia runtime
LanguageServer.jl955 MB1128 MB1567 MB11.2x36 sJulia runtime plus a SymbolServer pass over the environment
JETLS797 MB1976 MB2076 MB19.6x34 sJulia runtime plus type inference through JET

Two caveats worth carrying away from that table. Julia’s resident memory includes garbage the collector has not returned yet, and there is no way to ask a server to collect through the protocol — these are the numbers the operating system sees, which is also the number your laptop feels, but a forced collection would hand some of it back. And every server is measured once per run rather than averaged over many, since each Julia server takes the better part of a minute to settle; Fatou’s figure moves by a few megabytes between runs, and the Julia servers’ by a few tens. The gap is two orders of magnitude wider than either, so neither caveat threatens the conclusion.

Fatou’s own footprint decomposes roughly into a fixed engine cost, the package index for the workspace’s dependency closure, and the open files themselves. The index is the dominant term, and it scales with how many packages the environment resolves to — not with how much code you are editing.

One-shot runs

The language server stays resident; fatou format, fatou lint, and fatou parse do not. For a CI job or a pre-commit hook what matters is the high-water mark of a process that lives for a few milliseconds.

CommandOverInputPeak RSSWall
fatou format --checksrc tree850 KiB39.1 MB20 ms
fatou lintsrc tree850 KiB37.0 MB30 ms
fatou format --checkone file98 KiB9.1 MB10 ms
fatou lintone file98 KiB9.3 MB10 ms
fatou parseone file98 KiB7.9 MB20 ms
fatou --versionprocess floor-4.5 MB0 ms
trueharness floor-2.1 MB0 ms

The src tree is 36 files, 850 KiB. Lowest of 5 runs each, measured with GNU time.

The whole-tree cases run the files in parallel, so their peak holds several syntax trees at once. That peak is a function of how many files are in flight together, not of how long the file list is: pointing Fatou at ten times the code does not cost ten times the memory.

Configuration Reference

This page lists key accepted in fatou.toml. All keys are optional and omitting a key uses its default. Unknown keys are rejected with an error.

FATOU_CONFIG and global config files use this same schema. For a task-oriented walkthrough, see the configuration guide.

Top-level keys

KeyTypeDefaultDescription
excludearray of strings[]Patterns to exclude from file discovery.
extend-excludearray of strings[]Additional patterns, appended to exclude.

Both keys take gitignore-style patterns, resolved relative to the directory containing fatou.toml (or, for a FATOU_CONFIG or global config, the working directory). Excluded directories are pruned during discovery.

Files named explicitly on the command line are processed even when they match a pattern, unless --force-exclude is passed. Extra patterns can be added per run with --exclude on fatou format and fatou lint.

exclude = ["vendored/"]
extend-exclude = ["generated.jl"]

[format]

KeyTypeDefaultDescription
line-widthinteger92The width the formatter tries to keep lines within.
indent-widthinteger4Number of spaces per indentation level.
line-endingstring"auto"The newline style emitted at the end of each line.

line-width and indent-width can be overridden per run with the --line-width and --indent-width flags on fatou format.

line-ending accepts:

  • auto (default): mirror the source file’s first line ending, defaulting to lf when the file has none.
  • lf: always \n (Unix).
  • crlf: always \r\n (Windows).
  • native: \n on Unix, \r\n on Windows.
[format]
line-width = 92
indent-width = 4
line-ending = "auto"

Deprecation: the snake_case keys line_width and indent_width are still accepted but print a warning. Use the kebab-case line-width and indent-width instead; the snake_case forms will be removed in a future release.

[lint]

KeyTypeDefaultDescription
selectarray of stringsunsetIf set, only these rule IDs run.
ignorearray of strings[]Rule IDs to disable.
severitytable{}Per-rule severity overrides.
rulestable{}Per-rule option tables.

See the rule reference for the available rule IDs. An unrecognized ID in select, ignore, or severity is a warning, not an error.

[lint.severity] maps a rule ID to the severity its findings report, one of "error", "warning", "info", or "hint". Rules not listed keep their default severity.

[lint]
select = ["some-rule"]
ignore = ["another-rule"]

[lint.severity]
some-rule = "error"

[lint.rules.<id>]

A rule with a tunable knob reads it from its own table, named after the rule ID. Rules without options have no table. Keys are kebab-case, matching the rest of the file.

Unlike select, ignore, and severity, these tables are a schema: a misspelled rule ID, or a misspelled key inside one, is a configuration parse error and the run stops.

Per-rule severity is not set here; use [lint.severity] for that.

[lint.rules.discouraged-function]

Options for discouraged-function. Both keys are tables mapping a function name to the suggestion shown in the diagnostic.

KeyTypeDefaultDescription
functionstablethe built-in setReplaces the built-in deny-list.
extend-functionstable{}Adds to functions; an entry here also wins over a built-in of the same name.

The built-in set covers Base functions with process-wide or memory-unsafe effects: exit, cd, redirect_stdout, redirect_stderr, unsafe_load, unsafe_store!, unsafe_wrap, unsafe_string, pointer_from_objref, and unsafe_pointer_to_objref.

Setting functions = {} silences the rule without having to ignore it, which is the way to keep the rule available for a future project-specific list.

# Keep the built-ins and add a project rule of your own.
[lint.rules.discouraged-function]
extend-functions = { sleep = "use a timer instead of blocking the task" }

# Or replace the built-ins outright.
# functions = { my_legacy_helper = "call `new_helper` instead" }

Command-Line Help for fatou

Fatou: a language server, formatter, and linter for Julia

Usage: fatou [OPTIONS] <COMMAND>

Options

--config <PATH>

Path to an explicit fatou.toml (skips discovery)

--no-config

Ignore any fatou.toml (project, FATOU_CONFIG, or global) and use built-in defaults

--color <COLOR>

When to colorize human-readable output

Default value: auto

Possible values:

  • auto: Colorize when writing to a terminal and NO_COLOR is unset
  • always: Always colorize
  • never: Never colorize
-q, --quiet

Suppress non-essential output (errors are still shown). Under format --check this drops the per-file diff, leaving the list of files that would be reformatted and the summary; under parse it suppresses the CST

fatou parse

Parse and display the CST for debugging

Usage: fatou parse [OPTIONS] [FILE]

Arguments

<FILE>
Input file. Pass - for stdin, also read when the path is omitted and stdin is not a terminal

Options

--verify

Verify parser losslessness (reconstruct(text) == text)

--to <TO>

Output representation: the lossless CST (default) or the JuliaSyntax s-expression projection (the parser oracle)

Default value: cst

Possible values:

  • cst: The lossless rowan concrete syntax tree
  • sexpr: The JuliaSyntax-native s-expression projection

fatou format

Format .jl files

Usage: fatou format [OPTIONS] [PATH]...

Arguments

<PATH>...
Input file(s) or director(ies). Pass - for stdin, also read when paths are omitted and stdin is not a terminal

Options

--check
Check formatting without writing; prints a diff and exits non-zero if any file would change. Requires path arguments: there is no file on disk to report on when reading stdin
--line-width <N>
Override the target line width
--indent-width <N>
Override the indent width
--exclude <PATTERN>
Additional gitignore-style exclude patterns (repeatable or comma-separated); augments the configured exclude/extend-exclude
--force-exclude
Apply exclude patterns to files named explicitly on the command line too (they are normally always processed); for runners like pre-commit that pass staged files as arguments

fatou lint

Lint .jl files

Usage: fatou lint [OPTIONS] [PATH]...

Arguments

<PATH>...
Input file(s) or path(s)

Options

--fix

Apply safe fixes to the source and write the files back

--unsafe-fixes

Also apply fixes marked unsafe (implies --fix)

--exclude <PATTERN>

Additional gitignore-style exclude patterns (repeatable or comma-separated); augments the configured exclude/extend-exclude

--force-exclude

Apply exclude patterns to files named explicitly on the command line too (they are normally always processed); for runners like pre-commit that pass staged files as arguments

--julia-version <VERSION>

Target Julia version or range for version-compat checks (e.g. 1.10 or 1.6 - 1.11); overrides [julia] version and the project’s Project.toml [compat]

--output <OUTPUT>

Output format

Default value: pretty

Possible values: pretty, concise, json

fatou lsp

Run the language server on stdio

Usage: fatou lsp

Lint Rules

fatou lint runs a set of built-in rules over each file and reports a diagnostic for every finding. This page is the catalogue: one section per rule, keyed by its stable rule ID. That ID is what appears in a diagnostic, what [lint] select/ignore (and --select/--ignore) target, what [lint.severity] re-grades, and what a # fatou-ignore <id> comment suppresses.

Most rules are on by default. The few that are opt-in say so in their description; name one in select to run it.

Where a rewrite is unambiguous a rule carries an autofix: a safe fix (shown below as “After applying the fix”) is applied by fatou lint --fix; an unsafe fix, one that may change behavior, is applied only with --unsafe-fixes or as an editor code action, so it has no “after” block here.

Each example below is linted live to produce its diagnostics and fixed output, so this page never drifts from the rules’ actual behavior.

unused-binding

Flag a local variable that is assigned but never read in the same scope. Parameters, loop and catch variables, struct fields, type parameters, and top-level definitions are exempt, since those are meaningful even when unread. Names beginning with _ are skipped, following Julia’s throwaway convention.

tmp is assigned inside f but never used:

function f(x)
    tmp = x + 1
    return x
end
warning: unused-binding
 --> example.jl:2:5
  |
2 |     tmp = x + 1
  |     ^^^ local variable `tmp` is assigned but never used

unused-import

Flag an explicitly imported name that is never used: import X, import X as Y, and the colon-item forms using X: a / import X: a. The whole-module using X form is exempt, since it attaches exports that resolve elsewhere. A qualified use (X.f) or a re-export counts as a use.

sortperm is imported but never referenced:

using Base: sortperm, sum

println(sum([1, 2, 3]))
warning: unused-import
 --> example.jl:1:13
  |
1 | using Base: sortperm, sum
  |             ^^^^^^^^ `sortperm` is imported but never used

duplicate-argument

Flag the same parameter name declared more than once in a single signature. Julia rejects such a definition outright, so it is always a mistake. Positional and keyword parameters share one namespace.

x appears twice in the parameter list:

function dist(x, y, x)
    hypot(x, y)
end
error: duplicate-argument
 --> example.jl:1:21
  |
1 | function dist(x, y, x)
  |                     ^ argument name `x` is used more than once

duplicate-keyword-argument

Flag the same keyword argument supplied more than once at a call site. Julia rejects such a call at lowering (keyword argument "a" repeated in call to "h"), so it parses but cannot run. Keywords before and after the ; share one namespace, and the ;-block shorthand h(; a) counts as passing a. A keyword splat (h(; kw...)) names nothing the rule can read, so it neither triggers the finding nor silences it for the keywords written out. Calls inside quoted code or a macro call are exempt, since neither is lowered as written.

label is supplied twice in one call:

plot(xs, ys, label = "before", label = "after")
error: duplicate-keyword-argument
 --> example.jl:1:32
  |
1 | plot(xs, ys, label = "before", label = "after")
  |                                ^^^^^ keyword argument `label` is passed more than once

unused-argument

Flag a function parameter that is never read in its body. Every signature form is covered — long, short (f(x) = ...), anonymous, and do. All-underscore names (_, __) follow Julia’s throwaway convention and are skipped, and stub methods whose body is a single placeholder expression — a literal (f(x) = 0), nothing, or an error(...)/throw(...) call — are exempt. Because methods that dispatch on an argument’s type without reading its value are common, this rule is disabled by default; enable it with --select unused-argument.

factor is accepted but never used:

function scale(x, factor)
    2 * x
end
warning: unused-argument
 --> example.jl:1:19
  |
1 | function scale(x, factor)
  |                   ^^^^^^ function argument `factor` is never used

undefined-name

Flag an identifier that no resolution tier provides: not a local or a file binding, not a workspace sibling, not a whole-module using’s export, and not a Base/Core name. Such a read raises UndefVarError at runtime. The whole file is skipped when it evals, includes outside a known workspace, or usings a module the library cannot resolve — in those cases any name may exist; value reads inside macro calls and quoted code are likewise exempt. Off by default: the rule needs project context to be sound, so the language server enables it for workspace member files, while the CLI (resolving against a built-in Base/Core snapshot) leaves it opt-in for self-contained scripts.

raduis is a typo; no tier resolves it:

function area(radius)
    return pi * raduis^2
end
warning: undefined-name
 --> example.jl:2:17
  |
2 |     return pi * raduis^2
  |                 ^^^^^^ `raduis` is not defined

break-outside-loop

Flag a break or continue with no enclosing for or while loop. The code parses but always fails at lowering with “break or continue outside loop” — including inside a closure, do-block, or comprehension body defined within a loop, since break cannot cross a function boundary.

break with no loop in sight:

function process(x)
    if x < 0
        break
    end
    x
end
error: break-outside-loop
 --> example.jl:3:9
  |
3 |         break
  |         ^^^^^ `break` outside of a `for` or `while` loop

A do-block body is an anonymous function, so the outer loop is out of reach:

for i in 1:3
    foreach(1:2) do x
        continue
    end
end
error: break-outside-loop
 --> example.jl:3:9
  |
3 |         continue
  |         ^^^^^^^^ `continue` outside of a `for` or `while` loop

const-local

Flag a const declaration inside a local scope — a function or macro body, a let, a for/while body, a try, a closure, or a comprehension. const is only meaningful at global scope (the file top level and each module body); anywhere else the code parses but always fails at lowering with “unsupported const declaration on local variable”. A const field of a mutable struct is a different construct and is left alone, as is a const inside quoted code or a macro argument, which may never be lowered as written.

const inside a function body:

function scale(x)
    const factor = 2
    factor * x
end
error: const-local
 --> example.jl:2:5
  |
2 |     const factor = 2
  |     ^^^^^^^^^^^^^^^^ `const` declaration on a local variable

A let body is local too — the declaration belongs at top level:

let
    const limit = 10
    limit
end
error: const-local
 --> example.jl:2:5
  |
2 |     const limit = 10
  |     ^^^^^^^^^^^^^^^^ `const` declaration on a local variable

global-const-in-function

Flag a global const declaration inside a function — a function or macro body, a short-form definition, a closure, a do block, or a comprehension. Julia allows global const at global scope and in a soft local scope such as a let, a loop body, or a try, but inside a function the code parses and then always fails at lowering with “global const declaration not allowed inside function”. A soft scope nested inside a function does not help. Both spellings of the modifier are flagged, and a declaration inside quoted code or a macro argument is left alone, since it may never be lowered as written. An unmodified const in a local scope is const-local’s finding.

global const inside a function body:

function setup()
    global const LIMIT = 10
end
error: global-const-in-function
 --> example.jl:2:5
  |
2 |     global const LIMIT = 10
  |     ^^^^^^^^^^^^^^^^^^^^^^^ `global const` declaration inside a function

A nested soft scope does not rescue it — the enclosing function still owns the declaration:

function setup()
    for i in 1:3
        global const LIMIT = i
    end
end
error: global-const-in-function
 --> example.jl:3:9
  |
3 |         global const LIMIT = i
  |         ^^^^^^^^^^^^^^^^^^^^^^ `global const` declaration inside a function

local-const

Flag a const declaration carrying a local modifier, in either order. Julia has no local const construct: the code parses but always fails at lowering with “expected assignment after const”, everywhere — the file top level, a module or struct body, a loop or let, and inside a function alike. Write local z = 1 for a local binding or const z = 1 for a constant. A declaration inside quoted code or a macro argument is left alone, since it may never be lowered as written.

local and const cannot be combined:

local const LIMIT = 10
error: local-const
 --> example.jl:1:1
  |
1 | local const LIMIT = 10
  | ^^^^^^^^^^^^^^^^^^^^^^ `local const` declaration is not supported

The other order is the same construct, and a function body is no different from the top level:

function scale(x)
    const local factor = 2
    factor * x
end
error: local-const
 --> example.jl:2:5
  |
2 |     const local factor = 2
  |     ^^^^^^^^^^^^^^^^^^^^^^ `local const` declaration is not supported

noteq-definition

Flag a method definition for != (or ). Julia defines != as const != = !(==), so it is not meant to be overloaded: define == instead, and != follows automatically.

Defining != where == should carry the method:

!=(a::Grade, b::Grade) = a.score != b.score
warning: noteq-definition
 --> example.jl:1:1
  |
1 | !=(a::Grade, b::Grade) = a.score != b.score
  | ^^ `!=` is defined as `!(==)` and should not be overloaded; define `==` instead

unused-type-parameter

Flag a where clause type parameter that is never used in the signature or body, covering bare (where T), braced (where {T, S}), bounded (where {T<:Number}), and chained (where T where S) clauses. An unread parameter is usually a refactoring leftover or a forgotten ::T annotation. Struct type parameters are exempt (phantom parameters like struct Unit{T} end are idiomatic), as are all-underscore names.

T is bound but never referenced:

function f(x) where {T}
    x + 1
end
warning: unused-type-parameter
 --> example.jl:1:22
  |
1 | function f(x) where {T}
  |                      ^ type parameter `T` is never used

missing-include-file

Flag a static include("path") whose target does not exist on disk (relative paths resolve against the including file’s directory). Running the file would throw a SystemError. Only statically resolvable includes are checked: dynamic (include(f)), interpolated (include("$dir/a.jl")), qualified (M.include(...)), and two-argument forms cannot be resolved without running the code and are never flagged.

Including a file that does not exist:

include("missing.jl")
error: missing-include-file
 --> example.jl:1:9
  |
1 | include("missing.jl")
  |         ^^^^^^^^^^^^ included file "missing.jl" does not exist

include-cycle

Flag a static include("path") whose target transitively includes this file again — including a file that includes itself. Running such a file recurses without end. Every linted file on the cycle is flagged at its own include call; a file merely included twice along different paths (a diamond) is not a cycle.

A file (here example.jl) that includes itself:

include("example.jl")
warning: include-cycle
 --> example.jl:1:9
  |
1 | include("example.jl")
  |         ^^^^^^^^^^^^ include cycle: "example.jl" transitively includes this file

duplicate-include

Flag a static include("path") that pulls in a file an earlier include in the same file already did. Julia has no include guard, so the repeat evaluates the file a second time, redefining its methods and re-running its top-level code. Paths are compared after resolution, so "a.jl" and "./a.jl" count as the same file. Including one file into two different module blocks is not flagged — that runs its definitions into two separate namespaces — and neither is a file reached twice along different include chains.

The same file included twice, so its definitions run again:

include("util.jl")
include("util.jl")
warning: duplicate-include
 --> example.jl:2:9
  |
2 | include("util.jl")
  |         ^^^^^^^^^ duplicate include: "util.jl" is already included earlier in this file

duplicate-method

Flag a method definition whose dispatch signature an earlier definition in the same file already used. Julia holds one method per signature, so the later definition silently replaces the earlier one and the earlier body becomes dead. Signatures are compared on what dispatch actually sees: the positional argument types (an unannotated argument is Any), the where specs, and any type arguments the definition applies to its own name, as a parameterized constructor does. Argument names, default values, keyword arguments, and the declared return type are not part of a method’s identity, so definitions differing only in those still collide. Definitions with different where bounds are separate methods and are not flagged, and neither are definitions inside a macro call or a conditional branch, where the written signature is not evidence of what gets defined.

The second definition replaces the first, so 1 is unreachable:

f(x::Int) = 1
f(y::Int) = 2
warning: duplicate-method
 --> example.jl:2:1
  |
2 | f(y::Int) = 2
  | ^ `f` is already defined with this signature earlier in this file; the later definition replaces the earlier one

julia-version-compat

Flag syntax newer than the project’s declared Julia support range. Fatou parses the full superset of Julia syntax, so a construct from a newer release parses cleanly even when the project targets an older version; this rule reports when a supported version predates the construct (e.g. public needs 1.11, import ... as needs 1.6). The target range is taken from --julia-version, [julia] version, or the project’s Project.toml [compat] / Manifest.toml; with no target known the rule stays silent.

Targeting Julia 1.0, but public needs 1.11 and as needs 1.6:

module M
public foo
import A as B
end
error: julia-version-compat
 --> example.jl:2:1
  |
2 | public foo
  | ^^^^^^^^^^ the `public` keyword requires Julia 1.11.0, but the project supports 1.0.0 and up
error: julia-version-compat
 --> example.jl:3:8
  |
3 | import A as B
  |        ^^^^^^ renaming with `as` in `import`/`using` requires Julia 1.6.0, but the project supports 1.0.0 and up

call-arity

Flag a call that no visible method of the function accepts: a positional count outside every method’s range, or a keyword argument no positionally-matching method declares. Such a call raises MethodError at runtime. The method table unions every tier name resolution sees — the file’s own definitions, the workspace package, and the harvested library, qualified extensions included — so an unknown method always silences the check rather than triggering it. Calls that splat arguments, carry do blocks, sit in macro calls or quoted code, or target constructors and callable values are exempt, and a file that evals or includes outside a known workspace is skipped entirely. Off by default: the rule needs project context to be sound, so the language server enables it for workspace member files, while the CLI leaves it opt-in for self-contained scripts.

half has no two-argument method:

half(x) = x / 2

half(3, 4)
warning: call-arity
 --> example.jl:3:1
  |
3 | half(3, 4)
  | ^^^^^^^^^^ no method of `half` takes 2 positional arguments (methods accept 1)

function-has-no-methods

Flag a call to a function whose every visible definition is a bodyless function f end declaration. Such a function has an empty method table, so the call raises MethodError whatever it passes. Only a name the closed world defines is checked — one belonging to this file or to the enclosing workspace package — since the owner of a using’d or Base/Core name may add methods elsewhere. A declaration the package exports or declares public is exempt as an interface hook for a package extension or a downstream package to implement, as is a name that also names a type. The file is skipped entirely when it evals, includes outside a known workspace, or usings a module the library cannot resolve, and call sites in macro calls or quoted code are exempt. Off by default: like call-arity the rule needs project context to be sound, so the language server enables it for workspace member files while the CLI leaves it opt-in.

normalize is declared but never given a method:

function normalize end

normalize("data")
warning: function-has-no-methods
 --> example.jl:3:1
  |
3 | normalize("data")
  | ^^^^^^^^^ `normalize` has no methods: every definition is a bodyless `function normalize end`

redefined-constant

Flag a write that redefines a constant name, or defines over a name that already holds a value: reassigning a const binding, assigning to a global function, type, or module name (those bind implicit constants), defining a function over a plain value, or declaring a value const after the fact. All of these error at runtime when both sites execute. A definition and a write in disjoint branches of the same if are exempt — only one branch runs. Adding a method to a function and defining an outer constructor on a type are legal and stay silent. No fix: the rule cannot know which of the two definitions the author meant to keep.

Reassigning a const binding errors at runtime:

const threshold = 1.0
threshold = 2.0
warning: redefined-constant
 --> example.jl:2:1
  |
2 | threshold = 2.0
  | ^^^^^^^^^ reassignment of constant `threshold`

count already holds a value, so the method definition fails:

count = 0
count(xs) = length(xs)
warning: redefined-constant
 --> example.jl:2:1
  |
2 | count(xs) = length(xs)
  | ^^^^^ cannot define function `count`: it already has a value

type-piracy

Flag a method definition that extends a function the current module does not own, using only argument types it does not own either (“type piracy”). Because Julia dispatches on one global method table, such a method silently changes behavior for every other user of those types the moment the module loads. A definition is fine as long as it owns the function or at least one argument type (a type parameter or where bound counts). The rule is sound-first: it flags only when it can prove the function and every readable argument type are foreign, withholding on anything unknown, and it skips the whole file when a whole-module using cannot be resolved. Off by default: it needs project context, so the language server enables it for workspace member files while the CLI leaves it opt-in via --select.

Extending Base.show for only Base types is piracy:

Base.show(io::IO, x::Int) = print(io, x)
warning: type-piracy
 --> example.jl:1:6
  |
1 | Base.show(io::IO, x::Int) = print(io, x)
  |      ^^^^ `Base.show` commits type piracy: it extends a function this module does not own, and no argument type is owned here either

unreachable-code

Flag a statement no path of execution can reach: the tail after an unconditional return, throw, error, or rethrow, after an if/else that diverges in every arm, or after a while true with no break. The code runs, but the flagged statement never does, so it is either dead weight or a sign that the divergence above it is misplaced. Reachability comes from the file’s control-flow graph, so a for that may run zero times, an if with no else, a catch clause, and a conditional a && return all keep their tails live. No fix is offered: deleting the statement is a judgment call, and keeping it may be the point when the divergence is the bug.

Nothing after an unconditional return can run:

function f(x)
    return x + 1
    println("never")
end
warning: unreachable-code
 --> example.jl:3:5
  |
3 |     println("never")
  |     ^^^^^^^^^^^^^^^^ unreachable code: no path of execution reaches this statement

Both arms diverge, so the tail is dead too:

function classify(x)
    if x > 0
        return :pos
    else
        throw(DomainError(x))
    end
    return :unknown
end
warning: unreachable-code
 --> example.jl:7:5
  |
7 |     return :unknown
  |     ^^^^^^^^^^^^^^^ unreachable code: no path of execution reaches this statement

unresolved-import

Flag a using/import of a module the enclosing project cannot load: a name that is neither in its Project.toml [deps] nor provided by the harvested environment (the standard library included). Julia resolves a bare using Foo against the active project, so an undeclared name raises ArgumentError: Package Foo not found in current path when the module loads. Relative paths (using .Sub), interpolated paths, and loads inside quoted code or macro calls are exempt, and a name the harvest resolved is always accepted — so a transitive dependency goes unreported rather than risking a false positive. Off by default: the rule needs the project context that only a package source file carries, so the language server enables it for workspace member files while the CLI leaves it opt-in.

The project declares LinearAlgebra in [deps], but not Frobnicate:

using LinearAlgebra
using Frobnicate
warning: unresolved-import
 --> example.jl:2:7
  |
2 | using Frobnicate
  |       ^^^^^^^^^^ `Frobnicate` is not a dependency of this project

kwarg-default-mismatch

Flag a keyword parameter whose literal default cannot be an instance of its declared type, as in g(; y::Int = 1.0). A keyword’s ::T is not an implicit convert: Julia lowers it into a dispatch constraint on the inner method the default is passed to, so g() raises a MethodError every time. The check is exact, like dispatch itself — y::Float64 = 1 and y::Int8 = 1 are mismatches too — and fires only when both sides are certain: a bare, concrete Core type that resolves to Base, and a default whose own spelling pins its type down. Abstract and parametric annotations (Real, Vector{Int}), computed defaults, and the literals whose type follows their digit count (0x01) are all left alone.

y is declared Int, so the Float64 default never matches it:

function scale(xs; y::Int = 1.0)
    xs .* y
end
error: kwarg-default-mismatch
 --> example.jl:1:20
  |
1 | function scale(xs; y::Int = 1.0)
  |                    ^^^^^^^^^^^^ keyword argument `y` is declared `::Int`, but its default `1.0` has type `Float64`
  = help: a keyword's `::T` is a dispatch constraint, not a `convert`, so the default raises a `MethodError`

Julia does not promote here either — the default has to be an Int8 already:

counter(; start::Int8 = 0) = start
error: kwarg-default-mismatch
 --> example.jl:1:11
  |
1 | counter(; start::Int8 = 0) = start
  |           ^^^^^^^^^^^^^^^ keyword argument `start` is declared `::Int8`, but its default `0` has type `Int`
  = help: a keyword's `::T` is a dispatch constraint, not a `convert`, so the default raises a `MethodError`

invalid-type-declaration

Flag a :: type declaration whose declared type is a function. The right side of a :: must be a type, and Julia checks it eagerly: f(x::g) for a generic function g raises a TypeError when the method is defined, and a value-position x::g raises one as soon as it runs. The rule is deliberately partial, since only a closed answer about a name is trustworthy: it fires for a function defined in this file and for a top-level function of the enclosing workspace package, and stays silent for a Base/Core name (the export snapshot records no kinds), an imported name, and any binding that could hold a type such as a variable or a const alias. A same-named type, const, or module anywhere in the file or the package withholds the finding, so an outer constructor (Foo(x::Int) = …, which binds Foo as a function too) is never flagged. The file is skipped entirely when it evals, includes outside a known workspace, or usings a module the library cannot resolve, and annotations inside a macro call or quoted code are exempt. Off by default: like the other resolution-gated rules it needs project context, so the language server enables it for workspace member files while the CLI leaves it opt-in via --select.

scale is a function, so declaring y::scale is a TypeError:

scale(x) = 2x

apply(y::scale) = y
warning: invalid-type-declaration
 --> example.jl:3:10
  |
3 | apply(y::scale) = y
  |          ^^^^^ `scale` is a function, not a type: `::scale` is not a valid type declaration

assignment-in-condition

Flag a bare = assignment used as the test of an if/elseif/while. It is valid Julia but almost always a typo for ==, so it is reported with a safe fix that rewrites = to ==.

= where == was meant:

if x = 5
    println(x)
end
warning: assignment-in-condition
 --> example.jl:1:4
  |
1 | if x = 5
  |    ^^^^^ assignment used as a condition; did you mean `==`?
  = help: Replace `=` with `==` (safe fix)

After applying the fix:

if x == 5
    println(x)
end

nothing-comparison

Flag x == nothing / x != nothing, which compares against nothing by value. nothing is the singleton instance of Nothing, so an identity test (=== / !==, or isnothing) is meant: it is faster and cannot be overloaded. The rule reports a safe fix rewriting == to === and != to !==.

Comparing against nothing by value:

if x == nothing
    1
end
warning: nothing-comparison
 --> example.jl:1:4
  |
1 | if x == nothing
  |    ^^^^^^^^^^^^ comparison against `nothing` by value; use `===` or `isnothing`
  = help: Replace `==` with `===` (safe fix)

After applying the fix:

if x === nothing
    1
end

missing-comparison

Flag x == missing / x != missing. missing propagates through ==, so the comparison is always missing no matter what x is, and using it as a condition raises a TypeError. Use ismissing (or the identity test === / !==) instead. The rule reports an unsafe fix rewriting == to === and != to !==: the rewrite turns a missing result into a Bool, which is the intent but is still a change in behavior.

Comparing against missing by value:

if x == missing
    1
end
warning: missing-comparison
 --> example.jl:1:4
  |
1 | if x == missing
  |    ^^^^^^^^^^^^ comparison against `missing` by value is always `missing`; use `ismissing` or `===`
  = help: Replace `==` with `===` (unsafe fix, requires `--unsafe-fixes`)

constant-condition

Flag a true/false literal used as an if/elseif/while test or as an operand of the short-circuit &&/||. The branch or short-circuit is decided before the code runs, so the literal is usually leftover debugging or a mistyped name. while true is exempt as Julia’s idiomatic infinite loop. No fix: removing the constant means restructuring the branch.

A literal if test always takes the branch:

if true
    println("always")
end
warning: constant-condition
 --> example.jl:1:4
  |
1 | if true
  |    ^^^^ this condition is always `true`

A literal operand decides && at parse time:

ok = false && check(x)
warning: constant-condition
 --> example.jl:1:6
  |
1 | ok = false && check(x)
  |      ^^^^^ `&&` has a constant `false` operand

module-shadows-parent

Flag a nested module with the same name as its direct parent module. A module binds its own name inside itself, so the child rebinds that self-reference: A in the parent body then refers to the child, and qualified names like A.x resolve against the wrong module. The usual cause is a file included into the module it already defines. No fix: renaming a module means updating every reference to it.

A submodule shadowing the module that contains it:

module A

module A
end

end
warning: module-shadows-parent
 --> example.jl:3:8
  |
3 | module A
  |        ^ module `A` has the same name as its parent module

loop-variable-shadow

Flag a for loop whose index variable is already an enclosing for loop’s index, and an assignment to a loop variable inside its own loop. The nested for binds a fresh variable, so the outer index is unreachable inside it — usually a copy-pasted inner loop whose index was never renamed. An assignment to a loop variable is discarded at the next iteration, since for rebinds the variable from the iterator on every pass, so it can never steer the iteration. Comprehension and generator clauses are left alone, as is reuse across a function body, a closure, or a do block. No fix: renaming an index or dropping an assignment changes what the body computes.

A nested loop reusing the enclosing loop’s index:

for i in 1:3
    for i in 1:2
        println(i)
    end
end
warning: loop-variable-shadow
 --> example.jl:2:9
  |
2 |     for i in 1:2
  |         ^ loop variable `i` shadows the enclosing loop's variable

An assignment the next iteration discards:

for i in 1:10
    if isodd(i)
        i += 1
    end
    println(i)
end
warning: loop-variable-shadow
 --> example.jl:3:9
  |
3 |         i += 1
  |         ^ assignment to loop variable `i` is discarded at the next iteration

index-from-length

Flag two suspect for-loop iteration specs. First, for i in 1:length(x) (or 1:size(x, d)) where the loop variable then indexes x: prefer eachindex(x) (or axes(x, d)), which stays correct for collections whose indices are not one-based. The match is name-based — any length/size call counts — and, lacking type information to exempt collections that really are one-based like Vector, the rule is opinionated, so it only fires when the loop variable actually indexes the collection. The first shape carries an unsafe fix rewriting the 1:length/1:size prefix to eachindex/axes: the rewrite is only value-equivalent when the collection’s indices are one-based and dense, which cannot be proven without type information, so it needs --unsafe-fixes. Second, for i in 3.5: iterating a bare numeric literal runs the loop body once and is almost always a mistaken range; no fix, since the intended range is unknowable.

1:length(x) used to index x:

for i in 1:length(x)
    println(x[i])
end
warning: index-from-length
 --> example.jl:1:10
  |
1 | for i in 1:length(x)
  |          ^^^^^^^^^^^ iterate `eachindex(x)` instead of `1:length(x)`
  = help: Replace `1:length` with `eachindex` (unsafe fix, requires `--unsafe-fixes`)

Iterating a bare number loops once:

for i in 3.5
    println(i)
end
warning: index-from-length
 --> example.jl:1:10
  |
1 | for i in 3.5
  |          ^^^ iterating a numeric literal runs the loop body once; did you mean a range?

discouraged-function

Flag a call to a function on a configurable deny-list. The built-in set covers Base functions with process-wide or memory-unsafe effects — exit, cd, redirect_stdout, redirect_stderr, and the unsafe_*/pointer conversions — each reported with the alternative to reach for.

Configure it under [lint.rules.discouraged-function]: functions replaces the built-in set, extend-functions adds to it (an entry there also rewords a built-in), and functions = {} silences the rule without ignoring it. Both are tables mapping a function name to the suggestion shown in the diagnostic.

A call carrying a do block is never reported, since for cd and the redirect_* functions that form is the recommended alternative. A qualified callee (Base.exit) is a different name and does not match. A built-in name is only reported once it is confirmed to be Base’s, so a local of the same name — or a file whose imports cannot be resolved — reports nothing; a name the project configured is reported unless a definition in the same file shadows it. No fix is offered, since the rewrite is a judgment call.

exit ends the process and cd leaves the working directory changed:

function cleanup()
    cd("/tmp")
    exit(1)
end
warning: discouraged-function
 --> example.jl:2:5
  |
2 |     cd("/tmp")
  |     ^^ `cd` is discouraged: use the `cd(f, dir)` do-block form so the working directory is restored
warning: discouraged-function
 --> example.jl:3:5
  |
3 |     exit(1)
  |     ^^^^ `exit` is discouraged: let the caller decide when the process ends

typeof-comparison

Flag typeof(x) == T / typeof(x) != T (in either operand order), which tests for the exact type and so answers false for every subtype of Ttypeof(x) == Integer is false for every integer, since typeof returns the concrete Int64. Use x isa T, the subtype test.

The already-correct === / !==, the broadcast .== / .!=, and a comparison chain are left alone, as is typeof(a) == typeof(b), which compares two runtime types and has no isa spelling. The callee must be confirmed to be Base’s typeof, so a local shadow, a qualified Base.typeof, or a file whose imports cannot be resolved reports nothing.

The rule reports an unsafe fix rewriting the comparison to x isa T (!(x isa T) for !=): widening the exact-type test to the subtype tree is the intent, but it is still a change in behavior, and a caller may genuinely want exact-type identity. The fix is withheld — the finding still stands — when a comment sits inside the rewritten span, or when typeof’s argument binds more loosely than isa and so cannot be spliced in without parentheses.

typeof(x) == Integer is false for every integer:

if typeof(x) == Integer
    1
end
warning: typeof-comparison
 --> example.jl:1:4
  |
1 | if typeof(x) == Integer
  |    ^^^^^^^^^^^^^^^^^^^^ `typeof(x) == T` matches only the exact type; use `x isa T`, which includes subtypes
  = help: Rewrite as an `isa` test (unsafe fix, requires `--unsafe-fixes`)

shadowed-base-name

Flag a call to a name the file binds to a value while Base or Core exports it as a function. Julia has one namespace and no call-position fallback, so the binding masks the Base name everywhere: after length = 3, a later length(xs) raises a MethodError for calling an Int, and assigning the other way round fails too once the module has used the Base name. Both a binding and a call are required — the binding alone is ordinary Julia. Only bindings that plainly hold a value count. Method, macro, type, and module definitions of the name are exempt, as is import Base: length; so is a parameter, since passing a function in under a Base name (request(stack::Base.Callable, …)) is the higher-order idiom rather than an accident, though a catch variable — which holds the thrown exception — still counts. A binding the source visibly assigns something callable (a lambda, another builtin under a new name, or any qualified path such as exit = t.__exit__) is exempt too, as is anything defined or called inside quoted code or a macro call, where a DSL may spell an attribute length = 32 without touching Base. No fix: renaming the binding means rewriting every reference to it.

The assignment masks Base’s length, so the call fails:

length = 3
n = length(xs)
warning: shadowed-base-name
 --> example.jl:2:5
  |
2 | n = length(xs)
  |     ^^^^^^ `length` here is this file's own binding, not Base's `length`

A catch variable can mask a builtin just as well:

try
    risky()
catch error
    error("failed")
end
warning: shadowed-base-name
 --> example.jl:4:5
  |
4 |     error("failed")
  |     ^^^^^ `error` here is this file's own binding, not Base's `error`

non-public-access

Flag a qualified read of a name the target module neither exports nor declares public. Those two statements are how a Julia module says what its API is — public (1.11) declares intent without attaching the name to a using — so everything else is internal and free to change in a patch release. Only a qualifier that really names a module is checked: a local, a parameter, a name an item list bound (import Foo: Bar), and any plain field access are values, not modules. Two kinds of module are left alone: one that declares no public API at all, whose whole surface is reached qualified by design, and one that re-exports with Reexport.jl, whose @reexport using Bar is a macro call the index cannot follow. So is the package under development, whose own internals its files may use. A Base/Core export reaches through any module, since every module but a baremodule implicitly does using BaseThreads.ReentrantLock is Base’s type — and so do the eval and include every module binds for itself. Definition sites are not reads: Base.show(io, x) = … extends a function and Foo.bar = 1 writes one, and code inside a quote or a macro call is exempt as everywhere else. Off by default: the rule needs the harvested library that only project context provides, and it reports what a module declared, so a package whose documented interface is qualified and unexported is reported too. No fix — the caller cannot make someone else’s name public.

summarysize is part of Base’s public API; unwrap_unionall is not:

Base.summarysize(x)
Base.unwrap_unionall(T)
warning: non-public-access
 --> example.jl:2:1
  |
2 | Base.unwrap_unionall(T)
  | ^^^^^^^^^^^^^^^^^^^^ `Base` does not export `unwrap_unionall` or declare it `public`

Macros are checked in their own namespace:

Base.@_inline_meta
warning: non-public-access
 --> example.jl:1:1
  |
1 | Base.@_inline_meta
  | ^^^^^^^^^^^^^^^^^^ `Base` does not export `@_inline_meta` or declare it `public`

eager-broadcast

Flag a Base reducer applied to a broadcast that exists only to be reduced: sum(abs.(xs)) builds a whole mapped array and then sums it away, where sum(abs, xs) applies abs as it reduces and allocates nothing. The reducers with such an (f, itr) method are all, any, count, maximum, minimum, prod, and sum.

Only the exact reducer(f.(x)) shape is flagged. A keyword argument on either call, a second positional argument, and a fused multi-container broadcast (hypot.(xs, ys)) all describe a different call and are left alone, as is a broadcast operator (any(xs .> 0)), which names no function to pass. The reducer must be confirmed to be Base’s, so a local shadow, a qualified Base.sum, or a file whose imports cannot be resolved reports nothing.

The fix moves the function into the reducer’s first argument, but needs --unsafe-fixes: broadcasting treats a scalar as a zero-dimensional container of itself where the two-argument method iterates it, and any/all stop at the first decisive element, so a function that prints, mutates, or throws runs a different number of times. The fix is withheld — the finding still stands — when a comment sits in the rewritten span.

The mapped array is built only to be summed away:

total = sum(abs.(residuals))
warning: eager-broadcast
 --> example.jl:1:9
  |
1 | total = sum(abs.(residuals))
  |         ^^^^^^^^^^^^^^^^^^^^ call `sum(abs, residuals)` instead of `sum(abs.(residuals))`, which materializes the broadcast result
  = help: Pass the function as `sum`'s first argument (unsafe fix, requires `--unsafe-fixes`)

any need not test every element:

if any(isnan.(xs))
    error("bad data")
end
warning: eager-broadcast
 --> example.jl:1:4
  |
1 | if any(isnan.(xs))
  |    ^^^^^^^^^^^^^^^ call `any(isnan, xs)` instead of `any(isnan.(xs))`, which materializes the broadcast result
  = help: Pass the function as `any`'s first argument (unsafe fix, requires `--unsafe-fixes`)

sorted-extremum

Flag indexing one end of a freshly sorted collection: sort(xs)[1] copies and orders every element to answer what minimum(xs) answers in one pass, and sort(xs)[end] (like sort(xs)[begin] for the other end) does the same for maximum(xs).

Only a plain sort(x) at either end is flagged. A rev, by, or lt keyword decides which element lands where, so the index no longer picks the extremum a reducer would return; any other index (sort(xs)[2]) genuinely needs the order; and sort! is a different function. sort must be confirmed to be Base’s, so a local shadow or a qualified Base.sort reports nothing.

The fix replaces the indexing with the extremum call, but needs --unsafe-fixes: sorting orders NaN last, so sort(xs)[1] returns the smallest real number where minimum(xs) propagates the NaN. It is withheld — the finding still stands — when the file’s minimum/maximum is not Base’s, or when a comment sits in the rewritten span outside the collection.

Sorting to read the smallest element:

lowest = sort(scores)[1]
warning: sorted-extremum
 --> example.jl:1:10
  |
1 | lowest = sort(scores)[1]
  |          ^^^^^^^^^^^^^^^ call `minimum(scores)` instead of `sort(scores)[1]`, which sorts the whole collection
  = help: Replace the sorted indexing with `minimum` (unsafe fix, requires `--unsafe-fixes`)

And the largest:

highest = sort(scores)[end]
warning: sorted-extremum
 --> example.jl:1:11
  |
1 | highest = sort(scores)[end]
  |           ^^^^^^^^^^^^^^^^^ call `maximum(scores)` instead of `sort(scores)[end]`, which sorts the whole collection
  = help: Replace the sorted indexing with `maximum` (unsafe fix, requires `--unsafe-fixes`)

length-findall

Flag length(findall(p, x)), which allocates a vector of every matching index just to ask how many there are. count(p, x) answers with a counter and no allocation, and covers the one-argument form too: length(findall(mask)) is count(mask).

Both calls must be plain — no keyword arguments, no splats — and both length and findall must be confirmed to be Base’s, so a local shadow, a qualified Base.findall, or a file whose imports cannot be resolved reports nothing.

The fix rewrites the pair to a count call carrying findall’s own argument list, but needs --unsafe-fixes: findall walks a collection’s keys where count iterates its elements, which is the same walk for an array and a different one for a Dict, whose values findall tests and whose key => value pairs count does. It is withheld — the finding still stands — when the file’s count is not Base’s, or when a comment sits in the rewritten span outside the argument list.

The index vector is built only to be measured:

n = length(findall(isodd, xs))
warning: length-findall
 --> example.jl:1:5
  |
1 | n = length(findall(isodd, xs))
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^ call `count(isodd, xs)` instead of `length(findall(isodd, xs))`, which builds an index vector just to count it
  = help: Count the matches directly with `count` (unsafe fix, requires `--unsafe-fixes`)

The one-argument form counts a mask:

hits = length(findall(mask))
warning: length-findall
 --> example.jl:1:8
  |
1 | hits = length(findall(mask))
  |        ^^^^^^^^^^^^^^^^^^^^^ call `count(mask)` instead of `length(findall(mask))`, which builds an index vector just to count it
  = help: Count the matches directly with `count` (unsafe fix, requires `--unsafe-fixes`)

fixed-regex

Flag occursin(r"abc", s), whose regex literal carries no metacharacter and so matches one fixed substring. occursin("abc", s) asks the same question of the same values and answers it with a substring search instead of the regex engine.

Every Base function that takes a pattern is read the same way, each at the argument that holds it: occursin(r"abc", s), the flipped contains(s, r"abc"), startswith/endswith, split/eachsplit, each => pair of a replace, and the curried forms that fix the pattern (contains(r"abc")). The curried occursin(s) fixes the haystack instead, so its argument is no pattern and is left alone, and rsplit takes no regex at all.

The pattern must be a plain r"..." — no flag suffix, since a flag changes what the pattern means, and no interpolation — and it must be non-empty and free of every PCRE metacharacter and backslash escape. The callee must be confirmed to be Base’s, so a local shadow, a qualified Base.occursin, or a file whose imports cannot be resolved reports nothing.

The safe fix deletes the r prefix and nothing else. A pattern with no backslash and no $ reads identically as an ordinary string literal, and the literal keeps its own delimiters, so the string it spells is unchanged.

The pattern is a plain substring, matched through PCRE:

if occursin(r"error", line)
    push!(failures, line)
end
warning: fixed-regex
 --> example.jl:1:13
  |
1 | if occursin(r"error", line)
  |             ^^^^^^^^ use the plain string `"error"` instead of the regex `r"error"`, whose pattern has no metacharacter
  = help: Drop the `r` prefix and match the substring (safe fix)

After applying the fix:

if occursin("error", line)
    push!(failures, line)
end

The same search written the other way round, curried:

failures = filter(contains(r"error"), lines)
warning: fixed-regex
 --> example.jl:1:28
  |
1 | failures = filter(contains(r"error"), lines)
  |                            ^^^^^^^^ use the plain string `"error"` instead of the regex `r"error"`, whose pattern has no metacharacter
  = help: Drop the `r` prefix and match the substring (safe fix)

After applying the fix:

failures = filter(contains("error"), lines)

Every other pattern position reads the same way:

fields = split(line, r"::")
warning: fixed-regex
 --> example.jl:1:22
  |
1 | fields = split(line, r"::")
  |                      ^^^^^ use the plain string `"::"` instead of the regex `r"::"`, whose pattern has no metacharacter
  = help: Drop the `r` prefix and match the substring (safe fix)

After applying the fix:

fields = split(line, "::")

comparison-negation

Flag ! applied to a parenthesized equality test, which Julia spells with a single operator: !(a == b) is a != b, !(a === b) is a !== b, and both read back the other way. The Unicode spellings , , and collapse the same way.

The rewrite is exact rather than merely equivalent-in-practice: Base defines !=(x, y) = !(x == y) and !==(x, y) = !(x === y), so the two spellings agree on every input by construction.

Only the equality family is reported. The orderings are left alone because < and >= are independent methods rather than negations of each other, and they disagree on exactly the inputs a partial order is partial about: !(NaN < 1) is true while NaN >= 1 is false. The broadcast forms a .== b and .!x are containers of values rather than tests, and a comparison chain (a == b == c) has no two-operand rewrite, so none of them is flagged.

The rule reports a safe fix that reuses the comparison’s own source text with the operator swapped, so spacing and any comment between the operands survive. The fix is withheld — the finding still stands — when a comment sits in the deleted !( or ), and when the negation sits somewhere a bare comparison would rebind, as in x + !(a == b).

Negating an equality test spells the inequality:

if !(status == :ok)
    retry()
end
warning: comparison-negation
 --> example.jl:1:4
  |
1 | if !(status == :ok)
  |    ^^^^^^^^^^^^^^^^ write the comparison directly: `!(a == b)` is `a != b`
  = help: Rewrite as `!=` (safe fix)

After applying the fix:

if status != :ok
    retry()
end

The identity comparison negates the same way:

found = !(lookup(key) === nothing)
warning: comparison-negation
 --> example.jl:1:9
  |
1 | found = !(lookup(key) === nothing)
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^ write the comparison directly: `!(a === b)` is `a !== b`
  = help: Rewrite as `!==` (safe fix)

After applying the fix:

found = lookup(key) !== nothing

length-zero

Flag a comparison of length(x) against 0 or 1 that asks whether x is empty. A length is a non-negative integer, so length(x) == 0, length(x) <= 0, and length(x) < 1 all spell isempty(x), while length(x) != 0, length(x) > 0, and length(x) >= 1 spell !isempty(x); each mirrored spelling with the literal first (0 < length(x)) collapses the same way. isempty is the test collections actually implement, and it need not count what it throws away.

The already-deliberate ===, the broadcast .== / .<, and a comparison chain are left alone, as are the bounds that ask a different question (length(x) == 1) or none at all (length(x) >= 0, which is always true). The callee must be confirmed to be Base’s length, so a local shadow, a qualified Base.length, or a file whose imports cannot be resolved reports nothing.

The rule reports a safe fix replacing the comparison with isempty(x) or !isempty(x), reusing the argument’s own source text. The fix is withheld — the finding still stands — when the file’s isempty is not Base’s, or when a comment sits in the rewritten span outside the argument.

Comparing a length against zero is an emptiness test:

if length(xs) == 0
    println("nothing to do")
end
warning: length-zero
 --> example.jl:1:4
  |
1 | if length(xs) == 0
  |    ^^^^^^^^^^^^^^^ test `isempty(xs)` instead of comparing `length(xs)` to `0`
  = help: Rewrite as an `isempty` test (safe fix)

After applying the fix:

if isempty(xs)
    println("nothing to do")
end

The non-emptiness spellings, including the mirrored ones:

while 0 < length(queue)
    pop!(queue)
end
warning: length-zero
 --> example.jl:1:7
  |
1 | while 0 < length(queue)
  |       ^^^^^^^^^^^^^^^^^ test `!isempty(queue)` instead of comparing `length(queue)` to `0`
  = help: Rewrite as an `isempty` test (safe fix)

After applying the fix:

while !isempty(queue)
    pop!(queue)
end

redundant-boolean

Flag a test compared against a boolean literal, or a conditional whose two arms are the literals themselves. x == true and x != false are x; x == false and x != true are !x; c ? true : false is c and c ? false : true is !c. Because == and != are symmetric, the mirrored spellings (true == x) collapse the same way.

This is distinct from constant-condition, which owns the literal-as-test case (if true), where the branch is decided before the code runs.

The two halves do not ship the same fix. The conditional rewrite is reported as a safe fix: ?: requires a Bool test, so on every input that does not throw, c ? true : false hands back that very Bool. The comparison rewrite is reported as an unsafe fix, because == is not identity — it promotes across the numeric tower (1 == true is true), answers missing for missing, and is overloadable — so the two spellings agree only when the operand is already a Bool.

The deliberate === / !==, the broadcast .== / .!=, and a comparison chain are left alone, as are a comparison of two boolean literals (no operand survives it) and a conditional whose arms agree (constant rather than redundant).

The fix reuses the surviving operand’s own source text, parenthesizing it when a bare ! would rebind (a + b == false becomes !(a + b)). It is withheld — the finding still stands — when a comment sits in the replaced span outside that operand.

A conditional over the two literals is its own test:

ready = queue_started(q) ? true : false
warning: redundant-boolean
 --> example.jl:1:9
  |
1 | ready = queue_started(q) ? true : false
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this conditional just yields `queue_started(q)`
  = help: Replace the conditional with its test (safe fix)

After applying the fix:

ready = queue_started(q)

Comparing a test to a boolean literal restates it:

if x.valid == false
    reject(x)
end
warning: redundant-boolean
 --> example.jl:1:4
  |
1 | if x.valid == false
  |    ^^^^^^^^^^^^^^^^ comparing to `false` is redundant: write `!x.valid`
  = help: Drop the comparison to `false` (unsafe fix, requires `--unsafe-fixes`)

string-boundary

Flag occursin(r"^abc", s) and occursin(r"abc$", s), boundary tests written as anchored regex searches. startswith(s, "abc") and endswith(s, "abc") name the boundary they test and need no match engine.

The pattern must be a plain r"..." — no flag suffix, since i and m change what an anchor means — anchored at exactly one end with a non-empty, metacharacter-free remainder; r"^abc$" is an exact match rather than a boundary test. The callee must be confirmed to be Base’s, and the needle is read wherever its spelling puts it: occursin(r"^abc", s), the flipped contains(s, r"^abc"), and the curried contains(r"^abc"), which rewrites to the curried startswith("abc").

The startswith fix is safe: ^ matches at the start of the subject and nowhere else. The endswith fix needs --unsafe-fixes, because PCRE’s $ also matches before a final newline — occursin(r"abc$", "abc\n") is true where endswith("abc\n", "abc") is false. Either fix is withheld — the finding still stands — when the file’s startswith/endswith is not Base’s, or when a comment sits in the rewritten span outside the haystack and the pattern.

A leading anchor is a prefix test:

if occursin(r"^Test", name)
    run(name)
end
warning: string-boundary
 --> example.jl:1:4
  |
1 | if occursin(r"^Test", name)
  |    ^^^^^^^^^^^^^^^^^^^^^^^^ test `startswith(name, "Test")` instead of matching the anchored regex `r"^Test"`
  = help: Test the prefix with `startswith` (safe fix)

After applying the fix:

if startswith(name, "Test")
    run(name)
end

A trailing anchor is a suffix test:

sources = filter(f -> occursin(r"_test$", f), files)
warning: string-boundary
 --> example.jl:1:23
  |
1 | sources = filter(f -> occursin(r"_test$", f), files)
  |                       ^^^^^^^^^^^^^^^^^^^^^^ test `endswith(f, "_test")` instead of matching the anchored regex `r"_test$"`
  = help: Test the suffix with `endswith`, which does not match before a trailing newline (unsafe fix, requires `--unsafe-fixes`)

A curried contains rewrites to a curried predicate:

sources = filter(contains(r"^src"), files)
warning: string-boundary
 --> example.jl:1:18
  |
1 | sources = filter(contains(r"^src"), files)
  |                  ^^^^^^^^^^^^^^^^^ test `startswith("src")` instead of matching the anchored regex `r"^src"`
  = help: Test the prefix with `startswith` (safe fix)

After applying the fix:

sources = filter(startswith("src"), files)

unnecessary-nesting

Flag an if whose entire body is another if, where neither carries an elseif or an else. The two tests are one && test spread over two levels of indentation: if a; if b; body; end; end is if a && b; body; end.

The two spellings agree on every input. if demands a Bool and && hands its right operand back untouched, so the merged test stops on a false a exactly where the nested form skips the inner if, and if opens no scope in Julia, so merging the blocks rebinds nothing.

An alternative on either if breaks that agreement and is not reported: with an outer else, the case where a holds and b does not runs nothing before the merge and the else branch after it. A body holding anything besides the inner if is left alone for the same reason — the outer test guards more than the inner one.

The fix splices the two tests and the inner block verbatim, parenthesizing a test that binds looser than && (if a || c nested in if b becomes (a || c) && b). It is withheld — the finding still stands — when a comment sits in the discarded headers. The inner body keeps its indentation, which the formatter settles.

An if guarding nothing but another if is one test:

if isopen(io)
    if !eof(io)
        read(io)
    end
end
warning: unnecessary-nesting
 --> example.jl:1:1
  |
1 | if isopen(io)
  | ^^^^^^^^^^^^^ this `if` only guards another `if`: write `if isopen(io) && !eof(io)`
  = help: Merge the nested `if` into its parent with `&&` (safe fix)

After applying the fix:

if isopen(io) && !eof(io)
        read(io)
    end

misnamed-suppression

Flag a # fatou-ignore directive that names a rule the linter does not ship. Suppression matches a rule by its exact ID, so a misspelled, renamed, or foreign ID silences nothing while reading as though it does. When exactly one shipped rule is a near match, the finding carries a safe fix that rewrites the ID and leaves the reason untouched.

A directive naming a rule that does not exist:

# fatou-ignore unused-bindings: set up by the C library
function f()
    handle = open_device()
    1
end
warning: misnamed-suppression
 --> example.jl:1:16
  |
1 | # fatou-ignore unused-bindings: set up by the C library
  |                ^^^^^^^^^^^^^^^ `unused-bindings` is not a fatou rule; this suppresses nothing
  = help: Replace with `unused-binding` (safe fix)

After applying the fix:

# fatou-ignore unused-binding: set up by the C library
function f()
    handle = open_device()
    1
end

blanket-suppression

Flag a # fatou-ignore directive that names no rule. A bare # fatou-ignore-file silences every rule in the file, including rules added later; a bare # fatou-ignore silences nothing at all, because the node-level form requires a rule ID. Name the rule the directive is meant to suppress. There is no fix: nothing in the source says which rule that is.

A file-wide directive naming no rule turns the linter off for the whole file:

# fatou-ignore-file: generated code

struct Point
    x::Float64
end
warning: blanket-suppression
 --> example.jl:1:1
  |
1 | # fatou-ignore-file: generated code
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ suppression names no rule; it silences every rule in this file
  = help: name the rule: `# fatou-ignore <rule>: <reason>`

unexplained-suppression

Flag a # fatou-ignore directive that states no reason. The : <reason> part is optional, so this rule is off by default; select it in a project that wants every suppression to record why the finding was accepted. There is no fix: the reason is the part only the author can supply.

A suppression with nothing to say for itself:

# fatou-ignore unused-binding
function f()
    handle = open_device()
    1
end
warning: unexplained-suppression
 --> example.jl:1:1
  |
1 | # fatou-ignore unused-binding
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ suppression states no reason
  = help: add one: `# fatou-ignore <rule>: <reason>`

outdated-suppression

Flag a # fatou-ignore directive that suppressed nothing: either the rule it names ran and reported nothing it covers, or the directive has no code after it to apply to. A rule that this run did not enable, and any rule in a file whose names cannot be resolved (one that evals, or usings a module the run did not harvest), is dormant rather than stale and is never reported. The safe fix deletes the directive.

A directive at the end of a file, with nothing left to suppress:

function f(x)
    x + 1
end
# fatou-ignore unused-binding: the scratch value below
warning: outdated-suppression
 --> example.jl:4:1
  |
4 | # fatou-ignore unused-binding: the scratch value below
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ suppression has nothing after it to apply to
  = help: delete the directive
  = help: Delete the suppression (safe fix)

After applying the fix:

function f(x)
    x + 1
end

Contributing

Thanks for your interest in contributing to Fatou! Bug reports, fixes, new lint rules, parser and formatter work, and documentation improvements are all welcome. For larger changes, please open an issue first so we can discuss the approach.

Development environment

The repository ships a devenv (Nix) environment (devenv.nix) that provides the pinned Rust toolchain, a Julia interpreter, mdbook, task, and the auxiliary cargo tools. Enter it with devenv shell. Julia packages (JuliaSyntax.jl, the parser oracle, plus the formatter-comparison tools) are managed by Julia’s own package manager via the repo’s pinned Project.toml/Manifest.toml, not by Nix.

Nix is not required, though. A stable Rust toolchain (see rust-toolchain.toml) is enough to build and run the full test suite:

cargo build --workspace
cargo test --workspace

Quality gates

CI is the source of truth for quality gates. Before opening a pull request, make sure these pass locally:

cargo test --workspace                                                 # all tests
cargo clippy --workspace --all-targets --all-features -- -D warnings   # warnings are errors
cargo fmt --all -- --check                                             # rustfmt-clean

Or via task: task test, task lint, task format-check. CI additionally runs cargo-audit and cargo-deny, and builds and tests on Linux, macOS, and Windows.

The fatou-parser reparse benchmark sits behind a bench feature, so a plain --all-targets build never pulls in criterion, whose alloca dependency wants a C toolchain. Run it with task bench-reparse. The clippy line above passes --all-features, so the bench is still linted.

Snapshot tests use insta: review changed snapshots with cargo insta review and accept them with cargo insta accept. Logging in tests honors RUST_LOG (e.g., RUST_LOG=debug cargo test).

Test-driven development

Fatou is developed test-first: write a failing test, watch it fail, then make it pass. For a bug, add a failing fixture or snapshot that reproduces it before the fix.

  • Parser fixtures live in crates/fatou-parser/tests/fixtures/parser/<case>/ with an input.jl; the harness snapshots the CST and diagnostics and asserts losslessness (reconstruct(text) == text).
  • Formatter fixtures live in crates/fatou-formatter/tests/fixtures/formatter/<case>/ with an input.jl and a hand-authored expected.jl. Fatou owns its formatting style; expected.jl is written by hand, never captured from a formatter. The suite also checks idempotence (format(format(x)) == format(x)) and clean reparse of the output.
  • Parser parity is measured against JuliaSyntax.jl via a differential oracle (crates/fatou-parser/tests/juliasyntax_oracle.rs) that needs no Julia at test time.

Architecture and roadmap

  • AGENTS.md documents the architecture, the design tenets (deterministic full-reflow formatting, first-class incremental parsing, losslessness), and the project conventions in detail.
  • TODO.md is the live roadmap and records known issues and follow-ups. When in doubt about scope or priority, check there.

Documentation

The documentation site (fatou.dev) is an mdBook under docs/. Preview it locally with:

task docs-preview        # mdbook serve docs --open

Commits and versioning

  • Use Conventional Commits (type(scope): subject) in the imperative mood, with subject lines ideally under 60 characters.
  • Fatou follows semantic versioning. Releases and CHANGELOG.md are generated by tooling—never edit the changelog by hand.

License

By contributing, you agree that your contributions are licensed under the MIT License.