Useful? A star is how other developers find it — ★ GitHub · letools.dev/tools/string-le
Someone has to read every user-visible string before a release. A QA lead checking the messages. A compliance reviewer looking for a claim the product is not allowed to make. A localisation owner finding what never reached a catalog. None of them has the editor open, and several of them cannot be handed a checkout at all.
string-le . puts every string in the repository into one file they can
read.
Sixty seconds
|
# the point of the whole thing:
|
./config.json:2:14 Settings
./src/messages.ts:2:12 Delete this permanently?
./src/messages.ts:3:11 Never mind
3 strings in 3 files
Exit codes follow grep — 0 strings found, 1 none found, 2 the
question was malformed. Finding none is an answer, not an error.
Install
| Route | Command | Worth knowing |
|---|---|---|
| cargo | cargo install string-le |
Any platform, needs Rust 1.88+. |
| From source | git clone https://github.com/nolindnaidoo/string-lecd string-le/crate && cargo build --release |
The same build CI runs. |
No runtime, no network, nothing written.
Source files are the main event
Six formats are parsed — JSON, YAML, CSV, TOML, INI and dotenv. Ten source languages are read by their own literal syntax — Python, Rust, Go, shell, PHP, Ruby, Perl, C#, JavaScript, TypeScript. Anything else falls back to quoted runs: single, double or backtick.
That matters because a quoted-run pattern reads every source file through a JavaScript-shaped lens, and on real code the lens is wrong:
| input | quoted runs | its own language |
|---|---|---|
| a Python docstring | missed entirely | one value |
Rust r#"a raw "quoted" string"# |
a raw, string |
one value, quotes intact |
| a Go backtick string | missed entirely | one value |
| a shell heredoc | missed entirely | one value |
| a C# verbatim string | three fragments | one value |
The fallback is still not a consolation prize: an unrecognised format
is a normal, useful answer, which is why it is not an error, and
--format fallback asks for it deliberately.
The trade is honest: unquoted prose yields nothing. Point this at a README and you get nothing back, correctly.
What it reads, and what it drops
One rule, applied to every parsed format:
- keys are never extracted, only values
- non-string primitives are dropped — in a typed format a bare
42is a number and a TOML date is a date - the untyped formats are the exception: INI and
.envhave no types, soPORT=8080yields the string8080 - values are trimmed; empty ones are dropped
- duplicates are kept, in document order.
--dedupeis opt-in, because a string that appears forty times is a different finding from one that appears once, and which of those matters is your call
A directory is walked the way ripgrep walks one: .gitignore honoured,
hidden files skipped, --no-ignore and --hidden to reach the rest. A
file named explicitly is always read.
Positions, and where they stop
Each value is reported with its file and, where it can be found in the source, a 1-based line and column in UTF-16 units — the number your editor shows.
JSON is placed by its parser; everything else by a forward search over the source. Where a parser hands back real ranges they win, so a JSON value is always located even when its escapes mean it appears nowhere literally.
The rest can miss, and the report says how many. A parser resolves
escapes and folds scalars, so a YAML block scalar and a CSV cell with an
escaped quote are correct values that never appear literally in the file.
Those get no position and summary.unlocated counts them. Reporting a
nearby guess would be worse than reporting nothing; a count you can see
is the difference between a limitation and a lie.
Where it goes further than the extension
--multiline. JavaScript's . does not match a newline, so the
quoted-run pattern cannot span lines. The flag belongs to the fallback:
a Python docstring, a Go raw string, a heredoc and a template literal
span lines because their languages say so and need no flag at all. What
is left is a multi-line run in a format nothing here parses — an email
body, a help paragraph, a consent notice — the copy an audit least wants
to miss.
It is off by default, so the default answer is the extension's answer, and the shared corpus keeps the two honest.
Binary files are skipped, and counted. A NUL byte in the first 8KB
means binary — ripgrep's heuristic. Those files get no report line and
cannot fail --strict; the summary says how many, so the walk reaching
more files than the reader got is stated rather than left to be noticed.
A file that is text and still could not be read keeps its named
skipped diagnostic and still fails --strict.
It has no opinions
No spell check. No banned-word list. No reading-level score. No guess at which strings are "user-facing".
Which strings matter is the reviewer's call, and a tool that pre-filtered would decide the audit before the auditor saw it. What you do with the list — grep it, diff it against last release, feed it to a translation memory, hand it to legal — is the part this deliberately stays out of. A contract test asserts no flag asks for a judgment.
Options
--dedupe collapse repeated values to their first occurrence
--format <format> force a format instead of inferring from the name;
an unknown name falls back rather than failing
--values print only the values, one per line, for piping
--multiline let a *fallback* quoted run span lines; a language
whose own syntax spans lines needs no flag
--csv-header skip the first CSV row
--csv-column <n> take only this 0-based CSV column
--stdin read one document from stdin
--hidden walk hidden files and directories too
--no-ignore walk files that .gitignore excludes
As an MCP server
Two tools, both returning { ok, data, diagnostics, meta }:
extract_strings— content in, values out, no positions. Touches no filesystem. The npm server ships the same tool with byte-identical output; one corpus runs against both.string_le_scan— files or directories in, the same reports the CLI writes, positions included.
ok means the scan ran, never that it found something. A file with no
strings is a result, not an error.
The other four ways to run it
| Where | What you get | Install |
|---|---|---|
| VS Code | The same extraction, in your editor, on a keystroke | Marketplace |
| Cursor, VSCodium, Windsurf | The same extension | Open VSX |
| Any MCP agent, via Node | extract_strings over stdio |
npx string-le-mcp · npm |
| Zed | The MCP server as a context server | add it by hand (no listing yet) |
All sixteen LE tools are on letools.dev.
Documentation
| What | Where |
|---|---|
| What this tool is allowed to say — scope, output contract, refusals, non-goals | SPEC.md |
| How the code is written and held together — architecture, invariants, the gates | AGENTS.md |
| The VS Code extension this shares its extraction with | README.md |
| What changed | CHANGELOG.md |
| The tool's page, and the other fifteen | letools.dev/tools/string-le |
More from the LE family
Sixteen single-purpose tools for the work in front of every model. Each ships a Rust CLI and an MCP server. One page: letools.dev
Get it out
- String-LE — Extract every string in a codebase, with its position, so a person can read them
- Numbers-LE — Extract every hardcoded number in a codebase, so a person can check them
- Units-LE — Extract every quantity with its unit, normalized, and refuse the ambiguous ones by name
- Dates-LE — Extract every date and timestamp, and the exact instant each one resolves to
- IDs-LE — Extract every UUID, ULID, NanoID, ObjectId and Snowflake, and decode the time inside
- IPs-LE — Extract every IP address, CIDR block and MAC, normalized and classified by scope
- URLs-LE — Extract every URL in a codebase, with its protocol and exact position
- Paths-LE — Extract every file path in a codebase, and say whether it still points at anything
- Colors-LE — Extract every color in a codebase, and say which ones are not in your palette
Check it
- Regex-LE — Find every regex in a codebase, and report which can be driven into catastrophic backtracking
- Versions-LE — Find where one dependency is constrained differently across a repository's manifests
- i18n-LE — Identify the i18n library a project uses, then audit its catalogs by that library's rules
- Scrape-LE — Check whether a page is scrapeable before the scraper is written, and say when it cannot tell
Guard it
- Secrets-LE — Find hardcoded credentials in a codebase, and never print one into the report
- EnvSync-LE — Compare the dotenv files in a tree, and say which keys are missing from which
- Unicode-LE — Find the Unicode that hides meaning — bidi controls, invisibles, homoglyphs, mixed scripts
Each stands on its own: no shared crate, no published core. Where two of them agree, it is because the same answer was right twice.
Contact — nolindnaidoo.com · GitHub · LinkedIn
Also by nolindnaidoo
Rust — pixelcoords and pixelactions are one loop: pixelcoords answers where, pixelactions acts there. Their own tools, their own voice — not part of the LE family.
- pixelcoords — Freeze your screen, mark regions, get pixel-exact coordinates and crops pixelcoords.dev · crates.io · docs.rs
- pixelactions — Consume human-verified coordinates, perform the interaction, confirm it landed pixelactions.dev · crates.io · docs.rs
License
MIT — see LICENSE.