ripgrep searches text, and text is the wrong unit for code. A regex for console.log(...) misses the call split across three lines, matches the one inside a string, and can’t tell you which argument is which. Rename a function with sed and you rename it inside a comment too.

ast-grep parses each file into a syntax tree and matches against that. Your pattern is written as code, not as an escape-riddled regex:

  ast-grep run -p 'console.log($MSG)' -l js
  

That matches the call however it’s formatted, ignores the one in a string literal, and binds the argument to $MSG so you can use it in a replacement.

Install

  brew install ast-grep
cargo install ast-grep --locked
npm install -g @ast-grep/cli
  

The command is ast-grep. A short alias sg exists, but on Linux sg is already a system command for switching groups — use the full name in anything you commit.

The pattern language

Three things to learn, and then you know it:

SyntaxMatches
$VAROne node — an expression, an identifier, an argument
$$$ARGSZero or more nodes — the rest of an argument list, a statement body
Literal codeItself, structurally — whitespace and line breaks don’t matter
  # every fetch call, whatever the arguments
ast-grep run -p 'fetch($$$)' -l ts

# a specific method on a specific object
ast-grep run -p 'router.get($PATH, $$$HANDLERS)' -l ts

# Python: bare except clauses
ast-grep run -p 'try:
    $$$BODY
except:
    $$$HANDLER' -l py
  

Build patterns in the online playground first. Seeing the parse tree next to your pattern turns “why doesn’t this match” from guesswork into reading.

Rewriting, which is the point

Search is useful; the reason to install it is the rewrite.

  # swap a logger, keeping the argument
ast-grep run -p 'console.log($MSG)' -r 'logger.info($MSG)' -l js --interactive

# migrate an assertion style across a test suite
ast-grep run -p 'expect($A).toBe($B)' -r 'expect($A).toStrictEqual($B)' -l ts -U
  

--interactive walks the matches and asks before each edit — start there. -U applies everything at once, which is fine once you’ve seen the diff and have a clean git tree to fall back on.

The migrations this makes routine are the ones people otherwise postpone for a quarter: a renamed API across 400 call sites, an import path change, a deprecated option removed from every config.

Rules, for the checks you want to keep

A pattern you’ll run more than once belongs in a YAML rule, and rules can be stricter than a single pattern — matching only inside a certain context, or excluding a case.

  # rules/no-bare-except.yml
id: no-bare-except
language: python
severity: warning
message: Bare except swallows KeyboardInterrupt and SystemExit
rule:
  pattern: |
    try:
      $$$BODY
    except:
      $$$HANDLER
  
  ast-grep new              # scaffold sgconfig.yml and the rules directory
ast-grep scan             # run every rule
ast-grep scan --json      # machine-readable, for CI
  

That’s a project-specific linter in ten lines, for the conventions no off-the-shelf linter knows about — “don’t call this internal helper from outside the module”, “don’t construct this client without a timeout”.

Where it fits

UseTool
Find a string, a log message, a TODOripgrep
Find or change a code constructast-grep
Rename a symbol with full type awarenessYour IDE, or an LSP-based refactor

ast-grep understands syntax, not types or scope. It cannot tell two same-named methods on different classes apart — for that you want the language’s own tooling. Between “grep is too blunt” and “spin up a compiler plugin”, it fills the whole gap.

One habit worth keeping: commit before a bulk rewrite, and read the diff afterwards. A pattern that matched more than you expected is easy to spot in a diff and awful to find a week later.

Next

Some files aren’t text until something makes them text → MinerU

Last updated 23 Aug 2026, 00:00 UTC. history