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/(theconfig/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, orModel.where("x = #{v}", { "v": v }).Trusted.*→ jailedFile.*(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
enablefor a specific rule re-enables only that rule, even if the priordisablewas a blanket one. - A block
disablewith no matchingenableruns 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 thesolibinary (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
defwith no return annotation returnsAny. Only an explicit-> Voidmeans 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 withAny, as aModelsubclass already did. So does amodule, whosethisis whatever includes it. When the whole ancestry is in the file, an unknown member is still an error. let x = nilis "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}thenh["Authorization"] = "Bearer …"is ordinary Soli; a hash is a heterogeneous bag at run time.