Please check the build logs for more information.
See Builds for ideas on how to fix a failed build, or Metadata for how to configure docs.rs builds.
If you believe this is docs.rs' fault, open an issue.
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 · 306 languages · zero false positives · smart preservation · parallel · dry-run
Install · Features · Usage · Configuration · How it works · Contributing
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, 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
initcommand - 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 downloads a
release archive instead of compiling from source.
Run without installing:
Add --dry-run to preview changes before writing.
Requires Rust 1.70+. npm and pip packages download pre-built binaries automatically.
Quick Start
# Generate a configuration file tuned to your project
# Remove comments from a directory
# Preview changes as a diff, write nothing
Usage
# Single file
# Multiple files / globs
# Also strip doc comments and docstrings
# Also remove TODO and FIXME comments (preserved by default)
# Add custom patterns to preserve
# Process an entire tree with all CPU cores
Run uncomment --help for the full, grouped list of options.
The init command detects the languages in your project and writes a matching .uncomment.toml:
# Smart detection — includes only the languages it finds
# All 51 built-in languages
# Interactive selection
# Custom output location / overwrite
Development binaries for benchmarking and profiling are gated behind the bench-tools feature so
they are not installed for regular users:
# Install with extras
# Or run locally
Supported Languages
uncomment ships with 51 built-in language configurations and can process any of the 306 languages in 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.
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) · 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, .mk) ·
Shell/Bash (.sh, .bash, .zsh) · 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) ·
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)
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.
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:
// ~keep
/// Parent element ID for hierarchical relationships.
pub parent_id: ,
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.
| Language | Directives |
|---|---|
| Go | //nolint, //golangci-lint, //staticcheck, //go:generate |
| Python | # noqa, # type: ignore, # mypy:, # pyright:, # ruff:, # pylint:, # flake8:, # fmt: off/on, # black:, # isort:, # bandit:, # 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 |
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.
# Inventory everything
# Only the comments a run would remove
# Collapse identical comments into groups, sorted by frequency
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.
# Mark everything a scan reported as removable
# Mark specific comments by id
# Mark every comment whose text contains a substring
# Mark everything that would be removed
Flags:
--from FILE— read comment ids from a scan output file (JSONL or JSON; every field butidis 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.
# Check comments, report violations, write nothing
# Fix what can be fixed (canonical tags, injecting --todo-key)
# Check only what the branch touched
# Record current violations; future runs treat them as informational
Flags:
--fix— rewrite what can be rewritten (canonical tag form, injecting--todo-keywhere 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, elsemain)--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:
tag-not-canonical— a tag not written the waycanonical_tagis written. That covers two defects and the message says which: a different spelling, reported asFIXMEshould be written asTODO, and the canonical spelling in the wrong casing, reported astodoisTODOwritten with the wrong casing.--fixrewrites both.todo-missing-key— the tag must carry an issue key matchingkey_patterntodo-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. 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 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:
[]
= true
= '^\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.
= 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.
# 1. Read the decision surface: one record per distinct comment, by frequency
# 2. Inventory every site, which is what keep consumes
# 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
# 5. Run the real removal
# 6. Verify: a second scan reports 0 removable comments
Configuration
uncomment reads hierarchical TOML configuration. Precedence, lowest to highest:
- Built-in defaults
- Global config at
uncomment/config.tomlunder the platform config directory (dirs::config_dir()) —$XDG_CONFIG_HOMEor~/.configon Linux,~/Library/Application Supporton macOS,%APPDATA%on Windows - Local
.uncomment.tomlfiles (from repository root toward the file; inner beats outer) - Pattern matches (
[patterns."glob"]) within those configs, last match wins - Language-specific settings (
[languages.name]) - 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.
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.
[]
= false
= false
= false
= ["IMPORTANT", "NOTE", "WARNING"]
= true
= true
[]
= "Python"
= ["py", "pyw", "pyi"]
= ["comment"]
= ["string"]
= ["noqa", "type:", "pragma:", "pylint:"]
[]
# Keep all comments in test files
= false
= false
= false
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:
[]
= "Hare"
= ["ha"]
= ["comment"]
= ["TODO", "FIXME"]
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
repos:
- repo: https://github.com/Goldziher/uncomment
rev: v3.5.0
hooks:
- id: uncomment
pre-commit:
commands:
uncomment:
run: uncomment {staged_files}
stage_fixed: true
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).
Development
See 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 — it helps keep the project maintained for the community.