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.
cargo install --path crates/declint
# Fastest start: scaffold a config from a preset (python, ini, markdown).
declint init --lang ini
declint presets # list the embedded preset library
# In CI: lint everything under the current directory, annotate the PR,
# fail the job only on error-severity violations.
declint check . --format github --fail-on error
# Shell completions (bash; zsh/fish/powershell also supported).
source <(declint completions bash)
# In your editor: run a language server over stdio.
declint serve # discovers .declint.yaml / .declint/ itself
The recommended way is the declint.nvim companion plugin — it configures the client and attaches per project:
-- lazy.nvim
{ 'thyrgle/declint.nvim', config = function()
require('declint').setup()
end,
}
Any LSP client that launches a custom stdio server works too. With nvim-lspconfig:
require('lspconfig.configs').declint = {
default_config = {
cmd = { 'declint', 'serve' }, -- discovers .declint.yaml / .declint/ itself
filetypes = { 'markdown', 'sh' }, -- match your configs' languages
root_dir = vim.fn.getcwd,
},
}
require('lspconfig').declint.setup({})
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.
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.
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 *.yaml inside is a config
(a natural layout for per-language rule files: markdown.yaml,
sh.yaml, ...)declint.yaml — the legacy, non-hidden name, still acceptedThe 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.
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: [markdown, sh] # 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
| 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.
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.
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:
nil (no matches) or a list of
{ start = , finish = , captures = { name = "..." } } tables, with
positions relative to text; they are converted to absolute
positions for you.captures interpolate into the message template and are visible to
the rule's callback (if any) as c.captures.string.find/string.gmatch
— they execute at C speed; per-character Lua loops do not.declint_core::MatchParser and register it
with Callbacks::register_parser("name", ...), then reference
parser: name in 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.
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: ["port must be 1-65535, found 99999"]
- 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.
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/end are compiled with multi-line mode forced, so ^/$
anchor to lines.See examples/scoped-rules.yaml for a full
config.
declint check decides each file's language in this order:
--language <id> — always wins (e.g. --language markdown)md → markdown, py → python, sh → sh, ...), plus
well-known extension-less filenames (Dockerfile → dockerfile)languages key applyConfigs can pull in other configs — and declint ships a small library of curated presets, embedded in the binary:
version: 1
languages: [python] # 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
rules:/scopes: concatenate before the importer's own;
all ids share one namespace, and duplicates across imports are a
config error naming both sources.languages — only the top-level
config does.declint presets lists the library; declint presets python prints
the YAML; declint init --lang python scaffolds 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).
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/:
db.query() call must pass
tenant_id= (multi-tenancy). A callback vetoes compliant calls —
no regex can express "flag it only when it does NOT contain".start pattern selects
functions named handle_*, 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.
gh:thyrgle/decfastapi —
FastAPI-specific rules: blocking calls in async routes, missing
response_model, snake_case route paths, missing OpenAPI tags.
Install with declint 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 with declint install gh:thyrgle/dectailwindcss.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
.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).declint install -g gh:owner/repo) vendor into
~/.declint/store/ instead, for personal rules available everywhere.
Import them from any config with import: [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.
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.
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'
declint check --fix applies every
available fix in place (non-overlapping, position-ordered) and fails
only for the violations that remain.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. Using the thyrgle/declint@v1 action? It caches the
installed binary per version, so only the first run of a workflow pays
the compile — after that it's restore-and-lint. 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 is hint: any violation fails.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 # the composite action in this repo
with:
path: .
declint-version: "1.3.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.
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.
| 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.
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.
cargo install
— in CI and elsewhere — becomes a fast download; no Rust toolchain
required at all.range overrides,
per-rule instruction budgets, fix: templates → LSP CodeActions,
inline # declint:disable=<id> comments, per-rule file globs.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.
Rust
97.9%
JavaScript
2.1%
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.
cargo install --path crates/declint
# Fastest start: scaffold a config from a preset (python, ini, markdown).
declint init --lang ini
declint presets # list the embedded preset library
# In CI: lint everything under the current directory, annotate the PR,
# fail the job only on error-severity violations.
declint check . --format github --fail-on error
# Shell completions (bash; zsh/fish/powershell also supported).
source <(declint completions bash)
# In your editor: run a language server over stdio.
declint serve # discovers .declint.yaml / .declint/ itself
The recommended way is the declint.nvim companion plugin — it configures the client and attaches per project:
-- lazy.nvim
{ 'thyrgle/declint.nvim', config = function()
require('declint').setup()
end,
}
Any LSP client that launches a custom stdio server works too. With nvim-lspconfig:
require('lspconfig.configs').declint = {
default_config = {
cmd = { 'declint', 'serve' }, -- discovers .declint.yaml / .declint/ itself
filetypes = { 'markdown', 'sh' }, -- match your configs' languages
root_dir = vim.fn.getcwd,
},
}
require('lspconfig').declint.setup({})
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.
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.
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 *.yaml inside is a config
(a natural layout for per-language rule files: markdown.yaml,
sh.yaml, ...)declint.yaml — the legacy, non-hidden name, still acceptedThe 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.
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: [markdown, sh] # 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
| 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.
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.
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:
nil (no matches) or a list of
{ start = , finish = , captures = { name = "..." } } tables, with
positions relative to text; they are converted to absolute
positions for you.captures interpolate into the message template and are visible to
the rule's callback (if any) as c.captures.string.find/string.gmatch
— they execute at C speed; per-character Lua loops do not.declint_core::MatchParser and register it
with Callbacks::register_parser("name", ...), then reference
parser: name in 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.
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: ["port must be 1-65535, found 99999"]
- 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.
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/end are compiled with multi-line mode forced, so ^/$
anchor to lines.See examples/scoped-rules.yaml for a full
config.
declint check decides each file's language in this order:
--language <id> — always wins (e.g. --language markdown)md → markdown, py → python, sh → sh, ...), plus
well-known extension-less filenames (Dockerfile → dockerfile)languages key applyConfigs can pull in other configs — and declint ships a small library of curated presets, embedded in the binary:
version: 1
languages: [python] # 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
rules:/scopes: concatenate before the importer's own;
all ids share one namespace, and duplicates across imports are a
config error naming both sources.languages — only the top-level
config does.declint presets lists the library; declint presets python prints
the YAML; declint init --lang python scaffolds 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).
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/:
db.query() call must pass
tenant_id= (multi-tenancy). A callback vetoes compliant calls —
no regex can express "flag it only when it does NOT contain".start pattern selects
functions named handle_*, 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.
gh:thyrgle/decfastapi —
FastAPI-specific rules: blocking calls in async routes, missing
response_model, snake_case route paths, missing OpenAPI tags.
Install with declint 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 with declint install gh:thyrgle/dectailwindcss.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
.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).declint install -g gh:owner/repo) vendor into
~/.declint/store/ instead, for personal rules available everywhere.
Import them from any config with import: [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.
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.
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'
declint check --fix applies every
available fix in place (non-overlapping, position-ordered) and fails
only for the violations that remain.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. Using the thyrgle/declint@v1 action? It caches the
installed binary per version, so only the first run of a workflow pays
the compile — after that it's restore-and-lint. 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 is hint: any violation fails.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 # the composite action in this repo
with:
path: .
declint-version: "1.3.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.
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.
| 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.
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.
cargo install
— in CI and elsewhere — becomes a fast download; no Rust toolchain
required at all.range overrides,
per-rule instruction budgets, fix: templates → LSP CodeActions,
inline # declint:disable=<id> comments, per-rule file globs.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.
Rust
97.9%
JavaScript
2.1%