ESC
Type to search...
S
Soli Docs

Linting

Built-in static analysis to catch style issues and code smells without executing your code.

Usage

# Lint all .sl files in current directory (recursive)
soli lint

# Lint a specific directory
soli lint src/

# Lint a single file
soli lint app/main.sl

Exit codes

0 — no issues found. 1 — one or more issues found.

Locale files are skipped

Translation tables are data that happens to be written in Soli — long sentences in a hash literal, one key per line. Style rules over them produce hundreds of style/line-length hits nobody will act on, drowning out the real findings. Walking a directory skips them:

  • anything under a directory named locales/ (the config/locales/ convention);
  • a file whose stem is locale_<tag> or <tag>_locale, where the tag looks like a locale code — locale_fr.sl, locale_zh-Hans.sl, pt_BR_locale.sl.

The tag check is narrow on purpose, so helpers about locales keep being linted: locale_helper.sl and locale_switcher.sl are code, not data. Skipped files are counted in the summary line, and naming one explicitly always lints it:

soli lint app/helpers/
# No issues found. (9 locale files skipped)

# An explicit path is always linted — the escape hatch
soli lint app/helpers/locale_fr.sl

Output Format

Each issue is reported on a single line with the file path, line, column, rule, and message:

app/main.sl:12:5 - [naming/snake-case] variable 'myVar' should use snake_case
app/main.sl:30:9 - [smell/unreachable-code] unreachable code after return statement

2 issue(s)
found in 1 file(s)

Rules

Naming

naming/snake-case

Variables, functions, methods, and parameters should use snake_case.

# Bad
let myVar = 10
def processData end

# Good
let my_var = 10
def process_data end
naming/pascal-case

Classes and interfaces should use PascalCase.

# Bad
class my_class end
interface data_store end

# Good
class MyClass end
interface DataStore end

Style

style/empty-block

Blocks should not be empty. Add a comment or remove the block.

style/line-length

Lines should not exceed 120 characters.

Code Smells

smell/unreachable-code

Code after a return statement is unreachable and will never execute.

def example
  return 42
  print("never reached");  # Warning: unreachable code
end
smell/empty-catch

Catch blocks should not be empty. Silently swallowing errors hides bugs.

# Bad - error silently ignored
try
  risky()catch e
end

# Good - at least log the error
try
  risky()catch e
  print("Error: " + str(e))
end
smell/deep-nesting

Nesting depth should not exceed 4 levels. Consider extracting logic into separate functions.

smell/duplicate-methods

A class should not have two methods with the same name.

smell/closure-cycle

A closure assigned onto the instance — this.x = fn(...) ..., @x = |y| ..., this.handlers["k"] = fn .... The closure captures the method's environment, which holds this; stored on this, the two keep each other alive forever, so the instance and everything it holds leak in a long-lived worker. Store a method name and dispatch on it, or pass the closure in per call.

# Leaks: the instance holds a closure that holds the instance
this.on_save = fn(record) { this.log(record) }

# Instead: keep the name, call the method when needed
this.on_save = "log"
smell/dangerous-server-builtin

Calls to db_query_raw, Trusted.*, System.shell / System.shell_sync, or backtick command substitution from app/controllers/, app/middleware/, or app/views/. These primitives are powerful but become injection / traversal sinks when fed request-controlled data. The diagnostic spells out the safe alternative for each:

  • db_query_raw → parameterised @sdbql{ ... #{value} ... } block, or Model.where("x = #{v}", { "v": v }).
  • Trusted.* → jailed File.* (read/write/exists), which keeps every operation under the app root.
  • System.shell / backticks → System.run(["prog", "arg1", ...]) with an argv array, which never invokes a shell.

Models, migrations, and tests are out of scope — those layers legitimately use these APIs against operator-controlled data.

Idioms

These flag code that is correct but un-idiomatic — the Soli-specific patterns that read better with a builtin.

idiom/nil-comparison

Prefer .nil? / .present? over comparing to null.

# Bad
if user == null end
return user != null

# Good
if user.nil? end
return user.present?
idiom/prefer-blank

Prefer .blank? / .present? over comparing to an empty string. .blank? also covers the nil case.

# Bad
if name == "" end

# Good
if name.blank? end
idiom/prefer-includes

Replace a chain of 3+ comparisons of the same value with .includes?.

# Bad
status == "up" || status == "late" || status == "overdue"

# Good
["up", "late", "overdue"].includes?(status)
idiom/prefer-to-s

Coalescing to an empty string means “render this, and render nothing when it is nil”. .to_s says that in one call. It is also the more honest version when the value is not already a string: count ?? "" evaluates to the number when there is one and to "" when there is not, so what it yields changes type with the data. A real fallback (name ?? "Guest") is left alone — it says something .to_s cannot.

# Bad
let title = record.title ?? ""
let count = record.count ?? ""

# Good
let title = record.title.to_s
let count = record.count.to_s
security/unfiltered-mass-assignment

Model.create(params), .update, and .create_many in app/controllers/ or app/services/ persist every posted key. Whitelist first. Does not fire on a hash literal or on permit / _permit_params.

# Bad
Post.create(params)
Post.create(req["json"])

# Good
Post.create(permit(params, {"title": true, "body": true}))
Post.create(this._permit_params(params))
idiom/manual-find-guard

Model.find raises RecordNotFound on a miss (which the request handler turns into a 404), so a nil-check on its result never runs. Drop it, or use find_by / first_by when you want a nil result.

# Bad - dead code: .find already raised
post = Post.find(id)
return not_found() if post.nil?

# Good
post = Post.find(id)
component/props

A component's props(...) declaration must use string-literal names with no duplicates. Missing/unknown props are checked at render time in --dev, not by lint.

# Bad
props("title", "title")   # duplicate
props("title", x)         # not a string literal

# Good
props("title", "value")

Suppressing Warnings

When a warning is a known false-positive or an intentional exception, suppress it inline with a directive comment.

Single-line forms

disable-next-line covers the line below; disable-line covers the same line.

# soli-lint-disable-next-line smell/dangerous-server-builtin
if Trusted.is_dir(wt_path)
  ...
end

Trusted.read(p)  # soli-lint-disable-line smell/dangerous-server-builtin

Block forms

disable / enable toggle a rule for a region. Useful when several adjacent lines are intentional exceptions.

# soli-lint-disable smell/dangerous-server-builtin
exists = Trusted.is_dir(path)
data   = Trusted.read(path)
# soli-lint-enable smell/dangerous-server-builtin
  • Omit the rule name to suppress every rule (e.g. # soli-lint-disable). Pass a comma-separated list to scope to multiple rules.
  • An enable for a specific rule re-enables only that rule, even if the prior disable was a blanket one.
  • A block disable with no matching enable runs to the end of the file.
  • Prefer naming the exact rule so unrelated warnings still surface.

Editor Integration

The VS Code / Cursor extension provides full Language Server Protocol (LSP) support with real-time linting, hover documentation, autocomplete, and more.

Features

  • Real-time linting — warnings and errors displayed inline
  • Hover information — documentation for functions, classes, and builtins
  • Autocomplete — suggestions for keywords, types, and symbols
  • Go to definition — jump to symbol definitions
  • Find references — locate all uses of a symbol

Installation

cd editors/vscode
vsce package
# Install the generated .vsix file in Cursor or VS Code

Settings

  • soli.lsp.enable — Enable/disable LSP server (default: true)
  • soli.lsp.executablePath — Path to the soli binary (default: "soli")
  • soli.lint.enable — Enable/disable linting (default: true)
  • soli.lint.onSave — Run linter on file save (default: true)

Manual LSP Setup

For editors that support custom LSP servers directly (Neovim, Emacs, etc.):

require('lspconfig').soli.setup({
  cmd = {"soli", "lsp"},
  filetypes = {"soli"},
  root_dir = lspconfig.util.root_pattern("soli.toml", ".git"),
})

Learn More

For full editor setup instructions and all available LSP features, see the Editor Integration guide.

Type checking with soli check

Where soli lint catches style and smells, soli check runs Soli's optional type system over your code without executing it — ideal for CI or a pre-commit hook. It resolves imports and reports every mismatch with a file:line:column location, exiting non-zero when any are found.

soli check               # type-check the current project
soli check app/models    # check a directory
soli check app/models/user.sl

# example output
# app/models/user.sl: Type error: Type mismatch: expected Int, found String at 14:14
# 1 error(s) in 1 of 38 file(s)

Running a file with soli <file> type-checks it first too (use --no-type-check to skip); soli check is the standalone, whole-project form that never runs your app.

A directory is checked as one namespace

A running server loads app/controllers, app/models, app/services, app/policies, app/middleware, app/mailers, config and stdlib into one environment — so a helper declared in one file is callable from its neighbours with no import. Given a directory, soli check reads the top-level declarations of all of them first, and every file is checked knowing what its neighbours declare.

Two consequences. A project declaration overrides a built-in of the same name, because that is what happens at run time — a project that defines its own three-argument input is checked against that one. And a name declared without let counts: MAX_WIDTH = 280 at the top of a file declares MAX_WIDTH for the whole project, exactly as const would.

Naming a single file — soli check app/models/user.sl — keeps the narrower per-file view, since a loose script has no project around it. A neighbour's type is not inferred, only its existence: what it is would mean checking the file that declares it, so its calls are unconstrained rather than wrongly constrained.

The builtins are learnt from the runtime

The checker does not keep a list of the framework's globals. It registers the builtins into a throwaway environment and asks it what it holds, and it parses the preludes the runtime evaluates as Soli source — the routing verbs (get, middleware, resources), the mailer (Mailer, Message), the form builder (form_with, csrf_field), the upload helpers. A class global carries its native verbs with it, so I18n.cache_table(…) resolves while I18n.cache_tabel(…) is still an error. A builtin added to the runtime is known to soli check on the next build, with no second list to update.

Two sets are seeded per file rather than always, because they mean nothing everywhere. The test DSL — describe, test, expect, as_guest, the factories, the browser verbs — exists only when the runtime is about to run tests, so it is declared for a file soli test would run: anything under tests/, or a file named *_spec.sl / *_test.sl. A describe(…) in a controller is a real error, because a served application does not have it.

And the request scope — req, request, params, session, cookies, flash, current_user, plus render and redirect — is declared for a file that belongs to an application: under app/, config/ or stdlib/. A controller, a helper, a job and a middleware get them; a loose script still gets Undefined variable 'render', which is the point — a controller-shaped call in a script that cannot run it should not pass the check.

Where it answers Any on purpose

Annotations are optional in Soli, and the checker treats a missing one as unknown rather than as a claim. Four cases where it therefore says nothing rather than something wrong:

  • A def with no return annotation returns Any. Only an explicit -> Void means it returns nothing — and indexing the result of one is still an error.
  • A class whose parent is not in the file — ApplicationController < Controller, or a parent declared in a sibling — answers any member access with Any, as a Model subclass already did. So does a module, whose this is whatever includes it. When the whole ancestry is in the file, an unknown member is still an error.
  • let x = nil is "nothing known yet", not "null forever": the next line usually assigns a record and reads a field back.
  • Writing a key into a hash is not checked against the value type of the literal that built it. {"timeout": 10} then h["Authorization"] = "Bearer …" is ordinary Soli; a hash is a heterogeneous bag at run time.