# string-le — Rust specification
A port of the [String-LE](https://github.com/nolindnaidoo/string-le) VS
Code extension to a Rust CLI and MCP server: get every string value out
of a codebase so a person can read them.
**Parity first.** For extraction, the extension is the reference
implementation. The values this produces for a given document, and the
order they come in, must match what the extension produces. A difference
is a regression until proven otherwise.
## The one question
**What does this codebase actually say?**
Asked over a whole tree rather than a buffer, answered into a file a
person who does not have the repository open can read.
## Who asks it
Not the author of the code. The reviewer of it: a QA lead checking every
user-visible message before a release, a legal or compliance reader
looking for a claim the product is not allowed to make, a localisation
owner finding what was never put in a catalog, an accessibility auditor
reading the copy a screen reader will say.
None of them has the editor open, and several of them cannot be given a
checkout at all. That is the whole reason this half exists: the extension
answers this for one buffer for the person who wrote it, and this answers
it for a repository, into a file, for the person who did not.
It follows that **the fallback extractor is the main event, not the edge
case.** A `.ts` file is not a format this parses, so it falls through to
quoted-string extraction — and the quoted strings in a `.ts` file are
exactly the user-facing copy the reviewer came for. An unrecognised
format is a normal, useful answer here, which is why it is not an error.
## Shape
**One crate.** Self-contained: no published `-core`, no shared crate with
the family, and nothing holding this code equal to the similar files in
the sibling repos. Where they agree it is because the same answer was
right twice; where they diverge that is the point.
```
crate/
├── src/
│ ├── extract/ pure: the seven extractors, value collection,
│ │ positions. No filesystem, pub(crate).
│ ├── walk.rs ignore-aware tree walking and format detection
│ ├── scan.rs one file end to end — the only path either surface calls
│ ├── cli.rs the terminal surface
│ └── mcp/ the agent surface
└── fixtures/ the shared corpus, read by both frontends
```
**`extract/` touches no filesystem** and carries the **90% line coverage
floor per module**.
## Extraction — parity scope
### Seven extractors, one collection rule
`json`, `yaml`, `csv`, `toml`, `ini`, `env`, and `fallback` for
everything else. Aliases resolve as the extension resolves them —
`jsonc`→json, `yml`→yaml, `tsv`→csv, `cfg`/`conf`→ini, `dotenv`→env — and
a name that resolves to nothing is `fallback` rather than a refusal.
The four parsed formats (`json`, `yaml`, `toml`, `ini`) share one rule,
ported from `collectStrings`:
- **keys are never extracted, only values**
- non-string primitives are dropped — in a typed format a bare `42` is a
number, and a TOML date is a date
- **untyped line formats are the exception**: INI and `.env` parse every
value as text, so a numeric-looking value *is* a string there
- values are trimmed; empty and whitespace-only values are dropped
- recursion stops at depth 1000
`env` is line-based: `export ` prefixes stripped, `#` comments skipped,
inline comments removed from unquoted values only, surrounding quotes
removed. `csv` reads every cell, with optional header skip and optional
single-column selection. `fallback` takes quoted runs — double, single
or backtick.
### The order is the contract
Values come back in document order, duplicates included. `--dedupe` is
opt-in, because it is opt-in in the extension: a string that appears
forty times is a different finding from one that appears once, and which
of those matters is the reader's call.
## Positions — the addition
The extension returns values and no positions, and `extract_strings`
keeps it that way on both servers. Its callers have the buffer open.
A reviewer reading a 40,000-line extract does not, so **the CLI and this
crate's own tool report where each value came from**: always the file,
and a 1-based line and column when the value can be located in the
source. This is outside parity scope — the extension has nothing to
disagree with.
**JSON is placed by its parser; the other six by a forward cursor.** The
jsonc AST carries a range for every literal, so JSON needs no search and
can place the values a search never finds. The cursor walks the source
matching each extracted value in turn from where the previous one ended;
extraction already yields values in document order, so it never has to
guess between two occurrences of the same string.
Positions are outside parity scope, so a format may be placed however it
can be placed honestly. Where a parser offers spans, they win.
**A value that cannot be located reports no position, and the summary
counts how many.** This is the honest failure: a parser resolves escapes
and folds scalars, so a YAML block scalar and a CSV cell containing an
escaped quote are real values that never appear literally in the source.
Reporting a nearby guess would be worse than reporting nothing; a count
the reader can see is the difference between a limitation and a lie.
JSON used to be the largest source of these and now has none, which is
the whole reason it earned spans.
**Columns are UTF-16 code units**, 1-based, matching what an editor shows.
## Output contract
**stdout is protocol, stderr is human.** One JSON report per line, one
line per file.
```json
{
"file": "src/ui/messages.ts",
"format": "fallback",
"strings": [
{ "value": "Delete this permanently?", "line": 12, "column": 18 },
{ "value": "Are you sure?", "line": 13, "column": 18 }
],
"diagnostics": [],
"summary": { "strings": 2, "unlocated": 0 }
}
```
### Exit codes are the API
Following grep, as urls-le does, and for the same reason — this tool
reports what is there and holds no opinion about it:
- **0** — strings found.
- **1** — none found. An answer, not an error.
- **2** — the question was malformed: an unknown flag, an unreadable
input, a path that does not exist.
## The CLI surface
```
string-le mcp
string-le --version | --help
Options:
--dedupe collapse repeated values to their first occurrence
--format <format> force a format instead of inferring it from the
file name; an unknown name falls back rather than
failing
--values print only the values, one per line, for piping
--multiline let a quoted run span lines, so a multi-line
template literal is read too
--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
```
`--values` exists because the reviewer's next step is almost always
another tool — a spellchecker, a diff against last release, a
translation memory — and making them run `jq` first is a tax on the
person this was built for.
## The MCP surface
- **`extract_strings` belongs to both servers.** The npm server and this
one offer the same tool: same schema, same envelope, byte-identical
output, **values only and no positions**.
`fixtures/mcp-extract-strings.json` runs against both.
- **`string_le_scan` is this server's own**: files or directories in,
the same reports the CLI writes, positions included.
**Refusals speak the caller's vocabulary.** No message here names a flag.
## Where this deliberately differs
Three places, each opt-in or reported, never silent.
**`--multiline`.** JavaScript's `.` does not match a newline without the
`s` flag, so the extension's quoted-run pattern cannot span lines and a
multi-line template literal is invisible to it. Off by default, so the
default answer is the extension's answer. Asked for, the fallback reads
those runs — an email body, a help paragraph, a consent notice is exactly
the copy an audit least wants to miss, and the terminal has no reason to
inherit a limit that exists because a regex in an editor did not set a
flag.
**Nesting limits.** Each parser here guards its own depth — jsonc-parser
at 512, saphyr at 256 — below the 1000 the extension's walk stops at. A
document deeper than that comes back here as a **reported parse failure**
and there as a silently half-read document. Both yield nothing useful;
only one of them says so.
**Positions**, which the extension does not have at all, so there is
nothing to differ with.
Everything else is parity, and the corpus is what proves it.
## Non-goals
- **It does not judge a string.** No spell check, no banned-word list, no
tone or reading-level score, no "this looks user-facing" guess. Which
strings matter is the reviewer's call, and a tool that pre-filtered
would decide the audit before the auditor saw it.
- **It does not rewrite anything**, and never writes to a scanned file.
- **It does not extract keys**, in any format.
- **No network, ever.**
## Not in v1
- **Parser-exact spans for the other six formats.** JSON has them because
its parser already carried ranges; TOML, YAML and CSV would each need a
position-preserving parser bought for the purpose. The `unlocated`
count is what says whether that is worth it.
- **CSV streaming.** The extension streams for large files because it
must stay responsive in an editor; a CLI that reads a file and exits
has no such constraint.
- **A baseline file** for accepting known strings.
## Files that cannot be read
Exit 2 means the *question* was malformed — an unknown flag, an
unreadable format name, a path that does not exist. It does not mean one
file in fifty thousand was a PNG.
A file that is not UTF-8 text, or that cannot be opened, is:
- named on stderr,
- carried in the JSON report with a `skipped` diagnostic saying why,
- and left out of the exit code.
`--strict` turns any skipped file back into exit 2, for a pipeline that
wants zero tolerance. What is never allowed is the third option: a file
that silently vanishes from the report, which reads to whoever ran it as
a file that was clean.
## The byte-order mark
A leading BOM is stripped before extraction. It is three invisible bytes
that Notepad, Excel and a PowerShell redirect all add, and that VS Code
removes before the extension sees a document — so leaving it in means
the two frontends read the same file differently. It shifts every column
on the first line, and in a structured format it can lose the document
entirely.
A BOM anywhere other than the start is a zero-width no-break space and
belongs to the text.