CLI tool
alexpovel/srgn avatar
alexpovel/srgn

srgn: a grep that understands what a class body is

A grep-like tool which understands source code syntax and allows for manipulation in addition to search

915 stars12 forksRustMIT

At a glance

What is it?
srgn is a Rust command line tool that applies regular expressions inside language grammar aware scopes, so a pattern can be confined to Python class bodies or Rust enums rather than matching everywhere. It also carries a set of actions, so it can rewrite what it finds instead of only reporting it.
Who is it for?
srgn suits a developer who needs to find or rewrite code within a syntactic boundary, such as a pattern that occurs both inside a class body and in a free function, and who wants a single binary for that rather than an IDE plugin. It does not suit a general find-and-replace job, since every srgn invocation carries the cost of parsing the file, and the grammar scope list is fixed by the grammars compiled into the binary.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository received new commits within the last day.
What is it written in?
Mainly Rust, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

grep plus a scope that knows what a class body is

srgn calls itself a code surgeon: a grep-like tool which understands source code syntax and allows for manipulation in addition to search.

The comparison it draws is deliberate. Regular expressions are a core primitive, as in grep. What is added is higher precision, because the pattern is not applied to the whole file but to a chosen region, and that region can optionally be language grammar aware.

That is the difference in one example. Take a Python file with a dataclass named Bird that has an age attribute and a method that increments it, plus a module level function register_bird that also mentions age.

bash
$ cat birds.py | srgn --python 'class' 'age'
11:    age: int
15:        self.age += 1

The pattern age was sought and found only within Python class definitions. The occurrence in the body of register_bird was not surfaced, and with plain grep excluding it would be close to impossible. The documentation makes that point explicitly.

The design goal is stated as simplicity: if you know regex and the basics of the language you are working with, you are good to go. The tool is positioned as a mix of tr, sed, ripgrep and tree-sitter, complementing regular expressions and IDE tooling such as Rename all and Find all references, which operate along dimensions plain regex cannot reach.

The underlying engine is tree-sitter, and the grammar is what defines the scope. The scope name class is not a hardcoded concept for Python; it is a node type in the Python grammar, which is why the same idea works for Rust enums and for doc-strings.

Scopes and actions are the two halves of the command line

The command line structure is worth internalising, because it explains why some invocations look unusual.

The pattern is the scope, and the replacement is positional and always comes last, disambiguated by a double dash for safety.

bash
$ echo 'Hello World!' | srgn '[wW]orld' -- 'there' # replacement
Hello there!

Zero or more actions can be specified, and if none are given the input comes back unchanged.

bash
$ echo 'Hello World!' | srgn '[wW]orld' # zero actions: input returned unchanged
Hello World!
$ echo 'Hello World!' | srgn --upper '[wW]orld' -- 'you' # two actions: replacement, afterwards uppercasing
Hello YOU!

Replacement is always performed first and specified positionally, and any other actions are applied after and given as command line flags. That ordering is why --upper sits before the pattern in the second example and why the result is uppercase after the substitution rather than before it.

The search mode is entered when no actions are specified and a language such as --python is given. In that mode line numbers are printed by default. The documentation compares it to ripgrep but with syntactical language elements, which is the clearest description of the tool available.

The design consequence is that a scope is not a filter applied after the fact. It is a region the search is performed inside, and it is intersected with the regex scope rather than applied to the regex results. That is why the ordering of scope flags matters, as the next section shows.

Multiple scopes combine left to right, and -j joins them instead

Language scopes can themselves be given more than once, and how they combine is the part that surprises people.

Given a Rust file with an enum Genre and a constant of type Subgenre, two scopes can be drilled into together.

bash
$ cat music.rs | srgn --rust 'pub-enum' --rust 'type-identifier' 'Subgenre' # AND'ed together
2:    Rock(Subgenre),

Only lines matching all criteria are returned, acting like a logical and between all conditions. The documentation is explicit that conditions are evaluated left to right, and that this precludes some combinations from making sense. A search for a Python class body inside Python doc-strings usually returns nothing, because a docstring is not a class body.

The inverse works as expected. Searching for doc-strings inside class bodies surfaces the class docstring and the method docstring, and no docstrings outside class bodies.

The -j flag changes the semantics. Instead of intersecting left to right, it runs all queries independently and joins their results, so you can search multiple ways at once.

bash
$ cat birds.py | srgn -j --python 'comments' --python 'doc-strings' 'bird[^s]'
8:    """A bird!"""
19:        """Create a bird from an egg."""
20:        pass  # No bird here yet!

Here the pattern bird[^s] was found inside comments or docstrings, rather than docstrings that are within comments.

That single flag is the difference between conjunction and disjunction of scopes, and it is the one to reach for when the intuitive reading of your command is wrong. A surprising empty result is more often a scope ordering problem than a missing match.

Without stdin it walks the directory for you

When standard input is not given, srgn finds relevant source files on its own, and the output is grouped by file.

bash
$ srgn --python 'class' 'age'
docs/samples/birds
11:    age: int
15:        self.age += 1

docs/samples/birds.py
9:    age: int
13:        self.age += 1

It walks the current directory recursively, finding files based on file extensions and shebang lines, and processes them at what the documentation calls very high speed. The example given for scale is srgn --go strings '\d+' finding and printing all around 140,000 runs of digits in literal Go strings inside the Kubernetes codebase of about 3,000,000 lines of Go code, within 3 seconds on 12 cores of M3. Those are the project's own figures from its documentation, not an independent measurement, but they do indicate that the parsing path is designed for large trees rather than for a handful of files.

The file discovery rules are worth understanding because they explain surprising inclusions. A file with no extension is still picked up if its shebang identifies it as a script, which is why docs/samples/birds appears alongside docs/samples/birds.py in the example above. So the same search covers both named source files and extensionless executables.

The dependency list supports this: the ignore crate is there for walking and filtering directories the way ripgrep does, glob is present for path patterns, and grep-cli is used for the underlying search machinery.

The result is that srgn can be used as a drop-in replacement for a ripgrep invocation in the cases where plain regex is not precise enough, without giving up the recursive directory walk.

Getting the binary: crates.io, Rust 1.85 and a grammar per language

The crate is published on crates.io as srgn, and the documentation for the API is on docs.rs. The package metadata sets edition 2024 and a rust-version of 1.85.0, so the minimum supported toolchain is fixed and the project needs a recent Rust.

The dependency list is where the real shape of the tool becomes visible. Beyond the usual command line plumbing, which is clap with derive, env, error-context, string and wrap_help features, plus clap_complete for shell completions, the matching engine is regex at 1.12.2 with fancy-regex at 0.16.2. fancy-regex is the one to note, because it adds look-around and backreference support that the standard regex crate deliberately leaves out.

Then there is tree-sitter at 0.25.10 with a grammar crate per language: tree-sitter-c at 0.24.1, tree-sitter-c-sharp at 0.23.1 and tree-sitter-go at 0.25.0 are visible in the manifest, with further grammars in the truncated part of the list. The repository topics list the languages the project cares about, including C, C#, Go, Python, Rust, TypeScript and HCL.

The practical consequence is that grammar support is not open ended. It is whatever grammars are compiled into the binary, and the manifest also carries optional dependencies, the fst crate for finite state transducers and cached for a lookup cache, plus decompound for a different kind of matching. Those are feature flags rather than always-on behaviour.

Output handling is modest and predictable: colored at 3.0.0 for terminal output, env_logger with auto-color and humantime features for logging, and titlecase and const_format as small formatting helpers. The package is licensed MIT, published by Alex Povel, and the repository is developed in the open with a CHANGELOG.md, a CONTRIBUTING.md and a docs/ directory alongside the source, plus benches/ for benchmarks and a tarpaulin.toml for coverage.

Where srgn is the wrong tool, and what to reach for instead

The first wrong case is a general text substitution. If you want to replace a string across a whole tree, srgn is the wrong choice. Every invocation parses the files it touches, and parsing is the expensive part of the design. A sed or ripgrep invocation with a plain pattern is cheaper and sufficient when precision is not the problem.

The second wrong case is a language you do not have a grammar for. The scope names are tree-sitter node types, so the set of scopes available to you is fixed by the grammars compiled into the binary. If the language you care about is not in the dependency list, srgn offers you regex over the whole file, which is what grep already does well.

The third wrong case is a rename with semantic awareness. The documentation positions srgn as complementing IDE tooling such as Rename all and Find all references, not replacing it. srgn operates on text within a syntactic region; it does not track a symbol through scopes and does not know that two occurrences of an identifier refer to the same binding. If you need that, you want a compiler-aware tool.

The alternative approaches differ in kind rather than degree. tree-sitter on its own gives you the parse tree and the node types but no regular expression over the tree, which means you write a query language instead of a pattern. sed gives you line-based editing with no structure. IDE tooling gives you symbol awareness but requires an editor and an index. srgn sits in the middle, and its bet is that the middle is where most of the interesting one-off code surgery actually lives.

There is a further constraint that shapes when it works. Because scopes are evaluated left to right, a nested scope query has to be written in the order the grammar nests, and some combinations are empty by construction rather than by absence of a match. When a query returns nothing, the order of the scope flags is the first thing to change.

Editorial conclusion

srgn suits a developer who needs to find or rewrite code within a syntactic boundary, such as a pattern that occurs both inside a class body and in a free function, and who wants a single binary for that rather than an IDE plugin. It does not suit a general find-and-replace job, since every srgn invocation carries the cost of parsing the file, and the grammar scope list is fixed by the grammars compiled into the binary. Before adopting it, check that the language you care about has a grammar in the dependency list, and remember that a scope is evaluated left to right, so the order in which you write your scope flags changes the result.

Frequently asked questions

What is srgn and how is it different from grep?

srgn is a grep-like tool that understands source code syntax. Regular expressions are a core primitive as in grep, but the pattern can be applied inside a language grammar aware scope such as Python class bodies, and the tool can also rewrite what it finds rather than only report it.

How do I search only inside Python class definitions with srgn?

Pass the language flag and the grammar node type as a scope, for example cat birds.py | srgn --python 'class' 'age'. The pattern is then applied only within that scope, so occurrences in module level functions are not surfaced, and line numbers are printed by default in search mode.

What does the -j flag do in srgn?

It changes how multiple scopes combine. Without it, scopes are intersected left to right, so only lines matching all criteria are returned. With -j, all queries run independently and their results are joined, so a pattern can be found inside comments or docstrings rather than docstrings nested within comments.

How do I install srgn?

The crate is published on crates.io as srgn, with API documentation on docs.rs. The package metadata sets edition 2024 and rust-version 1.85.0, so a recent Rust toolchain is needed, and the binary relies on tree-sitter grammars compiled in per language.

Can srgn rewrite files or only report matches?

Both. Replacement is specified positionally as the last argument after a double dash and is always performed first, with any other actions applied afterwards as flags. A call with no actions returns the input unchanged.

Official sources

  1. alexpovel/srgn on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/alexpovel-srgn.svg)](https://hysenlabs.com/projects/alexpovel-srgn)

Was this useful?