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
# Skip paths entirely, whatever the comments in them
# 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
# Every global and pattern option, fully commented
# 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 55 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) · 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)
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:
// ~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:, # 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 --> |
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 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:
[]
= 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.
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.
[]
= false
= false
= false
= ["IMPORTANT", "NOTE", "WARNING"]
= ["playground/**", "thirdparty/**"]
= 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"]
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:
[]
= "Starlark"
= ["bzl", "bazel", "star"]
= ["BUILD", "BUILD.bazel", "WORKSPACE", "MODULE.bazel", "Tiltfile"]
= ["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.
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.