Fatou
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: complete installation and first-run walkthrough.
- Editor Setup: connect the language server to your editor.
- Configuration: set up
fatou.tomlfor your project. - Configuration Reference: every
fatou.tomlkey. - CLI Reference: every command and option.
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.
dprint
To format Julia files with dprint, add the Fatou dprint plugin. It bundles the formatter and requires neither Julia nor the Fatou CLI:
dprint config add jolars/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
To make formatting fail unless Fatou can verify that the parsed program and
comments are preserved, opt in with --safe. A multi-file safe run checks the
whole batch before writing any file:
fatou format --safe file.jl
fatou format --safe --check src
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.
Renaming a package entry from src/MyPkg.jl to src/NewPkg.jl also updates
name in its Project.toml or JuliaProject.toml and matching top-level
module MyPkg declarations. The project edit uses unsaved buffer text and
preserves the UUID, comments, and other fields. The destination must remain in
the package’s src/ directory and have a valid Julia identifier as its name.
Imports and references to the package name elsewhere require separate updates.
Static docstrings are decoded before the server presents them, so unsaved local
documentation and harvested package documentation render the same way. Their
Markdown headings appear in the document outline and fold as sections; external
links, fragments, footnotes, and explicit Documenter @ref targets navigate in
place. An explicit @ref also completes Julia symbols and documentation
anchors. Fences that declare Julia code—including julia, julia-repl,
jldoctest, and Julia-bearing Documenter directives—receive completion, hover,
signature help, go-to-definition, selection ranges, nested folding, and semantic
highlighting. Dynamic or custom @doc metadata remains opaque.
Prerequisites
Install Fatou (see Getting Started) and make sure the
fatou binary is on your PATH, or note its absolute path. The VS Code family
is an exception, since the extension bundles a binary, and so is Zed, whose
extension downloads one when it finds nothing on the 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.
Shared User Config
To use a shared fatou.toml outside your projects, add this to your user
settings.json:
{
"fatou.serverEnv": {
"FATOU_CONFIG": "P:/Softwares/fatou.toml"
}
}
Run Fatou: Restart Server after changing this setting or editing the shared file. Project configs take precedence, and the files are never merged. Use an absolute path; an unset or empty value uses the normal user config location. See User-Wide Defaults for the full resolution order and error behavior.
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.
Zed
Fatou attaches to Zed’s Julia language, which the Julia
extension provides. Install
that one first, then install Fatou from the extensions view
(zed: extensions in the command palette).
Zed uses the fatou on your PATH when there is one, and otherwise downloads
the release binary matching your platform. Keeping Fatou on the PATH is the
better option on distributions that cannot run the generic release build, NixOS
above all.
To run Fatou as the only Julia language server and use it for formatting, add
this to settings.json:
{
"languages": {
"Julia": {
"language_servers": ["fatou-language-server"],
"formatter": {
"language_server": {
"name": "fatou-language-server"
}
}
}
}
}
To format on save, also add "format_on_save": "on" under languages.Julia.
Settings go under the server’s id, using the schema described in Configuration below:
{
"lsp": {
"fatou-language-server": {
"settings": {
"format": { "line-width": 100 },
"lint": { "ignore": ["unused-binding"] }
}
}
}
}
A fatou.toml in the project shadows these entirely, so prefer the file when
the whole team should share the behavior.
To point Zed at a particular binary, set binary.path:
{
"lsp": {
"fatou-language-server": {
"binary": { "path": "/opt/fatou/bin/fatou", "arguments": ["lsp"] }
}
}
}
arguments replaces the command line rather than extending it, so it has to
keep naming a subcommand that speaks LSP.
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,
})
ALE (Vim and Neovim)
ALE includes a Fatou linter and fixer
for Julia. Install ALE and make sure fatou is on your PATH, then add to your
.vimrc or init.vim:
let g:ale_linters = {'julia': ['fatou']}
let g:ale_fixers = {'julia': ['fatou']}
If you already configure these dictionaries for other languages, add the julia
entries to them.
The linter starts fatou lsp to provide diagnostics and language features. The
fixer runs fatou format; use :ALEFix to format the current buffer. To run
configured fixers automatically on save, add:
let g:ale_fix_on_save = 1
To select a particular Fatou binary, set:
let g:ale_julia_fatou_executable = '/opt/fatou/bin/fatou'
This applies to both the linter and fixer. Configure project behavior through
fatou.toml. See :help ale-julia-fatou for ALE’s
Fatou-specific options.
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.
Dependency updates follow the update/upgrade distinction used by
crates.nvim, adapted to Julia’s Pkg
compatibility grammar. On a
dependency declaration or its existing [compat] entry, Fatou offers:
- Update to adjust the bound to the newest stable release it already allows.
- Upgrade to allow the newest stable release outside the current bound.
Actions appear only when they change the bound. Edits retain version precision,
operators, quotes, surrounding comments, and whitespace: given releases 1.9.3
and 2.3.4, a bound of "1.2" offers Update to "1.9" and Upgrade to "2.3".
A bound of "1" offers only Upgrade to "2". Julia’s comma-separated ranges
form a union; updates change the matching arm, and upgrades outside a union
append a new arm so earlier support ranges survive.
With LSP inlay hints enabled, each [compat] value shows the newest matching
release and any available upgrade, such as v1.9.3 → v2.3.4. The matching
release stays visible when the bound is current. These registry hints complement
the resolved versions beside [deps] UUIDs; their tooltips explain the
distinction.
This works in Project.toml and JuliaProject.toml, including [weakdeps],
[extras], and unsaved edits. Fatou reads installed directory and compressed
registries in the background, caching compressed results until the archive
changes. It skips yanked releases, prereleases, and dependencies overridden in
[sources]. Missing or malformed registry data produces fewer suggestions.
Suggestions use local registry data; unlike crates.nvim’s crates.io requests,
Fatou does not fetch published releases over the network. Update your registries
through Pkg when you need fresher suggestions. Applying an action edits only
Project.toml: it does not resolve the environment, update Manifest.toml,
download packages, invoke Julia, or check compatibility with the project’s Julia
version and other dependencies. Formatting remains unavailable for TOML files.
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
"\ndoes not offer\nabla. A second character brings the sequences back, so\nuand\alphastill 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" }tovim.lsp.buf.format(); in Helix, stripformatfrom the other server as shown above; in VS Code, seteditor.defaultFormatterfor[julia]. undefined-nameandcall-arityneed project context, so the language server enables them for workspace member files even though the command line leaves them opt-in. Anignoreentry still turns them off.
Integrations
dprint
Format Julia files alongside other languages with dprint
and dprint-plugin-fatou. The
plugin bundles Fatou’s formatter as WebAssembly, so you can format .jl files
without installing Julia or the Fatou CLI.
Setup
Install dprint. If your project does not yet have
a dprint.json, create one with dprint init. Then add Fatou:
dprint config add jolars/fatou
The command adds a versioned plugin URL to the plugins array in your dprint
configuration. Commit that configuration so contributors and CI use the same
plugin version.
Format the project’s files in place:
dprint fmt
For CI, check formatting without writing changes:
dprint check
The check exits nonzero when files need formatting. Both commands run all
configured plugins; Fatou handles .jl files. See the dprint CLI
reference for file selection and other options.
Configuration
Add a fatou object to your dprint.json, keeping the plugins array created
above. For example, merge in these settings:
{
"fatou": {
"lineWidth": 92,
"indentWidth": 4,
"lineEnding": "auto"
}
}
Every setting is optional. Values in fatou override dprint’s global settings:
| Key | Values | Default when omitted |
|---|---|---|
lineWidth | Nonnegative integer | Global lineWidth, otherwise 92 |
indentWidth | Nonnegative integer | Global indentWidth, otherwise 4 |
lineEnding | auto, lf, crlf, native | Global newLineKind, otherwise auto |
lineWidth is the target maximum line width, and indentWidth is the number of
spaces per indentation level. Fatou always uses spaces; dprint’s global
useTabs setting has no effect.
lineEnding: "auto" follows each file’s first line ending, falling back to LF
when the file has none. Use lf or crlf to force a particular style. The
plugin also accepts native, which resolves to LF in its WebAssembly build,
including when dprint runs on Windows.
The plugin reads its settings from dprint’s configuration and does not load
fatou.toml. If you also use the Fatou CLI or language server, keep the
corresponding [format] settings in sync: line-width, indent-width, and
line-ending. See Fatou’s formatting
configuration.
dprint controls file discovery through its top-level includes and excludes
settings and .gitignore. Put exclusions for this workflow in dprint.json;
the plugin does not apply exclusions from fatou.toml. See dprint’s
configuration guide.
Updates
The plugin is released independently of the Fatou CLI. To update the plugins listed in your dprint configuration, run:
dprint config update
Review and commit the configuration changes. Updating the Fatou CLI does not
update the formatter bundled in the plugin. To get matching output from both,
use releases with the same fatou-formatter version and equivalent settings.
The plugin provides formatting. For linting and the full language server, use the Fatou CLI and editor integration.
pre-commit
Run Fatou on staged Julia files with
fatou-pre-commit. The hooks
install the Fatou binary from PyPI in an environment managed by pre-commit, so
you do not need to install Fatou, Rust, or Julia separately.
Install pre-commit, then add this entry
to your .pre-commit-config.yaml:
repos:
- repo: https://github.com/jolars/fatou-pre-commit
rev: v0.18.0
hooks:
- id: fatou-lint
- id: fatou-format
Install the Git hook and run it once over all tracked files:
pre-commit install
pre-commit run --all-files
On subsequent commits, the hooks run on staged .jl files. fatou-lint reports
lint findings, and fatou-format formats files in place. When a hook changes a
file, review and stage the changes, then commit again.
To apply safe lint fixes, add --fix to the lint hook. Keep it before the
formatter so formatting runs after the rewrites:
hooks:
- id: fatou-lint
args: [--fix]
- id: fatou-format
To check formatting without changing files, add args: [--check] to
fatou-format.
Both hooks pass --force-exclude, so exclude and extend-exclude in your
fatou.toml apply even though pre-commit supplies filenames explicitly. See
excluding files for details.
The rev selects the Fatou version: v0.18.0 installs Fatou 0.18.0. Run
pre-commit autoupdate to update configured hook revisions, then review and
commit the changes to .pre-commit-config.yaml.
GitHub Actions
Use fatou-action to check formatting
and lint Julia code in CI. It installs and caches a prebuilt Fatou binary on
GitHub-hosted Linux, macOS, and Windows runners.
Create .github/workflows/fatou.yml:
name: Fatou
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
fatou:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: jolars/fatou-action@v1
with:
version: v0.18.0
By default, the action runs fatou format --check and fatou lint from the
repository root. Formatting differences or lint findings fail the check. It uses
the same configuration
discovery as the CLI.
The action’s @v1 tag selects the action version; the version input selects
the Fatou CLI version. Keep the latter aligned with your pre-commit revision and
local installation for consistent results. Omitting version selects the latest
Fatou release with a binary for the runner.
Set inputs under with to customize the check:
| Input | Purpose | Default |
|---|---|---|
path | File or directory to check | . |
format | Run the formatting check | "true" |
lint | Run the lint check | "true" |
config | Load an explicit fatou.toml path | Automatic discovery |
quiet | List files needing formatting without printing a diff | "false" |
For example, to check only formatting under src/:
- uses: jolars/fatou-action@v1
with:
version: v0.18.0
path: src/
lint: "false"
Set format: "false" to run only linting. See the action
reference for all inputs and
outputs.
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.
The dprint plugin uses the fatou object in
dprint.json for its settings and does not read fatou.toml.
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.
Editor Support
Fatou publishes a JSON Schema for fatou.toml, so
editors with TOML support can offer key and value completion, inline
documentation, and validation while you edit the configuration.
Schema URL : https://fatou.dev/fatou.schema.json
The schema is generated from the same configuration types Fatou uses at runtime and checked for drift in the test suite.
VS Code and Even Better TOML
With the Even Better
TOML
extension installed, add this association to your user or workspace
settings.json:
{
"evenBetterToml.schema.associations": {
"^(.*/)?fatou\\.toml$": "https://fatou.dev/fatou.schema.json"
}
}
Inline Schema Directive
Editors that recognize Taplo’s schema directive can select the schema from the configuration file itself:
#:schema https://fatou.dev/fatou.schema.json
Other editors and language servers, including Helix, Neovim with taplo-lsp,
Zed, and IntelliJ, can use the same URL through their TOML schema association
settings.
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 non-empty FATOU_CONFIG
shadows the global config entirely. An unset or empty value uses the normal user
config location. Relative paths resolve from the process’s working directory;
use an absolute path in editor settings.
In VS Code, add this to your user settings.json, then run Fatou: Restart
Server:
{
"fatou.serverEnv": {
"FATOU_CONFIG": "P:/Softwares/fatou.toml"
}
}
A missing or malformed config file makes the CLI fail. The language server logs a configuration warning in the Fatou output channel and uses editor settings or built-in defaults. Fix the file and restart the server to apply it.
FATOU_CONFIG and global configs 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:
--config <PATH>, which loads that file and skips discovery.--no-config, which ignores every file and uses the built-in defaults.- 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.gitancestor is walked to the filesystem root. $FATOU_CONFIG, when set and non-empty. A missing or malformed file here is a configuration error.- The global user config: the first existing file among
$XDG_CONFIG_HOME/fatou/fatou.toml, when that variable is set~/.config/fatou/fatou.toml- the platform config directory, on macOS
~/Library/Application Support/fatou/fatou.toml
- The built-in defaults.
Sources are never merged: exactly one file is used.
Checking standalone scripts
Declare the files you run as independent programs to check undefined names across their static includes:
[project]
entry-points = ["scripts/main.jl"]
The CLI and language server enable undefined-name by default for each entry
point and its included files. An explicit [lint] select replaces those
defaults; ignore = ["undefined-name"] disables the rule. Files outside these
programs keep their usual defaults.
No Julia package is required. Paths are relative to the configuration file. If
main.jl includes settings.jl and worker.jl, functions in worker.jl can
use globals defined in settings.jl. Misspellings still produce warnings.
Separate entry points do not share globals, even when they include the same
helper file. Findings identify the entry point and module where the name is
undefined.
Run fatou lint . to check the discovered Julia files. Running
fatou lint scripts/main.jl reports findings only in that file; its includes
still supply resolution context. The language server also uses unsaved changes
in included files.
Fatou never executes Julia to analyze a script. Dynamic includes, eval,
unresolved whole-module imports, and missing or broken dependencies can prevent
it from proving a name undefined. Those entry points remain conservative; see
the project configuration reference for
the current limits.
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_widthandindent_widthare still accepted but print a warning. Use the kebab-caseline-widthandindent-widthinstead; 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. Use extend-select to enable additional rules while
keeping the defaults. For example, to report undefined names in self-contained
files without configuring entry points or losing unused-variable warnings:
[lint]
extend-select = ["undefined-name"]
select, when set, replaces the default rule set. extend-select then adds to
that selection, and ignore turns individual rules off, including rules named
in extend-select:
[lint]
select = ["unused-binding", "unused-argument"]
extend-select = ["undefined-name"]
ignore = ["unused-argument"]
On the CLI, this configuration runs unused-binding and undefined-name. An
empty select = [] disables the defaults, leaving only rules in extend-select
that are not ignored. For workspace package files, the language server also
enables rules that need project resolution, unless they are ignored.
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
| Fatou | LanguageServer.jl | JETLS.jl | |
|---|---|---|---|
| Requires a Julia install | No | Yes | Yes |
| Analysis model | Syntax and name/scope | Static analysis + runtime symbol index | Type inference (JET.jl) |
| Type-aware diagnostics | No | Best-effort | Yes |
| Formatter | Built in | Delegates to JuliaFormatter or Runic | Delegates to Runic or JuliaFormatter |
| Linter | Built in | Built in (StaticLint) | Built in (lowering + type checks) |
| First-run cost | None | Precompilation and package indexing | Precompilation |
| Maturity | Young | Mature, de-facto default | Experimental |
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
| Fatou | Runic.jl | JuliaFormatter.jl | |
|---|---|---|---|
| Requires a Julia install | No | Yes | Yes |
| Configuration | Width, indent, line ending | None | ~38 options, .JuliaFormatter.toml |
| Named styles | One | One | Default, Blue, YAS, SciML, Minimal |
| Line-width limit | Yes (default 92) | None | Yes (margin, default 92) |
| Reflow model | Always full reflow | Preserves the author’s breaks | Full reflow, unless join_lines_based_on_source |
| Output depends on input layout | Never | Yes | Only 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
| Fatou | LanguageServer.jl (StaticLint) | JETLS.jl | |
|---|---|---|---|
| Requires a Julia install | No | Yes | Yes |
| Type-based diagnostics | No | Limited | Yes |
| Cross-package symbol knowledge | Base/Core snapshot + workspace | Indexes installed dependencies | Loads code |
| Rule model | Named rules, select/ignore, severity | julia.lint.* toggles | Diagnostic stages |
| Autofix | Safe and unsafe fixes | Some quick fixes | Some code actions |
| Standalone CLI | fatou lint | No, runs inside the server | jetls 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 where that difference shows up: how fast it formats, against Runic and JuliaFormatter, and how quickly it responds 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
formatter throughput and task bench-lsp for language-server speed and 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 -> Stringformatter (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), andabstractdataframe.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 usesfatou::formatter::check_paths(glob/directory discovery plus rayon-parallel formatting, read-only); JuliaFormatter usesformat(dir; overwrite = false)(recursive, thread-parallel, read-only). Runic is excluded from these scenarios by design: it has no in-process directory API (itsformat_fileis 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.jlv1.8.2(946c72a) - Versions: Fatou
0.20.0, Runic1.9.0, JuliaFormatter2.12.6, Julia1.13.0 - 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
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)
| Tool | Files | Bytes | Median (ms) | Throughput (MB/s) | Relative |
|---|---|---|---|---|---|
| Fatou | 1 | 41,937 | 4.1 | 10.26 | baseline |
| Runic | 1 | 41,937 | 15.8 | 2.65 | 3.88x |
| JuliaFormatter | 1 | 41,937 | 7.8 | 5.35 | 1.92x |
kinds.jl (JuliaSyntax/src/kinds.jl)
| Tool | Files | Bytes | Median (ms) | Throughput (MB/s) | Relative |
|---|---|---|---|---|---|
| Fatou | 1 | 24,442 | 1.5 | 15.79 | baseline |
| Runic | 1 | 24,442 | 12.6 | 1.94 | 8.13x |
| JuliaFormatter | 1 | 24,442 | 4.1 | 5.97 | 2.64x |
abstractdataframe.jl (DataFrames/src/abstractdataframe/abstractdataframe.jl)
| Tool | Files | Bytes | Median (ms) | Throughput (MB/s) | Relative |
|---|---|---|---|---|---|
| Fatou | 1 | 100,388 | 5.6 | 18.04 | baseline |
| Runic | 1 | 100,388 | 31.5 | 3.19 | 5.66x |
| JuliaFormatter | 1 | 100,388 | 13.7 | 7.35 | 2.46x |
Projects
Runic is absent because it has no in-process directory API. Hover a dot for the exact figures.Data table
JuliaSyntax (JuliaSyntax/src)
| Tool | Files | Bytes | Median (ms) | Throughput (MB/s) | Relative |
|---|---|---|---|---|---|
| Fatou | 15 | 332,783 | 10.6 | 31.37 | baseline |
| JuliaFormatter | 15 | 332,783 | 27.3 | 12.20 | 2.57x |
DataFrames (DataFrames/src)
| Tool | Files | Bytes | Median (ms) | Throughput (MB/s) | Relative |
|---|---|---|---|---|---|
| Fatou | 36 | 870,581 | 9.9 | 87.52 | baseline |
| JuliaFormatter | 36 | 870,581 | 34.0 | 25.59 | 3.42x |
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.
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
| Tool | Median time | Throughput (MB/s) | vs Fatou |
|---|---|---|---|
| Fatou | 5.9 ms | 7.10 | baseline |
| Runic | 189.1 ms | 0.22 | 32.01x |
| JuliaFormatter | 287.1 ms | 0.15 | 48.60x |
Language servers
The comparison here is not against the formatters but against 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.
The speed results split an editor session into cold readiness and warm requests:
- Initialize is the
initializerequest round trip from a fresh process. - Workspace ready runs from process start to the beginning of the final quiet window after background indexing.
- Open files ready runs from the burst of
didOpennotifications through diagnostics and the beginning of the next quiet window. - Document symbols and hover are warm stdio round trips after the server has settled. They span three open files; hover targets the first source-defined symbol in each file.
- Definition, references, and rename use
_namesat a pinned call site inselection.jl. Its definitions live elsewhere, and its references span the project. References include declarations; rename constructs theWorkspaceEditbut does not apply it.
Each target gets two unmeasured warmup rounds, then 20 measured rounds. The warm-request plot shows the median and p95. Its tooltips and collapsed detail table also report the median serialized result size and how many symbols, locations, or edits each server returns across how many files. Those counts matter: two servers are not doing comparable work when one returns fewer destinations or edits.
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 memory plot shows. 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
- Navigation target:
_namesinsrc/abstractdataframe/selection.jlat line 383 - Versions: Fatou
0.18.0, LanguageServer.jl5.0.0, JETLS7e01ca58(2026-08-08), Julia1.12.6 - Host: AMD Ryzen 9 7900 12-Core Processor (Linux x86_64, 61 GB RAM)
- Machine:
terra
Speed
Readiness and warm requests get separate plots, in seconds and milliseconds respectively. Both use a logarithmic time axis so the fastest and slowest responses remain visible; farther left is faster. Server colors stay the same across these plots and the memory plot below. Expand the detail tables for the underlying measurements and returned work.
Readiness
Readiness data
| Server | Initialize | Workspace ready | Open files ready |
|---|---|---|---|
| Fatou | 1 ms | 152 ms | 54 ms |
| LanguageServer.jl | 4.14 s | 27.70 s | 3.30 s |
| JETLS | 5.88 s | 9.88 s | 13.54 s |
Warm requests
Request timings and returned work
| Server | Request | Median | p95 | Returned work |
|---|---|---|---|---|
| Fatou | Document symbols | 1.29 ms | 1.68 ms | 54–123 symbols (median 70), 24 KiB |
| Fatou | Hover | 0.06 ms | 0.13 ms | 1 result, 162 B |
| Fatou | Go to definition | 0.28 ms | 0.40 ms | 5 locations in 4 files, 879 B |
| Fatou | Find references | 0.54 ms | 0.68 ms | 135 locations in 17 files, 23 KiB |
| Fatou | Rename | 0.61 ms | 0.69 ms | 135 edits in 17 files, 17 KiB |
| LanguageServer.jl | Document symbols | 5.42 ms | 7.96 ms | 372–626 symbols (median 519), 117 KiB |
| LanguageServer.jl | Hover | 0.09 ms | 0.35 ms | 1 result, 709 B |
| LanguageServer.jl | Go to definition | 1.30 ms | 1.38 ms | 5 locations in 4 files, 884 B |
| LanguageServer.jl | Find references | 54.30 ms | 84.15 ms | 135 locations in 17 files, 23 KiB |
| LanguageServer.jl | Rename | 54.57 ms | 59.51 ms | 135 edits in 17 files, 18 KiB |
| JETLS | Document symbols | 2.08 ms | 5.84 ms | 348–578 symbols (median 477), 131 KiB |
| JETLS | Hover | 1.28 ms | 4.23 ms | 1 result, 1 KiB |
| JETLS | Go to definition | 0.62 ms | 0.87 ms | 2 locations in 1 file, 761 B |
| JETLS | Find references | 1.07 ms | 4.38 ms | 135 locations in 17 files, 23 KiB |
| JETLS | Rename | 22.36 ms | 41.46 ms | 135 edits in 17 files, 18 KiB |
Warm requests show median / p95. Each target ran 20 measured rounds after 2 warmup rounds; symbols and hover span 3 files, while definition, references, and rename use _names in src/abstractdataframe/selection.jl.
Readiness is measured once per server because starting and indexing the Julia servers dominates a run. The warm requests have enough repetitions to expose their distribution, but they intentionally measure cached editor queries—not the first analysis of a newly opened file.
Memory
Memory data
| Server | Baseline | Settled | Peak | vs Fatou | Settled after | Doing |
|---|---|---|---|---|---|---|
| Fatou | 79 MB | 84 MB | 93 MB | baseline | 9 s | static analysis, no Julia runtime |
| LanguageServer.jl | 965 MB | 1104 MB | 1567 MB | 13.1x | 41 s | Julia runtime plus a SymbolServer pass over the environment |
| JETLS | 792 MB | 1998 MB | 2111 MB | 23.7x | 33 s | Julia runtime plus type inference through JET |
Two caveats worth carrying away from that figure. 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.
| Command | Over | Input | Peak RSS | Wall |
|---|---|---|---|---|
fatou format --check | src tree | 850 KiB | 39.2 MB | 20 ms |
fatou lint | src tree | 850 KiB | 26.6 MB | 30 ms |
fatou format --check | one file | 98 KiB | 10.3 MB | 10 ms |
fatou lint | one file | 98 KiB | 9.3 MB | 10 ms |
fatou parse | one file | 98 KiB | 7.7 MB | 10 ms |
fatou --version | process floor | - | 4.6 MB | 0 ms |
true | harness floor | - | 2.2 MB | 0 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. A
machine-readable JSON Schema is also
available for editor completion and validation.
Set FATOU_CONFIG to a custom user config path. Fatou uses it when no project
fatou.toml is found, before checking the platform’s user config directory. An
unset or empty value uses the normal user config location. Relative paths
resolve from the process’s working directory. In VS Code, set it through
fatou.serverEnv and run Fatou: Restart Server; see User-Wide
Defaults for an example.
Top-level keys
| Key | Type | Default | Description |
|---|---|---|---|
exclude | array of strings | [] | Patterns to exclude from file discovery. |
extend-exclude | array 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"]
[project]
| Key | Type | Default | Description |
|---|---|---|---|
entry-points | array of strings | [] | Script files to analyze as independent programs. |
Paths are literal files, without glob expansion, relative to the containing
configuration file. This also applies to --config, FATOU_CONFIG, and global
configuration files. Duplicate normalized paths are ignored. Configure entry
points in a file; editor-pushed settings do not establish a project root.
[project]
entry-points = ["scripts/main.jl"]
The CLI and language server enable undefined-name by default for these
programs. An explicit [lint] select replaces the defaults, and
ignore = ["undefined-name"] disables the rule. Files outside the configured
programs keep their usual defaults.
Entry points provide resolution context without expanding the files selected for
linting. Fatou follows static include("path") calls and resolves global
bindings in their host modules. Each entry point and host module is analyzed
separately. Findings identify the contexts in which a name is undefined.
Includes may supply names even when their files are excluded from diagnostic
reporting.
A missing or unreadable entry point fails CLI linting. The language server logs
the failure and continues checking other entries. Dynamic includes, eval,
unresolved whole-module usings, include cycles, and unreadable or unparseable
dependencies make an entry’s undefined-name analysis incomplete and suppress its
findings. Relative whole-module usings remain unresolved. Other independent
entries still run.
The language server uses unsaved buffers and refreshes dependent diagnostics
after edits, closes, and watched file changes. Script contexts currently support
diagnostics; completion and navigation do not use them. Cross-file method-table
checks (call-arity and function-has-no-methods) remain disabled for these
contexts because the script model records names, not method tables.
[format]
| Key | Type | Default | Description |
|---|---|---|---|
line-width | integer | 92 | The width the formatter tries to keep lines within. |
indent-width | integer | 4 | Number of spaces per indentation level. |
line-ending | string | "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 tolfwhen the file has none.lf: always\n(Unix).crlf: always\r\n(Windows).native:\non Unix,\r\non Windows.
[format]
line-width = 92
indent-width = 4
line-ending = "auto"
Deprecation: the snake_case keys
line_widthandindent_widthare still accepted but print a warning. Use the kebab-caseline-widthandindent-widthinstead; the snake_case forms will be removed in a future release.
[lint]
| Key | Type | Default | Description |
|---|---|---|---|
select | array of strings | unset | Base rule IDs to enable, replacing the defaults when set. |
extend-select | array of strings | [] | Additional rule IDs to enable alongside select or defaults. |
ignore | array of strings | [] | Rule IDs to disable, including those in extend-select. |
severity | table | {} | Per-rule severity overrides. |
rules | table | {} | Per-rule option tables. |
See the rule reference for the available rule IDs. An unrecognized
ID in select, extend-select, ignore, or severity is a warning, not an
error.
Fatou starts with the defaults, or select when set, adds extend-select, and
then removes rules in ignore. Repeated IDs do not run a rule more than once.
An empty select = [] disables the defaults; extend-select can still add
rules. For workspace package files, the language server also enables rules that
need project resolution, unless they are ignored.
[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]
extend-select = ["undefined-name"]
ignore = ["unused-binding"]
[lint.severity]
undefined-name = "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, extend-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.
| Key | Type | Default | Description |
|---|---|---|---|
functions | table | the built-in set | Replaces the built-in deny-list. |
extend-functions | table | {} | 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:
autoPossible values:
auto: Colorize when writing to a terminal andNO_COLORis unsetalways: Always colorizenever: Never colorize
-q,--quiet-
Suppress non-essential output (errors are still shown). Under
format --checkthis drops the per-file diff, leaving the list of files that would be reformatted and the summary; underparseit 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:
cstPossible values:
cst: The losslessrowanconcrete syntax treesexpr: 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
--safe- Verify that formatting preserves the parsed program and comments. Refuses malformed or unverifiable input; path batches are fully checked before any file is written
--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.10or1.6 - 1.11); overrides[julia] versionand the project’sProject.toml[compat] --output <OUTPUT>-
Output format
Default value:
prettyPossible 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 same-module global from included files, not a whole-module using’s export, and not a Base/Core name. For standalone programs, declare [project] entry-points = ["main.jl"] to enable this rule by default, follow static includes, and resolve ordinary global assignments. An explicit [lint] select replaces the defaults, and ignore = ["undefined-name"] disables the rule. Each entry point and host module is checked independently; findings name the failing contexts. Dynamic includes, eval, unresolved whole-module usings, and broken include closures suppress findings for that entry. Value reads inside macro calls and quoted code remain exempt. Without entry points, opt in with [lint] extend-select = ["undefined-name"]; standalone files containing includes are skipped. The language server also enables this rule for workspace package files.
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
invalid-docstring-code
Flag malformed Julia inside a static docstring’s explicit Julia-bearing fence. Ordinary julia and Documenter directive bodies are parsed directly; julia-repl and jldoctest transcripts parse only their Julia inputs, not expected output. Plain and foreign-language fences, opaque docstrings, and docstrings whose Markdown does not parse cleanly are left alone.
The declared Julia input is incomplete:
"""
~~~julia
f(
~~~
"""
f() = 1
warning: invalid-docstring-code
--> example.jl:3:2
|
3 | f(
| ^ invalid Julia in documentation fence: unterminated argument list
docstring-argument-mismatch
Flag a conventional - `name`: ... entry in a static docstring’s # Arguments section when name is not a positional or keyword parameter of the attached function or macro. Missing entries are not reported: documentation coverage and less structured argument prose remain opt-in policy rather than a default correctness check.
The signature says radius, but the argument entry retained a typo:
"""
# Arguments
- `raduis`: The radius.
"""
area(radius) = pi * radius^2
warning: docstring-argument-mismatch
--> example.jl:3:3
|
3 | - `raduis`: The radius.
| ^^^^^^^^ documented argument `raduis` is not in the attached signature
unresolved-docstring-reference
Flag an explicit, code-labeled Documenter @ref target when project resolution can prove that no such Julia symbol exists. Local Markdown anchors are accepted, and inferred references, prose-labeled links, unknown packages, dynamic definitions, unresolved usings, opaque docstrings, and unsupported target shapes stay silent rather than risk a false positive. 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.
The explicit target retains the same typo as its code label:
"""
See [`Base.raduis`](@ref Base.raduis).
"""
area(radius) = pi * radius^2
warning: unresolved-docstring-reference
--> example.jl:2:26
|
2 | See [`Base.raduis`](@ref Base.raduis).
| ^^^^^^^^^^^ documentation reference `Base.raduis` does not resolve
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 !==. Inside a real Test.@test assertion, it instead prefers isnothing(x) or !isnothing(x) when that name is confirmed to mean Base’s predicate.
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 T — typeof(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 Base — Threads.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`
test-bare-expression
Flag an @test argument that has no syntactic comparison, Boolean connective, or predicate call—for example, @test ready. Calls and nested macro calls are treated conservatively as potentially Boolean. The rule only matches the real Test.@test macro after a visible, file-local Test load. It is disabled by default because Julia’s dynamic types make bare Boolean values valid, and it offers no fix.
A bare value hides what relationship the test asserts:
using Test
@test result
warning: test-bare-expression
--> example.jl:2:7
|
2 | @test result
| ^^^^^^ this assertion has no comparison or predicate
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
test-isa-call
Flag @test isa(value, Type) and prefer @test value isa Type. The rule only matches the real Test.@test macro after a visible, file-local Test load. It reports a safe fix that reuses both arguments’ source text and adds parentheses when an operand would otherwise rebind. The fix is withheld when it would discard a comment outside the retained arguments.
Use Julia’s comparison spelling in a Test assertion:
using Test
@test isa(result, AbstractVector)
warning: test-isa-call
--> example.jl:2:7
|
2 | @test isa(result, AbstractVector)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^ write `result isa AbstractVector` instead of calling `isa`
= help: Rewrite as an `isa` comparison (safe fix)
After applying the fix:
using Test
@test result isa AbstractVector
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. Rust 1.89 or newer is enough to build Fatou; the
development toolchain tracks stable through rust-toolchain.toml:
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.
fatou.schema.json is generated from the configuration types. After changing a
configuration key, type, enum, or default, regenerate it with
UPDATE_EXPECTED=1 cargo test config_schema, review the diff, and rerun
cargo test config_schema. Do not edit the schema by hand.
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 aninput.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 aninput.jland a hand-authoredexpected.jl. Fatou owns its formatting style;expected.jlis 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.mddocuments the architecture, the design tenets (deterministic full-reflow formatting, first-class incremental parsing, losslessness), and the project conventions in detail.TODO.mdis 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.mdare generated by tooling—never edit the changelog by hand.
License
By contributing, you agree that your contributions are licensed under the MIT License.