uncomment 3.9.0

A CLI tool to remove comments from code using tree-sitter for accurate parsing
docs.rs failed to build uncomment-3.9.0
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.
Visit the last successful build: uncomment-3.0.3

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

crates.io npm PyPI CI License: MIT Sponsor

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 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 downloads a release archive instead of compiling from source.

Run without installing:

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

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

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.

Quick Start

# 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

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

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

# 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

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

# 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

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

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

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

# 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:

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

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

[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

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:

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

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

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

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

License

MIT