uncomment 3.9.0

A CLI tool to remove comments from code using tree-sitter for accurate parsing
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
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
<div align="center">

<img src="assets/banner.svg" alt="uncomment — strip the noise, keep the code" width="820">

**Strip the noise. Keep the code.**

uncomment removes comments from source code using tree-sitter's AST — so it is 100% accurate and
**never** touches comment-like text inside strings. It keeps what matters by default (TODO/FIXME,
docs, and linting directives) across **300+ languages**, with parallel processing and a safe dry-run
mode.

AST-accurate&nbsp;·&nbsp;306 languages&nbsp;·&nbsp;zero false positives&nbsp;·&nbsp;smart preservation&nbsp;·&nbsp;parallel&nbsp;·&nbsp;dry-run

[![crates.io](https://img.shields.io/crates/v/uncomment?style=flat-square&color=2dd4bf)](https://crates.io/crates/uncomment)
[![npm](https://img.shields.io/npm/v/uncomment-cli?style=flat-square&color=2dd4bf&label=npm)](https://www.npmjs.com/package/uncomment-cli)
[![PyPI](https://img.shields.io/pypi/v/uncomment?style=flat-square&color=2dd4bf)](https://pypi.org/project/uncomment/)
[![CI](https://img.shields.io/github/actions/workflow/status/Goldziher/uncomment/ci.yml?style=flat-square&label=CI)](https://github.com/Goldziher/uncomment/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-2dd4bf?style=flat-square)](./LICENSE)
[![Sponsor](https://img.shields.io/badge/Sponsor-%E2%9D%A4-2dd4bf?style=flat-square&logo=github-sponsors)](https://github.com/sponsors/Goldziher)

[Install](#installation)&nbsp;·&nbsp;[Features](#features)&nbsp;·&nbsp;[Usage](#usage)&nbsp;·&nbsp;[Configuration](#configuration)&nbsp;·&nbsp;[How it works](#how-it-works)&nbsp;·&nbsp;[Contributing](#contributing)

</div>

---

## Why uncomment

Regex-based comment strippers guess. They delete a `//` inside a string literal, mangle a URL in a
docstring, or leave a linting directive your CI depends on. uncomment doesn't guess: it parses your
code into a real syntax tree and removes only the nodes that are genuinely comments.

Originally built to clean up AI-generated code drowning in explanatory comments, it now works on
anything with a tree-sitter grammar.

## Features

- **100% accurate** — tree-sitter AST parsing identifies comments structurally, not by pattern matching
- **No false positives** — never removes comment-like content from strings
- **Smart preservation** — keeps TODO/FIXME, docs, and language-specific linting directives by default
- **306 languages** — powered by [tree-sitter-language-pack](https://github.com/kreuzberg-dev/tree-sitter-language-pack), every grammar compiled into the binary
- **Parallel** — multi-threaded processing that scales across cores
- **Safe** — dry-run mode with line-by-line diffs previews every change before you write
- **Configurable** — hierarchical TOML config with a smart `init` command
- **Built-in benchmarking** — optional performance analysis and profiling tools

## Installation

| Channel | Command |
| ------- | ------- |
| Homebrew (macOS/Linux) | `brew tap goldziher/tap && brew install uncomment` |
| Cargo (Rust) | `cargo install uncomment` |
| npm (Node.js) | `npm install -g uncomment-cli` |
| pip (Python) | `pip install uncomment` |

Prefer prebuilt binaries? [`cargo binstall uncomment`](https://github.com/cargo-bins/cargo-binstall) downloads a
release archive instead of compiling from source.

Run without installing:

```bash
npx -y uncomment-cli@latest .
uvx uncomment .
```

Add `--dry-run` to preview changes before writing.

<details>
<summary><b>Build from source</b></summary>

```bash
git clone https://github.com/Goldziher/uncomment.git
cd uncomment
cargo install --path .
```

Requires Rust 1.70+. npm and pip packages download pre-built binaries automatically.

</details>

## Quick Start

```bash
# Generate a configuration file tuned to your project
uncomment init

# Remove comments from a directory
uncomment src/

# Preview changes as a diff, write nothing
uncomment src/ --dry-run --diff
```

## Usage

```bash
# Single file
uncomment file.py

# Multiple files / globs
uncomment src/*.py

# Also strip doc comments and docstrings
uncomment --remove-doc file.py

# Also remove TODO and FIXME comments (preserved by default)
uncomment --remove-todo --remove-fixme file.py

# Add custom patterns to preserve
uncomment --ignore "HACK" --ignore "WARNING" file.py

# Skip paths entirely, whatever the comments in them
uncomment . --exclude "playground/**" --exclude "thirdparty/**"

# Process an entire tree with all CPU cores
uncomment . -j 0
```

Run `uncomment --help` for the full, grouped list of options.

<details>
<summary><b>Configuring with <code>init</code></b></summary>

The `init` command detects the languages in your project and writes a matching `.uncomment.toml`:

```bash
# Smart detection — includes only the languages it finds
uncomment init

# Every global and pattern option, fully commented
uncomment init --comprehensive

# Interactive selection
uncomment init --interactive

# Custom output location / overwrite
uncomment init --output config/uncomment-rules.toml --force
```

</details>

<details>
<summary><b>Optional benchmarking tools</b></summary>

Development binaries for benchmarking and profiling are gated behind the `bench-tools` feature so
they are not installed for regular users:

```bash
# Install with extras
cargo install uncomment --features bench-tools

# Or run locally
cargo run --release --features bench-tools --bin benchmark -- --target /path/to/repo --iterations 3
cargo run --release --features bench-tools --bin profile -- /path/to/repo
```

</details>

## Supported Languages

uncomment ships with 55 built-in language configurations and can process any of the **306 languages**
in [tree-sitter-language-pack](https://github.com/kreuzberg-dev/tree-sitter-language-pack) — every
grammar is compiled into the binary, so nothing is downloaded, built or cached at runtime, and any
language can be added via configuration.

<details>
<summary><b>55 built-in languages</b></summary>

Python (`.py`, `.pyw`, `.pyi`, `.pyx`, `.pxd`) · JavaScript (`.js`, `.jsx`, `.mjs`, `.cjs`) ·
TypeScript (`.ts`, `.mts`, `.cts`, `.d.ts`, `.d.mts`, `.d.cts`) · TSX (`.tsx`) · Rust (`.rs`) ·
Go (`.go`) · Go templates (`.tpl`, `.tmpl`, `.gotmpl`, `.gohtml`) · Java (`.java`) ·
C (`.c`, `.h`) · C++ (`.cpp`, `.cc`, `.cxx`, `.hpp`, `.hxx`) · C# (`.cs`) ·
Ruby (`.rb`, `.rake`, `.gemspec`) · PHP (`.php`, `.phtml`) · Elixir (`.ex`, `.exs`) · TOML (`.toml`) ·
JSON (`.json`) · JSON with Comments (`.jsonc`) · YAML (`.yml`, `.yaml`) ·
HCL/Terraform (`.hcl`, `.tf`, `.tfvars`) · Makefile (`Makefile`, `makefile`, `GNUmakefile`, `.mk`) ·
Shell/Bash (`.sh`, `.bash`, `.zsh`, `.bashrc`, `.zshrc`, `.zshenv`) · Haskell (`.hs`, `.lhs`) · HTML (`.html`, `.htm`, `.xhtml`) ·
CSS (`.css`) · XML (`.xml`, `.xsd`, `.xsl`, `.xslt`, `.svg`) · SQL (`.sql`) · Kotlin (`.kt`, `.kts`) ·
Objective-C (`.m`) · Swift (`.swift`) · Lua (`.lua`) · Nix (`.nix`) · PowerShell (`.ps1`, `.psm1`, `.psd1`) ·
Protobuf (`.proto`) · INI-like configs (`.ini`, `.cfg`, `.conf`) · Dockerfile (`Dockerfile`, `Dockerfile.*`) ·
Starlark/Bazel (`BUILD`, `BUILD.bazel`, `WORKSPACE`, `WORKSPACE.bazel`, `WORKSPACE.bzlmod`, `MODULE.bazel`, `.bzl`, `.bazel`, `.star`) ·
Java properties (`.properties`) ·
Markdown (`.md`, `.markdown`, `.mdown`, `.mkd`) ·
Scala (`.scala`, `.sc`) · Dart (`.dart`) · R (`.r`, `.R`) · Julia (`.jl`) · Zig (`.zig`) ·
Clojure (`.clj`, `.cljs`, `.cljc`, `.edn`) · Elm (`.elm`) · Erlang (`.erl`, `.hrl`) · Vue (`.vue`) ·
Svelte (`.svelte`) · SCSS (`.scss`) · LaTeX (`.tex`, `.sty`, `.cls`) · Fish (`.fish`) ·
Perl (`.pl`, `.pm`) · Groovy (`.groovy`, `.gradle`) · OCaml (`.ml`, `.mli`) ·
Fortran (`.f90`, `.f95`, `.f03`, `.f08`)

</details>

Objective-C uses the `objc` grammar. The `.m` extension is also used by MATLAB;
Uncomment treats it as Objective-C by default. When processing a mixed project,
pass only the Objective-C paths. Headers (`.h`) retain the C configuration;
Objective-C++ (`.mm`) is not included in built-in support.

Markdown has no comment of its own — `<!-- … -->` is raw HTML to CommonMark, and the grammar reports
it as the same `html_block` that carries a `<div align="center">` badge row. A block is treated as a
comment only when it is *nothing but* one comment, so embedded HTML, a `<!DOCTYPE>`, an unterminated
`<!--` (which runs to the end of the document) and `<!-- x --><div>kept</div>` are all left alone. A
comment inside a paragraph or a table cell is inline content rather than an `html_block`, so it is
out of reach; `.mdx` is not supported at all, because no grammar in the pack parses `{/* … */}`.

In a Go template a comment is only a comment inside an action, so a bare `/* … */` is literal output
text and `{{/* … */}}` is the only form. The grammar models the `{{` and `}}` as *siblings* of the
comment rather than as part of it, so the whole action is removed — deleting only the comment would
leave `{{}}`, which Helm refuses to render. One spacing caveat: `{{ /* spaced */ }}` and `{{-/* x */-}}`
produce no comment node at all and are left alone. A comment with code on *both* sides keeps the
spaces around it (`hello {{/* x */}} world` → `hello  world`), because in a template that whitespace is
rendered output. One that ends the line does not: `hello {{/* x */}}` leaves `hello`, since a trailing
comment takes its separator with it like any other language's.

## Preservation Rules

Certain comments are **never removed by default** — uncomment protects the ones your tooling and
teammates rely on.

**Always preserved:**

- Comments containing `~keep`
- `TODO` (unless `--remove-todo`), `FIXME` (unless `--remove-fixme`)
- Documentation comments (unless `--remove-doc`)

### The `~keep` marker

Put `~keep` on a `//`-style comment, not on a `///` or `/** */` doc comment. `~keep` is
plain comment text, so a marker written inside a doc comment is republished by every tool
that consumes doc comments — rustdoc, OpenAPI schemas, generated API clients, editor
hover text.

A marker on its own line protects the comment directly beneath it, which keeps it out of
anything rendered:

```rust
// ~keep
/// Parent element ID for hierarchical relationships.
pub parent_id: Option<String>,
```

The marker also extends across a contiguous run of comments, so one `~keep` protects a
whole multi-line block. A blank line or any code between comments ends the run.

Inside a doc comment the marker is usually redundant, since doc comments are preserved
anyway unless `--remove-doc` is set. uncomment strips such a marker from the doc text and
reports it (`stripped 2 redundant ~keep markers`), leaving the comment itself untouched.
Two cases are left alone: a marker is kept when `--remove-doc` is set, because there it is
the only thing protecting that doc comment, and prose *about* the marker is never rewritten
— a line containing backticks, or a commented-out code sample, is read as documentation
rather than as a directive.

<details>
<summary><b>Linting &amp; formatter directives (always preserved)</b></summary>

| Language | Directives |
| -------- | ---------- |
| Go | `//nolint`, `//golangci-lint`, `//staticcheck`, `//go:generate` |
| Python | `# noqa`, `# type: ignore`, `# mypy:`, `# pyright:`, `# ruff:`, `# pylint:`, `# flake8:`, `# fmt: off/on`, `# black:`, `# isort:`, `# bandit:`, `# nosec`, `# pyre-ignore` |
| JS/TS | `eslint-disable*`, `@ts-ignore`, `@ts-expect-error`, `@ts-nocheck`, `/// <reference`, `prettier-ignore`, `biome-ignore`, `deno-lint-ignore`, `v8/c8/istanbul ignore` |
| Rust | `#[allow]`, `#[deny]`, `#[warn]`, `#[forbid]`, `#[cfg]`, `clippy::`, `#[rustfmt::skip]` |
| Java | `@SuppressWarnings`, `@SuppressFBWarnings`, `//noinspection`, `// checkstyle:` |
| C/C++ | `// NOLINT`, `// NOLINTNEXTLINE`, `#pragma`, `// clang-format off/on` |
| Shell | `# shellcheck disable`, `# hadolint ignore` |
| YAML | `# yamllint disable/enable` |
| HCL/Terraform | `# tfsec:ignore`, `# checkov:skip`, `# trivy:ignore`, `# tflint-ignore` |
| Ruby | `# rubocop:disable/enable`, `# reek:`, `# standard:disable/enable` |
| Markdown | `<!-- markdownlint-* -->`, `<!-- prettier-ignore -->`, `<!-- vale off/on -->`, `<!-- START/END doctoc -->` |

</details>

## Subcommands

### `uncomment scan` — comment inventory

Reports every comment in the tree with the verdict a real run would reach, as JSONL, JSON or text. Each comment gets a stable id that survives edits elsewhere in the file.

```bash
# Inventory everything
uncomment scan src/ --format jsonl -o scan.jsonl

# Only the comments a run would remove
uncomment scan src/ --only removable

# Collapse identical comments into groups, sorted by frequency
uncomment scan src/ --group-identical --only removable
```

**Flags:**

- `--format jsonl|json|text` — output format (default: jsonl)
- `--only removable|preserved|all` — filter by verdict (default: all)
- `--group-identical` — collapse equivalent comments into one record per distinct text, with a site count and list
- `-o FILE` — write report to FILE instead of stdout

**ID scheme:** IDs are derived from the file path, comment bytes, and occurrence index within the file — no line numbers, so an id survives edits above the comment. Collisions within a file are widened to the full 32-hex identifier; cross-file collisions widen the later claimant.

### `uncomment keep` — write `~keep` markers

Applies `~keep` markers to comments selected by id, substring, or verdict. A line comment gets `~keep` appended; a block, doc or docstring comment gets a marker line directly above it, written with the language's plain line-comment token — or with its block pair (`/* ~keep */`, `<!-- ~keep -->`) when the language has no line form. A block, doc or docstring comment sharing its line with code cannot be marked at all: a marker line above it would attach to the line of code, so the comment is reported as unmarkable instead.

```bash
# Mark everything a scan reported as removable
uncomment keep src/ --from scan.jsonl

# Mark specific comments by id
uncomment keep src/ --id a3f9c1d2ab --id b7e4f8a1cd

# Mark every comment whose text contains a substring
uncomment keep src/ --match "legacy shim"

# Mark everything that would be removed
uncomment keep src/ --all-removable
```

**Flags:**

- `--from FILE` — read comment ids from a scan output file (JSONL or JSON; every field but `id` is ignored)
- `--id ID` — mark the comment with this id (repeatable)
- `--match SUBSTRING` — mark every comment whose text contains SUBSTRING
- `--all-removable` — mark every comment a default run would remove
- `--skip-missing` — warn about ids that no longer resolve instead of failing

**Why a marker line above for docstrings:** A Python docstring is the `string` node that becomes `__doc__` at runtime, so editing its bytes changes what the program reports about itself. A marker line written above the docstring is a plain comment that uncomment reads but the runtime ignores.

### `uncomment lint` — tag comment linting

Checks tag comments (TODO, FIXME, HACK, XXX) against the convention configured under `[lint]`: that the tag is canonical, that it carries an issue key, and that the key is not the issue the current branch is working on. Removes nothing, exits 1 on violations, and works as a pre-commit hook.

```bash
# Check comments, report violations, write nothing
uncomment lint src/

# Fix what can be fixed (canonical tags, injecting --todo-key)
uncomment lint src/ --fix --todo-key PROJ-1234

# Check only what the branch touched
uncomment lint src/ --changed-only

# Record current violations; future runs treat them as informational
uncomment lint src/ --baseline .lint-baseline.json --write-baseline
uncomment lint src/ --baseline .lint-baseline.json
```

**Flags:**

- `--fix` — rewrite what can be rewritten (canonical tag form, injecting `--todo-key` where missing)
- `--todo-key KEY` — issue key to insert into tag comments that have none (with `--fix`)
- `--changed-only` — lint only files changed against `--base` (default: `origin/HEAD`, else `main`)
- `--base REF` — base ref for `--changed-only`
- `--baseline FILE` — treat violations recorded here as informational
- `--write-baseline` — record every current violation in the baseline file and exit 0
- `--format text|json` — output format (default: text)

**Three rules:**

1. `tag-not-canonical` — a tag not written the way `canonical_tag` is written. That covers two defects and the message says which: a different spelling, reported as *`FIXME` should be written as `TODO`*, and the canonical spelling in the wrong casing, reported as *`todo` is `TODO` written with the wrong casing*. `--fix` rewrites both.
2. `todo-missing-key` — the tag must carry an issue key matching `key_pattern`
3. `todo-self-reference` — the key must not be the issue the current branch is for (that issue closes when the branch merges, leaving the TODO pointing at a dead ticket)

**Casing:** tags are matched regardless of casing, so `fixme`, `Todo` and `xXx` are tags and each is a `tag-not-canonical` violation whose `--fix` normalises it to `canonical_tag`. The key is not: `todo(AMVP-1)` counts as keyed, while `TODO(amvp-1)` and `todo(amvp-1)` do not, because `key_pattern` still requires the key exactly as written. Set `case_sensitive_tags = true` to go back to matching only the literal casing in `tags`, where `todo:` is not a tag at all.

A miscased tag has to open its comment, though — everything between the start of its line and the tag must be delimiter or decoration (`//`, `#`, `/*`, a `*` block continuation, a `-` bullet). `# todo: fix` and `* Hack: works around the driver` are tags; *this is a hack to work around the upstream bug* is a sentence, and rewriting `hack` in it to `TODO` would wreck it. A tag spelled exactly as configured needs no such position, because `TODO` in capitals is not a word anyone writes by accident: `// see also FIXME: the driver bug` stays a tag, as it always has been. Measured on an 89k-file monorepo, 165 of the 183 miscased tag words in code files were prose.

A sigil is a separate question from casing and always has been: `\b` sits between `@` and the tag, so `# @TODO: x` is a tag today and `# @todo: x` becomes one now. `--fix` rewrites the tag token alone and leaves the sigil, giving `# @TODO: x`. If `@todo` is prose you do not want linted, `case_sensitive_tags = true` is not the lever — drop `TODO` from `tags` or baseline the occurrences.

**Opt-in:** Linting is off by default. Enable it per file tree with `[lint]` in `.uncomment.toml`:

```toml
[lint]
enabled = true
key_pattern = '^\s*(?:TODO|FIXME|HACK|XXX)\((?<key>[A-Z][A-Z0-9]+-\d+)\)\s*:'
# Optional, default false. With `true`, only the literal casing in `tags` is a tag,
# so `todo:` and `Fixme:` are not flagged at all.
case_sensitive_tags = true
```

Note what `enabled = true` alone commits you to: with the default `tags` and `canonical_tag`, every existing `FIXME`, `HACK` and `XXX` in the tree becomes two violations — one for the tag, one for the missing key — and `--fix` rewrites the tag to `TODO`. Every miscased tag counts too, `todo:` and `Fixme:` included. On an existing codebase, reach for `--changed-only` or `--write-baseline` first.

### Large-repo workflow

On a large codebase, deciding which comments to keep is a batch process: scan once, filter the report, then mark what the filter selected.

Two scans, because the two reports answer different questions. A grouped report is for *deciding* — one judgement per distinct wording instead of hundreds. `keep` cannot act on it: a grouped record's `id` is a group id derived from the normalized text, with no path and no occurrence index, so it resolves to no single comment and the run fails with every id unresolved (`--skip-missing` downgrades that to a warning). Feed `keep --from` the ungrouped report.

```bash
# 1. Read the decision surface: one record per distinct comment, by frequency
uncomment scan . --group-identical --only removable

# 2. Inventory every site, which is what keep consumes
uncomment scan . --only removable --format jsonl -o scan.jsonl

# 3. Filter scan.jsonl — delete the lines for comments that should be removed,
#    keep the lines for comments that should be kept. Any JSON-aware tool works;
#    the only field `keep` reads is `id`.

# 4. Apply markers to the comments the filtered report selected
uncomment keep . --from scan.jsonl

# 5. Run the real removal
uncomment .

# 6. Verify: a second scan reports 0 removable comments
uncomment scan . --only removable
```

## Configuration

uncomment reads hierarchical TOML configuration. Precedence, lowest to highest:

1. Built-in defaults
2. Global config at `uncomment/config.toml` under the platform config directory (`dirs::config_dir()`) — `$XDG_CONFIG_HOME` or `~/.config` on Linux, `~/Library/Application Support` on macOS, `%APPDATA%` on Windows
3. Local `.uncomment.toml` files (from repository root toward the file; inner beats outer)
4. Pattern matches (`[patterns."glob"]`) within those configs, last match wins
5. Language-specific settings (`[languages.name]`)
6. Command-line flags

A directory's config file is named `.uncomment.toml`. Two earlier names are still read — `.uncommentrc.toml`, then `uncomment.toml` — so an existing config keeps working, but each is deprecated and a run that loads one says so once on stderr. A directory holding more than one of the three uses the highest-precedence name outright; the others are ignored rather than merged.

`[lint]` layers differently from the rest: a nested table amends the one above it key by key, each severity in `[lint.rules]` included, so a subdirectory can switch one rule off and still inherit `enabled`, `tags` and `key_pattern` from above.

`[languages.*]` does not follow step 3 either. The language registry is built once, before any file is read, from the configs in the invocation directory and its ancestors plus every config found under the paths being processed — deepest declaration wins. (`--config FILE` replaces that discovery entirely.) A config reached only later, during per-file resolution, comes too late: its `[languages]` section is ignored, with a warning naming the directory.

CLI flags are one-directional: `--remove-doc` sets `remove_docs = true` but an unset flag never clobbers a config-file value back to false.

`exclude` does not layer like the rest either — it is a union. A file matching any `exclude` glob in force is not collected by any subcommand, and a nested config can add an exclusion but never withdraw one. Repeating `--exclude GLOB` adds to the configured list rather than replacing it, and naming an excluded path on the command line does not override it: the setting says which paths the project never wants read, not which ones this invocation skips. Globs use the same dialect as `[patterns."<glob>"]` keys — `*` does not cross a `/`, `**` does — and are anchored the same way, relative to the directory holding the config file that declared them (relative to the invocation directory for `--exclude`, and for a `--config FILE` config). `exclude = ["vendor/**"]` also keeps the directory walk out of `vendor` rather than descending it and discarding the results. Like `[languages.*]`, `exclude` has to be known before collection, so it is read from the same set of configs: the invocation directory and its ancestors plus every config found under the paths being processed.

Every table rejects unknown keys, and a project config that does not parse stops the run instead of degrading to built-in defaults — so a typo such as `enable` for `enabled` in `[lint]` fails every subcommand, not just `lint`.

```toml
[global]
remove_todos = false
remove_fixme = false
remove_docs = false
preserve_patterns = ["IMPORTANT", "NOTE", "WARNING"]
exclude = ["playground/**", "thirdparty/**"]
use_default_ignores = true
respect_gitignore = true

[languages.python]
name = "Python"
extensions = ["py", "pyw", "pyi"]
comment_nodes = ["comment"]
doc_comment_nodes = ["string"]
preserve_patterns = ["noqa", "type:", "pragma:", "pylint:"]

[patterns."tests/**/*.py"]
# Keep all comments in test files
remove_todos = false
remove_fixme = false
remove_docs = false
```

<details>
<summary><b>Adding a language via configuration</b></summary>

Any of the 306 tree-sitter-language-pack languages works. The grammar is already in the binary, so
there is no grammar to fetch or build. The grammar is looked up by the `name` field lowercased — not
by the section key — so `name` has to be the pack's own name for the language, or the name of a
built-in you are overriding. A `name` that matches neither is reported on stderr rather than
silently ignored:

```toml
[languages.hare]
name = "Hare"
extensions = ["ha"]
comment_nodes = ["comment"]
preserve_patterns = ["TODO", "FIXME"]
```

A language can claim whole filenames instead of, or as well as, extensions — the only way to reach a
file that has no extension at all:

```toml
[languages.starlark]
name = "Starlark"
extensions = ["bzl", "bazel", "star"]
filenames = ["BUILD", "BUILD.bazel", "WORKSPACE", "MODULE.bazel", "Tiltfile"]
comment_nodes = ["comment"]
```

`filenames` is matched case-sensitively and consulted **before** any extension rule, so a claim on
`BUILD.bazel` decides that file regardless of what else claims `.bazel`. Case matters because
`BUILD` and `build` are different files to Bazel, and folding the key would hand a `build` shell
script to a Starlark parser. An entry ending in `.*` claims every name starting with the part before
the `*` — `filenames = ["Dockerfile.*"]` reaches `Dockerfile.prod` — and where two such prefixes
overlap the longer one wins, never whichever was registered first. A `[languages.*]` section needs at
least one `extensions` or `filenames` entry; with neither it claims no file at all and is rejected
rather than silently doing nothing.

</details>

## How It Works

Unlike regex-based tools, uncomment builds a proper Abstract Syntax Tree of your code with
tree-sitter, so it distinguishes:

- Real comments vs comment-like content in strings
- Documentation comments vs regular comments
- Inline comments vs standalone comments
- Language-specific metadata that must be preserved

The pipeline is modular: a **language registry** (51 built-ins, plus any other compiled-in grammar
named in config) feeds an **AST visitor** that finds comment nodes, a **preservation engine** decides
what to keep, and an **output generator** emits clean code.

## Git Hooks

<details>
<summary><b>pre-commit</b></summary>

```yaml
repos:
  - repo: https://github.com/Goldziher/uncomment
    rev: v3.5.0
    hooks:
      - id: uncomment
```

</details>

<details>
<summary><b>Lefthook</b></summary>

```yaml
pre-commit:
  commands:
    uncomment:
      run: uncomment {staged_files}
      stage_fixed: true
```

</details>

## Performance

AST parsing costs a little more than regex, but the tool is fast and scales well with threads.

- Small files (<1000 lines): ~20-30ms
- Large files (>10000 lines): ~100-200ms

| Threads | Files/second | Speedup |
| ------- | ------------ | ------- |
| 1 | 1,500 | 1.0× |
| 4 | 3,900 | 2.6× |
| 8 | 5,100 | 3.4× |

*Benchmarked on a large enterprise codebase of ~5,000 mixed-language files.* Measure your own with
the built-in `benchmark` and `profile` tools (see [optional benchmarking tools](#usage)).

## Development

```bash
cargo build              # Debug build
cargo test               # Run the test suite
cargo test -- --ignored  # Include network-dependent tests
cargo clippy             # Lint
cargo fmt --all          # Format
```

See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for local development, automation hooks, and release
procedures.

## Contributing

Issues and pull requests are welcome. If uncomment is useful to you, consider
[sponsoring development](https://github.com/sponsors/Goldziher) — it helps keep the project
maintained for the community.

## License

[MIT](./LICENSE)