declint 1.0.0

A YAML-configured regex linter and language server: `declint serve` in your editor, `declint check` in CI.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
# declint

A YAML-configured regex linter and language server, built on
[increparse](../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`](ARCHITECTURE.md).

```yaml
version: 1
rules:
  - id: no-tabs
    pattern: '\t+'
    message: "Use spaces, found '{match}'"
    severity: warning
```

**New to declint?** [`doc/tutorial.md`](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`](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

```sh
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):

```lua
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).

```yaml
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.:

```text
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`:

```yaml
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.)

```yaml
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`:

```yaml
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:

```yaml
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`](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:

```yaml
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:

```console
$ 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:

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

```yaml
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:

```console
$ 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):

```yaml
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:

```yaml
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`](https://docs.rs/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](#parser-rules).

**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`]crates/declint-core | Config loading/validation, message templates, the regex lint engine. No LSP dependencies. |
| [`crates/declint-lsp`]crates/declint-lsp   | The `serve()` language server: violations → LSP diagnostics via `increparse-lsp`. |
| [`crates/declint`]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:<pkg>). 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.