A transliteration of pyright 1.1.412
(git tag 1.1.412) from TypeScript to Go, done bottom-up through the dependency
graph.
The goal is behavioral identity with the TypeScript sources, not a redesign. Where the Go differs, the reason is written down at the point of difference.
The port lives in go/; everything else in the repository is the
unmodified pyright 1.1.412 tree it was transliterated from and is verified
against. go/PORTING.md is the authoritative status
document; go/BENCHMARKS.md has the performance
numbers and methodology; go/UPSTREAM-BUGS.md
collects the pyright bugs found along the way.
Measured on a ~3,150-file production codebase with its virtual environment
active (all third-party imports resolving), against pyright 1.1.412 under
Node, identical diagnostics in every row — full tables and methodology in
go/BENCHMARKS.md:
| invocation | pyright 1.1.412 | Go port |
|---|---|---|
| full check, single-threaded | 1× | ~0.9× the wall time, ~0.65× the CPU |
| full check, no cache | 59 s (--threads) |
115 s (--threads 4) |
--cachedir, nothing changed |
n/a — pyright has no cache | 5.0 s / 0.6 GB |
--cachedir, typical (leaf) file changed |
n/a | ~7 s |
| pre-commit file list (1,779 files), warm | 107 s every run | 5.8 s |
The two implementations differ in shape, not just speed. Uncached, pyright's
multi-process --threads is still faster on this heavily-interconnected
project. The port's result is
--cachedir: a run-to-run cache keyed by each file's transitive dependency
closure, so unchanged files replay their diagnostics and a changed file
re-checks only its reverse import closure — while import resolution reruns
fresh every time, which is what keeps a new file shadowing a module from
serving stale results. The recurring invocation — CI on a mostly-unchanged
tree, a pre-commit hook — answers in seconds:
pyright-go --threads 4 --cachedir .pyright-cachePyright's own TypeScript tests are run against the Go implementation, rather
than being transliterated into Go tests. cmd/tokenserver exposes the Go
tokenizer and parser over a JSON protocol; tools/ts-bridge swaps the modules
under test for shims that forward to it, then runs the unmodified test files.
src/tests/tokenizer.test.ts:
91 passed, 0 failed, 0 skipped, 91 total
src/tests/parser.test.ts:
23 passed, 0 failed, 4 skipped, 27 total
src/tests/typePrinter.test.ts, src/tests/symbolNameUtils.test.ts and
src/tests/typeCacheUtils.test.ts:
6 passed, 0 failed, 0 skipped, 6 total
7 passed, 0 failed, 0 skipped, 7 total
2 passed, 0 failed, 0 skipped, 2 total
src/tests/pathUtils.test.ts, src/tests/uri.test.ts and
src/tests/importResolver.test.ts:
63 passed, 0 failed, 0 skipped, 63 total
95 passed, 0 failed, 0 skipped, 95 total
34 passed, 0 failed, 0 skipped, 34 total
The 4 skipped cases drive the fourslash harness, which needs a live
in-process Program that a stateless bridge cannot provide. They are
reported as SKIP with a reason rather than being dropped or counted as
passing.
On top of that, corpus differentials run both implementations over every
file in src/tests/samples and diff the full output.
compare-corpus.js covers the tokenizer — every token field, the line ranges,
the type: ignore / pyright: ignore maps, and the derived predominant
line-ending/indent/quote statistics:
1302 files compared, 0 mismatched
compare-ast.js covers the parser — every node type, every range, every flag,
every string, every numeric literal (compared as IEEE bit patterns, so no float
formatting is involved), plus every diagnostic message and position:
1343 identical, 0 different, 1343 total
This is the check that carries the most weight, and it earns it: it is what
caught the diagnostic-addendum indentation being two ordinary spaces in the Go
port where diagnostic.ts writes two non-breaking spaces (U+00A0). Nothing else
in the suite would have noticed.
compare-config.js covers the service's config path, which config.test.ts
cannot: that test mutates ConfigOptions in place and asserts on object
identity, neither of which survives a stateless bridge. Instead both
implementations build an AnalyzerService over every project directory under
src/tests/samples, in command-line and language-server mode, and the whole
resulting ConfigOptions is diffed — every scalar, every file spec's compiled
regular expression, the 96-field diagnostic rule set, every execution
environment, and the files the source enumerator finds:
78 identical, 0 different, 78 total
The Go-native tests in common/, parser/, analyzer/ and vfs/ cover the
pieces the TypeScript suite does not reach directly, such as the identifier
lookup tables and the in-memory file system.
Everything below runs from the go/ directory.
Go tests:
go test ./...The TypeScript-against-Go bridge needs the 1.1.412 sources, an esbuild
binary, and a handful of Node packages -- install them in one command, because
--no-save prunes anything not named:
npm install --no-save esbuild vscode-uri vscode-jsonrpc vscode-languageserver vscode-languageserver-textdocument jsonc-parser smol-toml tmp fs-extra @yarnpkg/fslib @yarnpkg/libzipExtract the reference tree once, then run everything:
make refmake bridgeIndividual checks have their own targets (make bridge-tests,
make bridge-ast, make bridge-binder, make bridge-config, ... -- see the
Makefile).
make bridge-evaluator-tests is the evaluator gate: pyright's own type
evaluator and checker tests -- 1,279 cases across nine files -- run
unmodified: 1,269 pass, 0 fail, 10 skipped (the skips drive the fourslash
harness, which needs a live in-process Program).
make bridge-types-full is the per-node type differential: the printed type
of every name in every corpus file -- 88,477 names over 1,343 files --
diffed one name at a time, which is what localizes an evaluator failure to a
single expression rather than an error count. make bridge-types is the
faster sampled variant.
Beyond the corpus, go/PORTING.md documents the whole-project differential: the
CLI run against a real 3,138-file project alongside pyright 1.1.412, zero
diagnostic differences in both single-threaded and --threads modes.
make bridge-binder-oracle, make bridge-config-oracle,
make bridge-evaluator-oracle and make bridge-types-oracle run those checks'
TypeScript side alone and
report what it produced. They need no Go server, so they validate the harness
independently of the port — which is how two defects in the config oracle were
caught before anything was compared against it.
typePrinter.test.ts works differently from the others: rather than shipping
data to Go, the shim records the test's type-construction calls and replays
them on the Go side, so it exercises the Go types.ts port as well as the
printer.
Text is UTF-16. This is the single most consequential decision. JavaScript
strings are sequences of UTF-16 code units, and every offset pyright records —
token starts, node ranges, diagnostic positions — is a code unit offset. Go
strings are UTF-8, so using them would silently shift every offset in any file
containing non-ASCII text. common.Text is therefore []uint16, with
CharCodeAt/Length/Substring carrying JavaScript's semantics.
This is not merely about offsets. stringTokenUtils.ts builds its output with
String.fromCharCode, which truncates to 16 bits, so "\U0001f600" unescapes
to the single code unit 0xf600 rather than the astral code point, and
"\ud800" produces an unpaired surrogate. Neither survives a UTF-8 round trip.
Identifier values are the exception and are plain Go strings: an identifier
cannot contain an unpaired surrogate, so that conversion is lossless, and every
consumer wants a comparable map key.
Optionality. TypeScript's undefined becomes a Go pointer, a nil slice/map,
or an explicit ok return, chosen so that code branching on === undefined
keeps branching the same way. PythonVersion.Micro is a *int for exactly this
reason.
Discriminated unions. Token shapes become a Token interface plus concrete
structs embedding TokenBase; token as StringToken becomes a type assertion.
Numbers. NumberToken.value is number | bigint in TypeScript, and which
arm the tokenizer picks changes the stored value of large integer literals.
NumberValue keeps the distinction. jsnumber.go reimplements parseInt,
parseFloat and BigInt because the exact rounding and accepted-prefix rules
decide what gets stored.
Generated files. localization/localize_gen.go (9.3k lines) and
parser/unicode_gen.go (3.7k lines) are generated from the TypeScript sources
by localization/gen/generate.js and parser/gen/generate_unicode.js. Both
inputs are bulk data — 847 message accessors and 3241 Unicode ranges — where
retyping by hand would be the least reliable option available. Regenerate rather
than edit.
Concurrency. JavaScript is single-threaded, so the TypeScript sources use
unguarded module-level mutable state freely. Where Go callers could reasonably
parse in parallel, that state is guarded (sync.Once for the identifier lookup
tables, a mutex for the localization tables). The one place this changes
structure is documented on ensureIdentifierCharMap, and
TestFastTableUnchangedByFullBuild pins the equivalence.
Deliberate divergences. There are three, all commented in place:
ParameterizedString.formatuses a literal replacement. The TypeScript version passes the substituted value as the replacement argument ofString.prototype.replace, which gives$&,$1and friends special meaning inside a substituted type or symbol name. That is an artifact of the JavaScript API rather than intended behavior.- The lazy identifier-table build does not rewrite the 256-entry fast table,
because it would rewrite it with the values already there while racing
readers. See
ensureIdentifierCharMap. shallowCopyWithNewIDuses reflection where_parseExpressionStatementusesObject.assign({}, leftExpr). The TypeScript is itself type-agnostic there, and a 78-case type switch would only add somewhere for the two to drift apart.
Performance comments in the TypeScript sources that describe V8-specific
behavior (detachSubstring, cloneStr, the two-shape token objects) were
evaluated individually rather than copied: detachSubstring and cloneStr
survive because Go subslices retain their backing array for the same reason V8
sliced strings retain their parent, while the two-shape object pattern does not,
because a nil slice header costs nothing.