declint 1.0.0

A YAML-configured regex linter and language server: `declint serve` in your editor, `declint check` in CI.
declint-1.0.0 is not a library.

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

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

Neovim setup

Any LSP client that launches a custom stdio server works. Neovim (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.

VS Code

Point a generic LSP client extension at declint serve with the filetypes you want.

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:

  1. .declint.yaml — a single hidden config file
  2. .declint/ — a hidden directory; every *.yaml inside is a config (a natural layout for per-language rule files: markdown.yaml, sh.yaml, ...)
  3. 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: [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

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 to text; they are converted to absolute positions for you.
  • Each returned match then flows through the standard pipeline — its captures interpolate into the message template and are visible to the rule's callback (if any) as c.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::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.

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: ["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.

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/end are 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:

  1. --language <id> — always wins (e.g. --language markdown)
  2. The file's extension, via a built-in table of common Neovim filetype names (md → markdown, py → python, sh → sh, ...)
  3. Unknown → only configs without a languages key 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: [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
  • 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 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, top-level function scopes), ini (tabs, trailing whitespace, empty values, a [server] scope example), markdown (tabs, trailing whitespace, bare URLs).

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 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.

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 --fix applies 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 is hint: 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@v0        # 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 range overrides, 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.