declint
A YAML-configured regex linter and language server, built on
increparse. Describe lint rules in a small YAML file and get
both an LSP server (declint serve, diagnostics in your editor as you
type) and a CLI (declint check, for CI) — no Rust required. The
internal layout is summarized in ARCHITECTURE.md.
version: 1
rules:
- id: no-tabs
pattern: '\t+'
message: "Use spaces, found '{match}'"
severity: warning
New to declint? doc/tutorial.md builds an INI
linter step by step — first rules, scoped sections, Lua callbacks, and
the editor payoff — in about 30 minutes. For a second course,
doc/python-tutorial.md does the same for a
Python ruleset: warm-up rules, the no-lookaround tricks, function
scopes, and the mutable-default-arguments callback.
Quick start
# Fastest start: scaffold a config from a preset (python, ini, markdown).
# In CI: lint everything under the current directory, annotate the PR,
# fail the job only on error-severity violations.
# Shell completions (bash; zsh/fish/powershell also supported).
# In your editor: run a language server over stdio.
Neovim setup
Any LSP client that launches a custom stdio server works. Neovim (with nvim-lspconfig):
require. =
require..
The filetypes list should match the languages: keys in your configs —
Neovim sends the filetype as the LSP languageId, and declint only
publishes diagnostics from configs whose languages match it. A file of
any other filetype gets a clean (empty) diagnostic publish.
VS Code
Install the declint extension
(or grab the .vsix from a release and run Extensions: Install from VSIX...). It spawns declint serve for you; declint.path,
declint.args, and declint.filetypes live under the declint
settings section — match declint.filetypes to your configs'
languages keys.
Any generic LSP client extension pointed at declint serve also works.
Config discovery
With no explicit path, both declint serve and declint check look for a
config site starting at the current directory and walking up through
parents. At each directory the search order is:
.declint.yaml— a single hidden config file.declint/— a hidden directory; every*.yamlinside is a config (a natural layout for per-language rule files:markdown.yaml,sh.yaml, ...)declint.yaml— the legacy, non-hidden name, still accepted
The first hit wins. Ids must be unique within one config file, but
different files in a .declint/ directory may reuse them — only one
language's configs apply to any given document, so codes stay
unambiguous.
Config reference
The config file is declint.yaml by default (both subcommands take an
explicit path too).
version: 1 # required; this declint understands version 1
import: # optional; merge other configs' rules+scopes in
- preset:python # embedded presets (see "Imports & presets")
- ./team-scopes.yaml # or any relative YAML file
languages: # optional; editor language ids this config applies
# to (exact match; missing = all languages)
rules: # file-global rules; optional if `scopes` is present
- id: rule-name # required; unique; becomes the diagnostic's code
pattern: '\t+' # required unless `parser` is present; Rust `regex`
# syntax (no lookaround)
parser: | # instead of `pattern`: a Lua function that sees
return function(text, offset) # the whole file — see "Parser rules"
return nil
end
message: "..." # required unless `callback` is present; see templates
severity: warning # optional: error | warning | info | hint (default warning)
callback: | # optional; Lua snippet or `checks/foo.lua` path,
return function(c) # see "Callbacks" below
return nil
end
scopes: # optional; see "Scoped rules" below
- id: scope-name # unique; shares a namespace with rule ids
start: '...' # required; starts a region (multi-line `^`/`$`)
end: '...' # optional; ends a region (default: end of file)
rules: # rules that run only inside this scope's regions
Everything is validated at load time — regex syntax, message placeholders, duplicate ids, unknown keys — and a bad config is rejected with the rule id and file line of the problem, e.g.:
declint: rules.yaml:5: rule 0 ('no-tabs'): invalid pattern: repetition operator missing expression
Message templates
| Placeholder | Meaning |
|---|---|
{match} |
the text the pattern matched |
{name} |
the text of named capture group name — (?<word>\w+) |
{{ / }} |
literal braces |
Unknown placeholder names are a config error, caught at load time.
Callbacks
For decisions a message template can't express, a rule can run a Lua
callback instead of (or alongside) a message. The callback: value is
either an inline block scalar or a path to a .lua file (relative to the
config); the snippet must return function(ctx) ... end:
rules:
- id: todo-ticket
pattern: 'TODO:[ \t]*(?<ticket>\S*)'
severity: warning
callback: |
return function(ctx)
if ctx.captures.ticket == "" then
return { severity = "warning", message = "TODO without a ticket" }
end
return nil -- cites a ticket -> allowed
end
The callback receives one table — match, captures (named and
"1"-numbered), start/finish (byte offsets), line/col (1-based),
path, language, rule — and decides:
| Return | Meaning |
|---|---|
nil / false |
allowed — no diagnostic for this match |
true |
violate, using the rule's own message template |
{ message = "...", severity = "..." } |
violate with overrides (severity optional) |
| thrown Lua error | an error-severity diagnostic names the rule; the run continues |
Every call runs under an instruction budget, so a runaway loop fails as a diagnostic instead of hanging the editor. Snippets are compiled when the config loads — a syntax error is a config error with the rule id.
Rust embedders can skip Lua entirely: implement
declint_core::MatchCallback and register it by name
(Callbacks::register("my_check", ...)), then reference it in YAML as
callback: my_check.
Parser rules
Regex rules match one pattern at a time and can't remember previous
matches. When a rule needs the whole file in hand — counting duplicates,
flagging absent constructs, hand-rolled matching — replace pattern
with a parser: a Lua function called once per scan unit that
returns every match. (The two keys are mutually exclusive; one is
required.)
rules:
- id: duplicate-keys
parser: |
return function(text, offset)
-- text: the whole file (or the scope region, for scoped rules)
-- offset: text's absolute byte position in the file
local seen, matches = {}, {}
local pos = 1
while pos <= #text do
local nl = text:find("\n", pos, true) or (#text + 1)
local line = text:sub(pos, nl - 1)
local key = line:match("^%s*([%w-]+)%s*=")
if key and seen[key] then
matches[#matches + 1] = {
start = offset + pos - 1, finish = offset + pos - 1 + #line,
captures = { key = key, count = tostring(seen[key] + 1) },
}
end
if key then seen[key] = (seen[key] or 0) + 1 end
pos = nl + 1
end
return matches
end
message: "'{key}' defined {count} times"
severity: error
The contract:
- Return
nil(no matches) or a list of{ start = , finish = , captures = { name = "..." } }tables, with positions relative totext; they are converted to absolute positions for you. - Each returned match then flows through the standard pipeline — its
capturesinterpolate into the message template and are visible to the rule'scallback(if any) asc.captures. - Parsers run under a larger instruction budget than callbacks (10
million instructions per call). Prefer
string.find/string.gmatch— they execute at C speed; per-character Lua loops do not. - Like callbacks, parsers must be pure: same text in, same matches out.
- Rust embedders: implement
declint_core::MatchParserand register it withCallbacks::register_parser("name", ...), then referenceparser: namein YAML.
Parser rules close the gaps the tutorials' "ceiling" sections describe: duplicates, absence rules ("every function must have a docstring"), and custom matching that no regex dialect could express.
Testing your rules
Rules are programs — pin their behavior with embedded fixtures, run by
declint test:
rules:
- id: port-range
pattern: '(?m)^\s*port[ \t]*=[ \t]*(?<port>\S+)'
severity: error
tests:
- name: out-of-range port
text: "[server]\nport = 99999\n"
violations: 1
messages:
- name: valid port passes
text: "[server]\nport = 8000\n"
violations: 0
Each test runs only that rule over text (scopes included — scoped
rules test their scoped behavior), then checks the violation count and
that each expected messages entry appears. declint test runs every
embedded test in the discovered configs — imported presets included —
and exits 1 on any failure. declint explain <file> shows the other
half of the picture: which configs applied, which rules resolved, and
what matched in a given file.
Scoped rules
A scopes entry segments the file and runs its rules only inside those
regions — lint's version of a pass that expands, then subpasses that run
per region:
version: 1
scopes:
- id: shell-fence
start: '^```sh$' # where a region begins (required)
end: '^```$' # where it ends (optional; default: end of file)
rules:
- id: no-sudo
pattern: '\bsudo\b'
message: "Don't use sudo in scripts"
severity: error
start/endare compiled with multi-line mode forced, so^/$anchor to lines.- Regions are sequential within a scope; different scopes may overlap freely (each family is independent).
- Rule and scope ids share one namespace and must be unique across the whole config.
- In the editor, an edit inside one region re-lints only that region — every other region keeps its identity in the parse tree.
See examples/scoped-rules.yaml for a full
config.
CLI language handling
declint check decides each file's language in this order:
--language <id>— always wins (e.g.--language markdown)- The file's extension, via a built-in table of common Neovim filetype
names (
md→markdown,py→python,sh→sh, ...), plus well-known extension-less filenames (Dockerfile→dockerfile) - Unknown → only configs without a
languageskey apply
Imports & presets
Configs can pull in other configs — and declint ships a small library of curated presets, embedded in the binary:
version: 1
languages: # language policy belongs to THIS config
import:
- preset:python # embedded preset (declint presets — list them)
- ./team-scopes.yaml # any relative YAML config
rules:
# your own rules, merged after the imports
- Imported
rules:/scopes:concatenate before the importer's own; all ids share one namespace, and duplicates across imports are a config error naming both sources. - Imported configs must not declare
languages— only the top-level config does. - Relative paths resolve against the importing file; imports may nest, with cycle detection.
declint presetslists the library;declint presets pythonprints the YAML;declint init --lang pythonscaffolds a config that imports it.
Shipped presets: python (PEP 8 warm-ups, mutable defaults, docstrings,
top-level function/class scopes), ini (tabs, trailing whitespace,
empty values, duplicate keys, a [server] scope example), markdown
(tabs, trailing whitespace, bare URLs), sh (rm-with-variables, sudo,
missing shebang, strict-mode suggestion), dockerfile (unpinned/latest
FROM, sudo in RUN, ADD vs COPY, apt cleanup, missing
HEALTHCHECK), toml (tabs, trailing whitespace, empty values,
spacing, duplicate keys per section), json (trailing commas,
single-quoted strings, unquoted keys, comments), javascript (== vs
===, var declarations, leftover console.log).
Rules other linters can't do
Stock linters ship fixed catalogs of universal rules. declint's
differentiator is the rules they will never ship — your conventions.
Two examples from examples/house-rules/:
- A conditional house rule: every
db.query()call must passtenant_id=(multi-tenancy). A callback vetoes compliant calls — no regex can express "flag it only when it does NOT contain". - A convention-scoped rule: a scope whose
startpattern selects functions namedhandle_*, so "handlers never print" applies only inside request handlers and nowhere else. Stock linters cannot scope a rule by naming convention without a custom AST plugin.
Both are a dozen lines of YAML. See examples/house-rules/ for the
working setup.
Both are a dozen lines of YAML. See examples/house-rules/ for the
working setup.
Community rulesets
gh:thyrgle/decfastapi— FastAPI-specific rules: blocking calls in async routes, missingresponse_model, snake_case route paths, missing OpenAPI tags. Install withdeclint install gh:thyrgle/decfastapi.gh:thyrgle/dectailwindcss— Tailwind class-usage hygiene for the templates ESLint can't reach: duplicate and contradicting classes, attribute hygiene, v4 readiness. Install withdeclint install gh:thyrgle/dectailwindcss.
Sharing rulesets
Rule sets are plain YAML configs in git repos — anyone's repo can be a ruleset. To use one:
$ declint install gh:thyrgle/declint-rules
vendored .declint/vendor/thyrgle/declint-rules/HEAD/declint.yaml
wired into .declint.yaml
- Project installs vendor the files under
.declint/vendor/<owner>/<repo>/<ref>/and wire the import into your config. The files are ordinary repo content: review them in the PR, commit them, and CI never touches the network — same commit, same lints, forever. Pin with@<tag-or-sha>for extra precision (unpinned installs print a warning). - Global installs (
declint install -g gh:owner/repo) vendor into~/.declint/store/instead, for personal rules available everywhere. Import them from any config withimport: [global:owner/repo]. Global installs float — re-running install updates them — so prefer project installs for anything shared or audited.
The fetched config's own relative import: entries are fetched too, so
multi-file rule sets work. And the trust note, plainly: rulesets may
contain Lua callbacks and parsers — installing one is running code.
Vendor-first exists so you read exactly what runs, exactly once, before
it ever executes.
declint install requires network access; linting the vendored copy
does not.
Inline suppressions
Put declint:disable in a line to silence every rule on that line, or
declint:disable=no-tabs,trailing-whitespace to silence specific rules —
in whatever comment syntax the language uses:
port = 99999 ; declint:disable=port-range
password = hunter2 # declint:disable-next-line
declint:disable-next-line covers the following line instead. The
suppression applies to the line's violations only — nothing global, and
the markers work in any language because they are matched as plain text.
Fixes
Rules with a fix: template are auto-repairable: the template replaces
each match, interpolating captures like messages.
rules:
- id: compare-to-none
pattern: '==\s*None'
message: "use 'is None', not '== None'"
fix: 'is None'
- In CI and the terminal:
declint check --fixapplies every available fix in place (non-overlapping, position-ordered) and fails only for the violations that remain. - In the editor: rules with fixes become quickfix code actions — the lightbulb offers "declint: apply fix for …" on the offending lines.
Continuous integration
declint check is CI-shaped: file:line:col: severity[id]: message
output, exit 0 clean / 1 violations / 2 broken setup. Pass
--format github and every violation becomes a GitHub Actions workflow
command — inline annotations right on the pull request:
$ declint check --format github .
::error file=app.ini,line=10,col=1,endLine=10::[declint/port-range] port must be 1-65535, found 99999
Directory arguments are walked recursively (.gitignore is respected,
hidden paths and non-UTF-8 files skipped), so the whole incantation for
a repository is declint check . with the config discovery you already
use locally. Two knobs for CI:
--fail-on error|warning|info|hint— all violations are still reported (and annotated), but only those at or above the threshold fail the job. The default ishint: any violation fails.- Lua callback
print()s route to stderr — debug freely, the annotation stream stays clean.
As a workflow step (annotates the PR, fails the job on violations):
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: thyrgle/declint@v1.0.0 # the composite action in this repo
with:
path: .
declint-version: "0.8.0"
Or by hand, without the action:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo install declint --locked
- run: declint check . --format github
The config comes from the repository being linted — the same
.declint.yaml / .declint/ your editors use. Lua callbacks and parser
rules run sandboxed with instruction budgets, so a looping rule fails a
diagnostic instead of hanging the job.
Regex flavor
Patterns use the regex crate: linear-time, no
catastrophic backtracking, named groups via (?<name>...). Lookaround and
backreferences are not available in regex rules — for matching beyond
that, use a parser rule.
CRLF files: $ in multi-line mode anchors before \n — a \r
left by Windows line endings sits between your match and the anchor.
End-of-line patterns should tolerate it ([ \t]+\r?$). Scope
boundaries are CRLF-safe automatically: their multi-line mode also
enables CRLF anchors.
Workspace layout
| Crate | Role |
|---|---|
crates/declint-core |
Config loading/validation, message templates, the regex lint engine. No LSP dependencies. |
crates/declint-lsp |
The serve() language server: violations → LSP diagnostics via increparse-lsp. |
crates/declint |
The CLI binary (serve / check). |
declint-lsp::language(config) also embeds into your own server if you
already have an increparse-lsp-based one.
Design notes
Linting here is a flat regex scan per document revision, so the parse tree
stays trivial (one region, immediately done) — what declint reuses from
increparse/increparse-lsp is the plumbing: incremental change
translation, byte↔UTF-8/16/32 position conversion, document bookkeeping,
and diagnostics publishing. Violations are merged into the same
publishDiagnostics stream as parse diagnostics via the
SimpleLanguage::extra_diagnostics hook.
Diagnostics carry source: "declint" and code: <rule-id>, so clients can
filter, style, and (later) suppress per rule.
Roadmap
- v2: nested scopes (a scope inside a scope), callback
rangeoverrides, per-rule instruction budgets,fix:templates → LSP CodeActions, inline# declint:disable=<id>comments, per-rule file globs.
Status
0.9.0 — declint test (embedded rule fixtures), declint explain (config/rule visibility for a file), --format json. 0.8.0 — declint install (gh: rulesets vendored into the project or the global store, importable as global:). 0.7.1 — Lua print() routes to stderr, CRLF-safe scope boundaries, --fail-on severity thresholds, shell completions. 0.7.0 — imports & presets (preset:python/ini/markdown, declint init, declint presets), Lua parser rules (whole-file matchers for duplicates, absence rules, and custom matching), Lua match callbacks, hidden configs with directory discovery, per-language configs, scoped rules; the schema is versioned to keep future configs compatible.