Useful? A star is how other developers find it — ★ GitHub · letools.dev/tools/numbers-le
Somebody has to verify that the rate in the code is the rate in the specification. A tax percentage, a retention window, a rounding boundary, a retry limit. In a regulated setting that check is a deliverable, and the person doing it is an auditor or an actuary or a compliance reviewer — usually without a checkout, always without the editor open.
A magic-number linter does not serve them. It tells a developer to hoist a literal into a constant; it never hands anyone the list of every literal with its file and its line.
Sixty seconds
|
# the point of the whole thing:
|
./config.json:2:11 8080
./rates.env:1:5 0.2
./src/pricing.ts:1:17 1.15
3 numbers in 3 files
Exit codes follow grep — 0 numbers 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 numbers-le |
Any platform, needs Rust 1.88+. |
| From source | git clone https://github.com/nolindnaidoo/numbers-lecd numbers-le/crate && cargo build --release |
The same build CI runs. |
No runtime, no network, nothing written.
How a number is printed is the contract
JavaScript numbers are IEEE-754 doubles and so are Rust's, so the values agree by construction. The strings do not.
| value | JavaScript | Rust's default |
|---|---|---|
1e21 |
1e+21 |
1000000000000000000000 |
1e-7 |
1e-7 |
0.0000001 |
-0 |
0 |
-0 |
This tool's whole output is numbers rendered as text, so it implements
ECMAScript's Number::toString rather than reaching for Rust's: shortest
round-trip digits, decimal notation while 1e-6 ≤ |x| < 1e21,
exponential with an explicit sign outside it. Both boundaries are pinned
by the corpus the extension also builds against.
That is also why value is a string in the JSON report. Re-encoding
through a JSON number would hand you whatever your parser prints, which
is the one thing this exists to control.
What counts as a number
One policy, shared by every format:
- Only finite numbers.
NaNand±Infinityare rejected even where a format can express them — YAML.inf, TOMLnan. An extractedInfinityis noise to everything downstream. - Coercion is per format. INI,
.envand CSV values are inherently text, soPORT=8080is the number 8080. JSON, YAML and TOML tell42from"42", and a quoted number there is data — a version pinned as a string, an id that must not lose its leading zero. - A coerced string must be numeric in full.
12abc,1.2.3,0x1Aand1_000are rejected.parseFloatread the first two as12and1.2, and a version string quietly becoming a number is the kind of wrong an audit cannot see. - Dates are not numbers, so a TOML datetime stays out.
The parsers decide more than the policy does. 0x1A is rejected as a
coerced string in INI and accepted as 26 in YAML and TOML, because
those parsers resolve it before the policy ever sees it. Both frontends
inherit that, and the corpus pins it.
Source languages have a literal reader
python rust go java kotlin csharp cpp c javascript typescript sql shellscript, by language id or by file extension, are read by a
numeric-literal extractor: hex 0xFF, binary 0b1010, octal 0o755 and
legacy 0755, separators 1_000_000 and 1'000, suffixes 123n,
1.5f, 10u32, 100L.
Type names are not numbers. u32, i64, f32 and usize report
nothing. Under the text scan they reported 32, 64, 32 — a Rust file
yielded numbers that were never in it.
A dialect changes an answer, so the language is not a label: 0755
is 493 in C, C++, Go and Java, and 755 in Rust, Python 3, Kotlin and C#.
The text scan has no grammar
For a format nothing here parses and no language claims — Markdown, a
log, plain text — numbers come from scanning the raw text. v1.2.3
reads as 1.2 and 0.3.
That is not a defect to report; it is what the extension does, and a scan with no parser cannot know a version string is one token. It is why the scan is now reserved for prose.
Notation
Every finding carries how the literal was written — decimal, hex,
binary, octal, scientific, bigint — because 0x1A and 26 are
the same number and not the same line of code.
It follows coercion. A typed format hands over a number its parser
already resolved, so JSON, YAML and TOML report decimal; INI, .env
and CSV parse their own text and keep what it said; source languages and
the text scan keep everything.
Binary files
A NUL byte in the first 8 KiB — ripgrep's own test — and the file is
never opened as text: no report line, no effect on the exit code, and a
count on stderr so coverage is never overstated silently. A file that
is text and could not be read keeps its named diagnostic and still
fails --strict.
Positions, and where they stop
Each number is reported with its file and, where it can be found, a 1-based line and column in UTF-16 units — the number your editor shows.
Finding it is harder than for text, because a number's source and its
printed form are often different: 0x1A is reported as 26, +7 as
7, 1e21 as 1e+21. So the search is by value, not by text — scan
the document for numeric runs, and pair each number with the next run
that parses to the same double.
Two consequences, both honest:
- A run the scanner cannot see — a hex literal, an underscored literal —
has no offset to give. That number reports no position and
summary.unlocatedcounts them. - A run in a key can take the match. In
k26 = 0x1Athe extracted26finds the digits in the key. The number is right; the position is a best effort, and it is forward-only so it can never point above a number already reported.
JSON, the source languages and the text scan skip all of that: JSON
walks an AST with real ranges, and the other two are scanners — which
is why a hex literal is placed in a .rs file and unplaced in a .toml
one.
It has no opinions
No magic-number heuristic. No range check. No "this looks like a rate" guess. No arithmetic, and nothing is ever rewritten.
Which numbers matter is the reviewer's call, and a tool that pre-filtered would decide the audit before the auditor saw it. 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 to a text scan
--strict exit 2 if a text file could not be read
--values print only the numbers, one per line, for piping
--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_numbers— content in, numbers out, no positions. Touches no filesystem. The npm server ships the same tool with byte-identical output, tokens included; one corpus runs against both.numbers_le_scan— files or directories in, the same reports the CLI writes, positions included.
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_numbers over stdio |
npx numbers-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/numbers-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.