// mtsc — the single entry point of this toolchain.
//
// It is two things at once, and the dispatch in `main` is where they
// meet:
//
// * a TypeScript -> JavaScript compiler whose command line is a
// SUPERSET of `tsc` / `tsgo` (see `tsc_options.mbt` for the three
// tiers that claim rests on, and `tsconfig_project.mbt` for `-p`);
// * four verbs that used to be four separate binaries —
// `mtsc check` (was `tscheck`), `mtsc conformance` (was `tsacc`),
// `mtsc bridge` (was `ts2mbt`) and `mtsc pkg` (was `mbt2ts`).
//
// A verb wins over a file of the same name. That is the one place the
// two halves can collide, and resolving it toward the verb is what keeps
// `mtsc check` from depending on whether a directory called `check`
// happens to exist; a source file is spelled `x.ts`, so nothing real is
// shadowed. `mtsc ./check` still names the file.
//
// The compile pipeline itself is unchanged:
//
// parse → JSX-runtime lowering → (optional) identifier mangling → emit
//
// When neither `--out` nor `--outDir` is given the JS is written to
// stdout. With `--sourceMap`, a v3 map is also written next to the
// output file (`.map`); a `//# sourceMappingURL=` footer is
// appended to the JS so loaders can pick it up. Mangling and source-maps
// are mutually exclusive for now — the source position table doesn't
// survive the rename pass yet.
//
// Every input is type-checked before it is transformed. `mtsc` uses the
// checker's strict entry point so diagnostics match `mtsc check
// --strict`, and never writes JavaScript when the checker finds an
// issue.
///|
async fn mtsc_build_jsx_config(
opts : MtscOptions,
entry_path : String,
) -> @transform.JsxConfig {
let mut cfg = @transform.JsxConfig::default()
// tsconfig first, then the CLI flags, then (inside the transform) the
// per-file pragmas — increasing precedence, matching what TypeScript
// does with the same three sources.
//
// mtsc read `paths` out of tsconfig and nothing else, so a preact /
// solid / vue-JSX application compiled to
// `import { jsx } from "react/jsx-runtime"` and could not run at all.
// The `/** @jsxImportSource preact */` pragma was the only form
// supported, and no application uses it — the setting goes in
// tsconfig, per package, and in a monorepo it is inherited through
// `extends` (sprawlens: `jsx` in the base config, `jsxImportSource`
// in the package's own).
match @parser.tsconfig_compiler_option(entry_path, "jsx") {
Some(jsx) =>
// `preserve` and `react-native` leave JSX in the output, which
// this pipeline cannot do — it always lowers — so they map to the
// automatic runtime rather than being honoured.
match jsx {
"react" => cfg = cfg.with_runtime("classic")
"react-jsxdev" => cfg = cfg.with_runtime("automatic").with_dev(true)
_ => cfg = cfg.with_runtime("automatic")
}
None => ()
}
match @parser.tsconfig_compiler_option(entry_path, "jsxImportSource") {
Some(src) => cfg = cfg.with_import_source(src)
None => ()
}
match @parser.tsconfig_compiler_option(entry_path, "jsxFactory") {
Some(f) => cfg = cfg.with_pragma_jsx(f)
None => ()
}
match @parser.tsconfig_compiler_option(entry_path, "jsxFragmentFactory") {
Some(f) => cfg = cfg.with_pragma_jsx_frag(f)
None => ()
}
match opts.jsx_runtime {
Some(r) => cfg = cfg.with_runtime(r)
None => ()
}
match opts.jsx_import_source {
Some(s) => cfg = cfg.with_import_source(s)
None => ()
}
// `--jsxFactory` / `--jsxFragmentFactory` had no command-line spelling
// at all: the two settings were read from tsconfig above and from the
// per-file pragma inside the transform, and the flags `tsc` offers for
// them fell through to "unknown option". The CLI sits above tsconfig
// in the same precedence order as the other two.
match opts.jsx_factory {
Some(f) => cfg = cfg.with_pragma_jsx(f)
None => ()
}
match opts.jsx_fragment_factory {
Some(f) => cfg = cfg.with_pragma_jsx_frag(f)
None => ()
}
if opts.jsx_dev {
cfg = cfg.with_dev(true)
}
cfg
}
///|
fn string_to_utf8_bytes(s : String) -> Bytes {
// MoonBit's `String` is a sequence of UTF-16 code units. Real
// UTF-8 encoding has to:
// * 1 byte for ASCII (< 0x80)
// * 2 bytes for U+0080..U+07FF
// * 3 bytes for U+0800..U+FFFF (BMP), excluding the surrogate
// range
// * 4 bytes for U+10000..U+10FFFF (built from a surrogate
// pair: high in U+D800..U+DBFF, low in U+DC00..U+DFFF)
//
// The previous implementation kept only the low byte of each
// unit, which truncated everything ≥ U+0080 and produced
// mojibake for non-ASCII strings ("regulární" -> "regul�rn�").
let arr : Array[Byte] = []
let mut i = 0
while i < s.length() {
let unit = s[i].to_int()
if unit < 0x80 {
arr.push(unit.to_byte())
i = i + 1
} else if unit < 0x800 {
arr.push(((unit >> 6) | 0xC0).to_byte())
arr.push(((unit & 0x3F) | 0x80).to_byte())
i = i + 1
} else if unit >= 0xD800 && unit < 0xDC00 {
// High surrogate: combine with the next low surrogate.
let mut done = false
if i + 1 < s.length() {
let lo = s[i + 1].to_int()
if lo >= 0xDC00 && lo < 0xE000 {
let cp = (((unit - 0xD800) << 10) | (lo - 0xDC00)) + 0x10000
arr.push(((cp >> 18) | 0xF0).to_byte())
arr.push((((cp >> 12) & 0x3F) | 0x80).to_byte())
arr.push((((cp >> 6) & 0x3F) | 0x80).to_byte())
arr.push(((cp & 0x3F) | 0x80).to_byte())
i = i + 2
done = true
}
}
if !done {
// Unpaired high surrogate → U+FFFD replacement (`EF BF BD`).
arr.push((0xEF : Int).to_byte())
arr.push((0xBF : Int).to_byte())
arr.push((0xBD : Int).to_byte())
i = i + 1
}
} else if unit >= 0xDC00 && unit < 0xE000 {
// Stray low surrogate → U+FFFD.
arr.push((0xEF : Int).to_byte())
arr.push((0xBF : Int).to_byte())
arr.push((0xBD : Int).to_byte())
i = i + 1
} else {
arr.push(((unit >> 12) | 0xE0).to_byte())
arr.push((((unit >> 6) & 0x3F) | 0x80).to_byte())
arr.push(((unit & 0x3F) | 0x80).to_byte())
i = i + 1
}
}
Bytes::from_array(arr)
}
///|
async fn write_text(path : String, content : String) -> Bool {
let _ = @fs.write_file(path, string_to_utf8_bytes(content), create=0o644) catch {
e => {
println("write error \{path}: \{e}")
return false
}
}
true
}
///|
fn mtsc_dirname(path : String) -> String {
match path.rev_find("/") {
Some(i) => path[0:i].to_owned()
None => ""
}
}
///|
fn mtsc_basename(path : String) -> String {
match path.rev_find("/") {
Some(i) => path[i + 1:].to_owned()
None => path
}
}
///|
/// A specifier the loader resolves against the filesystem itself, rather
/// than handing to package resolution: relative (`./x`, `../x`, `.`,
/// `..`) or rooted (`/x`).
///
/// One predicate for all four test sites in the graph walk. The import
/// loop and the re-export loop are mirror images of each other and each
/// tests this twice — once to decide whether a type-only edge is worth
/// following, once to pick the resolve branch — so an inlined test is
/// four copies that have to agree. `.` and `..` were missing from all
/// four; see `@transform.is_relative_specifier`.
fn mtsc_is_local_specifier(spec : String) -> Bool {
@transform.is_relative_specifier(spec) || spec.has_prefix("/")
}
///|
fn mtsc_join_path(dir : String, child : String) -> String {
if dir == "" {
child
} else if dir.has_suffix("/") {
dir + child
} else {
dir + "/" + child
}
}
///|
fn mtsc_normalize(path : String) -> String {
let parts : Array[String] = []
for part in path.split("/") {
let p = part.to_owned()
if p == "" || p == "." {
continue
}
if p == ".." {
if parts.length() > 0 && parts[parts.length() - 1] != ".." {
let _ = parts.pop()
} else {
parts.push("..")
}
continue
}
parts.push(p)
}
let leading = if path.has_prefix("/") { "/" } else { "" }
leading + parts.join("/")
}
///|
/// An empty `TsModule`, for the paths that need a value but no
/// declarations — a type-side parse that failed, or one that was
/// skipped because nothing downstream reads it.
fn mtsc_empty_module() -> @ast.TsModule {
{
funcs: [],
interfaces: [],
type_aliases: [],
imports: [],
values: [],
classes: [],
local_classes: [],
namespaces: [],
enums: [],
module_augmentations: [],
strict_property_initialization: true,
no_implicit_any: true,
strict_null_checks: true,
use_define_for_class_fields: false,
deprecated_compiler_options: [],
parameter_property_misuses: [],
strict_mode_misuses: [],
invalid_decorator_uses: [],
grammar_misuses: [],
imported_binding_names: [],
import_module_specs: [],
class_base_type_args: [],
param_optional_initializer_misuses: [],
interface_member_modifier_misuses: [],
class_dup_diagnostics: [],
top_level_stmts: [],
}
}
///|
/// Does this path name a file the JavaScript pipeline cannot parse and
/// has no business parsing — a stylesheet, an image, a font, a media
/// file? Bundlers resolve these through loaders; a JS-only bundle has
/// nothing to emit for them.
///
/// An extension list rather than "anything not .ts/.tsx/.js": a resolved
/// path with an unfamiliar extension is more likely a module the
/// resolver mis-resolved than an asset, and silently emptying that would
/// turn a resolution bug into missing code rather than an error.
fn is_non_js_asset_path(path : String) -> Bool {
for
ext in [
".css", ".scss", ".sass", ".less", ".styl", ".svg", ".png", ".jpg", ".jpeg",
".gif", ".webp", ".avif", ".ico", ".bmp", ".woff", ".woff2", ".ttf", ".otf",
".eot", ".mp3", ".mp4", ".wav", ".ogg", ".webm", ".pdf", ".wasm",
] {
if path.has_suffix(ext) {
return true
}
}
false
}
///|
/// Is this asset path a STYLESHEET whose import is going to be read as a
/// value?
///
/// The caller only has the resolved path at this point, not the import
/// declaration that reached it, so this answers the narrower question it
/// can: is it a stylesheet at all. The side-effect form is by far the
/// common one and is handled correctly, so warning on every stylesheet
/// would be noise — which is why the caller pairs this with the
/// value-form check it can make.
fn is_stylesheet_path(path : String) -> Bool {
for ext in [".css", ".scss", ".sass", ".less", ".styl"] {
if path.has_suffix(ext) {
return true
}
}
false
}
///|
/// Walk imports starting from `entry_path` and read every reachable
/// file from disk into `files`. Only relative imports (`./x`, `../y`)
/// and rooted paths are followed — bare specifiers (`react`, `@scope/x`)
/// are left to the bundler to emit verbatim.
async fn mtsc_load_bundle_files(
entry_path : String,
files : Map[String, String],
aliases : Map[String, String],
externals : Map[String, Bool],
type_props : Map[String, Bool],
// Whether anything downstream reads `type_props`. Only
// `--reserve-typed-props` does, and harvesting it costs a full second
// parse of every file in the graph — 0.9 s of the 2.1 s read+parse
// phase on 9 MB of `typescript.js`. Skip it when nobody asked.
want_type_props : Bool,
// `[tokenize, parse, read+decode, resolve]` micros accumulated across
// every file, so `--timing` can separate the lexer from the recursive
// descent and both from the filesystem. Reported as four rows because
// the single "read + parse files" row hid the actual hot spot: path
// resolution, not parsing, was 42-60% of a multi-file compile.
parse_split : Array[Double],
// Every block this walk parses, keyed the way `files` is, so the
// bundler can reuse them instead of parsing the same sources again.
// The walk has to parse each file to find its imports, and the
// bundler has to parse each file to build the module graph — the same
// work, twice, for what is 78-94% of a bundle's wall time.
parsed : Map[String, @ast.TsModuleBlock],
// Import / re-export edges discovered along the way, in the shape the
// graph checker wants: resolution is the CLI's job, and the checker
// needs the edges to resolve a type declared in another module.
edges : Array[@mtsc_core.MtscModuleEdge],
) -> Result[Unit, String] {
// Sequential DFS over the import graph. moonbitlang/async runs on
// a cooperative single-threaded scheduler, so spawning per-file
// tasks adds task-group overhead without overlapping any real
// work — the parser itself stays the dominant CPU cost.
let mut tokenize_micros = 0.0
let mut parse_micros = 0.0
let mut read_micros = 0.0
let mut resolve_micros = 0.0
// The sibling `.d.ts` scan, separately. It was inside the
// "module graph walk" residual, and it is not a walk at all: next to
// `node_modules/typescript/lib/typescript.js` there are 98 `.d.ts`
// files totalling 3.2 MB, and this read and fully parsed every one of
// them. That is what a residual hides.
let mut ambient_micros = 0.0
// The import / re-export edge walk, which is what the "module graph
// walk" residual is actually named after. On a 95-file target it was
// 310 ms with no ambient `.d.ts` in sight, so the label had to be
// checked rather than believed.
let mut edge_micros = 0.0
// Bare-specifier resolution (`react`, `roughjs/bin/math`), separately
// from the relative path math above it. Both are "module resolution",
// and they are not the same cost: one is arithmetic over a cached
// directory listing, the other walks `node_modules` and reads
// `package.json` `exports` / `typesVersions`. Once `cli: resolve
// module paths` became the largest row on a 95-file target there was
// no way to act on it without knowing which half it was.
let mut bare_micros = 0.0
// Time every `mtsc_resolve_existing` call, cache hits included, so the
// row measures what resolution costs the compile rather than what it
// costs on a miss.
async fn timed_resolve(base : String) -> String {
let started = @bench.monotonic_clock_start()
let r = mtsc_resolve_existing(base)
resolve_micros = resolve_micros + @bench.monotonic_clock_end(started)
r
}
let queue : Array[String] = [entry_path]
while queue.length() > 0 {
let disk_path = queue[queue.length() - 1]
let _ = queue.pop()
if files.contains(disk_path) {
continue
}
let resolved = timed_resolve(disk_path)
// A relative import that names no file on disk is not a reason to
// fail the compile. It is what a build-generated module looks like
// from a source checkout: TypeScript's own `checker.ts` imports
// `./_namespaces/ts.performance.js`, which its build produces and
// the repository does not carry. Treat it the way a bare specifier
// is treated — leave the import verbatim and move on. The entry
// file itself is exempt: nothing to compile if that is missing.
if disk_path != entry_path && !mtsc_file_exists(resolved) {
println(
"mtsc: unresolved import \{disk_path} — left as an external import",
)
continue
}
// Already loaded under its real path? Then this is the same module
// reached by a different spelling, and there is nothing to do.
//
// The guard above this loop keys on the SPECIFIER, and for the two
// spellings that resolve to one file that is not the same question.
// `./util` -> `util.ts` gets an alias entry below, so the specifier
// guard catches the second visit. `./util.js` -> `util.ts` cannot:
// the alias is only written when resolution APPENDED an extension,
// and here it replaced one. So nothing deduplicated the
// `.js`-specifier style that TypeScript-with-NodeNext sources use
// everywhere, and each repeat visit re-read the file, re-parsed it,
// and re-pushed its own imports — which repeat in turn.
//
// On a diamond-shaped graph that is 2^depth. Measured on a
// synthetic chain where every module imports both modules of the
// next level: with `.js` specifiers, depth 4/6/8/10/12 took
// 9/19/61/217/853 ms, roughly 4x per two levels; with extension-less
// specifiers the same graphs were flat at 8-11 ms. zod is that shape
// — 133 files, `from "../core/util.js"` 65 times — and had not
// finished PARSING after eighteen minutes.
if files.contains(resolved) {
// Skipping the work is the fix; the alias still has to be written,
// or the bundler cannot resolve the specifier this importer wrote.
// Same condition as the main path below, for the same reason.
if disk_path != resolved &&
resolved.has_prefix(disk_path + ".") &&
!files.contains(disk_path) {
match files.get(resolved) {
Some(src) => files[disk_path] = src
None => ()
}
match parsed.get(resolved) {
Some(mb) => parsed[disk_path] = mb
None => ()
}
}
continue
}
let read_started = @bench.monotonic_clock_start()
// Decide BEFORE reading. A `.woff2` is binary and decoding it as
// UTF-8 fails outright (`utf8.Malformed`), so an asset has to be
// recognised by its path, not by its contents — and skipping the
// read also keeps megabytes of fonts and images out of the compile.
let asset = is_non_js_asset_path(resolved)
let raw_source = if asset {
""
} else {
@fs.read_file(resolved).text() catch {
e => return Err("read error \{resolved}: \{e}")
}
}
read_micros = read_micros + @bench.monotonic_clock_end(read_started)
// A `.json` import is a module whose default export is the parsed
// document. Handing the file to the TypeScript parser instead is a
// syntax error on the first key — `Expected Semicolon, got Colon` —
// and it takes the whole compile with it. Excalidraw's `i18n.ts`
// does `import percentages from "./locales/percentages.json"`, which
// is enough to stop its 1 MB element package from bundling at all.
//
// JSON is a subset of the expression grammar, so the document can
// become a module by wrapping it rather than by teaching the parser
// a second grammar: `export default ;` parses as the
// object / array / scalar literal it already is.
//
// Default export only. That is what an ESM JSON module offers, and
// it is what every import of one in the corpus uses;
// `resolveJsonModule`'s named imports of top-level keys would need
// one `export const` per key and are not synthesized here.
//
// One semantic difference is worth naming: a `"__proto__"` key in an
// object literal sets the prototype instead of becoming an own
// property, where `JSON.parse` makes it an own property. A document
// with that key is mis-modelled.
// An asset import is not JavaScript and has no JavaScript to
// contribute. `import "./ContextMenu.scss"` is a side effect a CSS
// pipeline resolves, and handing the stylesheet to the TypeScript
// parser is `Expected type, got Star` on the first `*` selector.
// Excalidraw reaches one from its element package and the whole
// compile stops there.
//
// Synthesize an empty module instead. The side-effect form then
// contributes nothing, which is correct for a JS-only bundle.
//
// The VALUE form (`import styles from "./x.module.css"`) still gets
// `{}`, and that is not a fix: it is not the class-name map a
// CSS-modules pipeline would build, so a bundle that reads
// `styles.foo` sees `undefined`. The import site reports it — the
// warning lives at the enqueue below, which is where the import
// DECLARATION is in scope and the value form can be told from the
// side-effect one — and the reason for not synthesizing something
// is worth stating.
//
// Scanning the stylesheet for class selectors and handing back an
// identity map (`{ foo: "foo" }`) was the obvious alternative. It
// trades a value that fails LOUDLY — `undefined.length` throws,
// `${styles.foo}` renders "undefined" — for one that fails SILENTLY:
// a plausible class name that matches no rule, because mtsc emits no
// CSS either. Unstyled UI and no error is the worse outcome.
//
// And there is no evidence it matters. All 76 of Excalidraw's
// stylesheet imports are the side-effect form, which is handled
// correctly; the corpus has no value-form CSS import at all. So the
// honest move is to say what cannot be done rather than to guess,
// and to leave the guess for a caller who actually has a pipeline.
let source = if asset {
"export default {};\n"
} else if resolved.has_suffix(".json") {
"export default " + raw_source + ";\n"
} else {
raw_source
}
files[resolved] = source
// The extension-less form is also keyed, so the bundler can look up
// the specifier a module actually spelled (`./sub` -> `/dir/sub`).
// But ONLY when resolution just appended an extension. When it went
// through `/index.ts`, the alias names a DIRECTORY, and a
// module keyed by a directory path resolves its own relative
// imports one level too high: `src/router/trie-router` +
// `./router` becomes `src/router/router`. Hono hits this on its
// first router import.
if disk_path != resolved &&
resolved.has_prefix(disk_path + ".") &&
!files.contains(disk_path) {
files[disk_path] = source
}
// Split so `--timing` says which half of "parse" is which:
// tokenizing builds the whole token array up front, walking it is a
// separate cost, and the two want different fixes.
let tokenize_started = @bench.monotonic_clock_start()
let parser = @parser.Parser::from_source(source)
tokenize_micros = tokenize_micros +
@bench.monotonic_clock_end(tokenize_started)
let parse_started = @bench.monotonic_clock_start()
let mb = parser.parse_module_block() catch {
e => return Err("parse \{resolved}: \{e}")
}
parse_micros = parse_micros + @bench.monotonic_clock_end(parse_started)
// Keyed exactly like `files` above, including the extension-less
// alias, because the bundler looks a module up by whichever form
// the importing specifier spelled.
parsed[resolved] = mb
if disk_path != resolved && resolved.has_prefix(disk_path + ".") {
parsed[disk_path] = mb
}
// Second-pass parse for the TYPE side. The block parser above
// only sees runtime statements; interfaces / type aliases /
// classes live on `TsModule`, so we run a second parse to
// harvest the property names declared in those type
// declarations. They get added to the bundler's reserved-prop
// set so the property mangler never renames a name that's
// part of any module's public type surface.
let module_ = if want_type_props {
@parser.Parser::from_source(source).parse_module() catch {
_ => mtsc_empty_module()
}
} else {
mtsc_empty_module()
}
if want_type_props {
for n in @transform.type_declared_property_names(module_) {
type_props[n] = true
}
}
// Bracketed, minus the resolution accumulated inside it. The first
// version of this span was a plain wall-clock bracket while
// `resolve_micros` accumulated sub-spans INSIDE it, so the two
// OVERLAPPED: the edge walk read 358 ms and looked like the largest
// item on a 95-file target, when almost all of it was the module
// resolution already reported on its own row. An overlapping span
// is not a small error — it is a wrong number that reads like a
// finding, and it sent this investigation at the wrong function.
// Every row here is now disjoint, so they sum.
let edges_started = @bench.monotonic_clock_start()
let resolve_before_edges = resolve_micros + bare_micros
for imp in mb.imports {
// A type-only import contributes no runtime code, so the bundler
// skips it — but the CHECKER needs the module that declares the
// type, or `import type { Loc } from "./types"` leaves `Loc`
// unresolvable and identical shapes read as mismatches. Load and
// edge relative type-only imports; a bare one would drag a whole
// package's declarations in, so those stay skipped.
let spec = imp.module_spec
if imp.type_only && !mtsc_is_local_specifier(spec) {
continue
}
if mtsc_is_local_specifier(spec) {
// A stylesheet imported for its VALUE — `import styles from
// "./x.module.css"` — is the one asset form mtsc cannot model:
// the synthesized module hands back `{}`, so every `styles.foo`
// is `undefined` and the failure is silent. Say so here rather
// than guessing a class-name map; the note at the asset stub
// above says why guessing is worse.
//
// The side-effect form (`import "./x.scss"`) is correct and
// stays silent — all 76 of Excalidraw's stylesheet imports are
// that form, which is why this cannot warn on every stylesheet.
if is_stylesheet_path(spec) &&
(
imp.default_binding is Some(_) ||
imp.namespace_binding is Some(_) ||
imp.named_bindings.length() > 0
) {
println(
"mtsc: \{resolved} imports `\{spec}` for its value, but mtsc has no CSS pipeline — the import yields `{}`, so every class-name lookup on it is `undefined`",
)
}
let combined = if spec.has_prefix("/") {
mtsc_normalize(spec)
} else {
mtsc_normalize(mtsc_join_path(mtsc_dirname(resolved), spec))
}
edges.push({
importerPath: resolved,
moduleSpecifier: spec,
targetPath: timed_resolve(combined),
})
queue.push(combined)
continue
}
if externals.contains(spec) {
continue
}
if aliases.contains(spec) {
continue
}
let bare_started = @bench.monotonic_clock_start()
let bare = @parser.resolve_runtime_module_specifier(resolved, spec)
bare_micros = bare_micros + @bench.monotonic_clock_end(bare_started)
match bare {
Some(found) => {
aliases[spec] = found
edges.push({
importerPath: resolved,
moduleSpecifier: spec,
targetPath: found,
})
queue.push(found)
}
None => ()
}
}
// Re-exports (`export * from "./a"`, `export { X } from "./a"`)
// also pull `./a` into the bundle. Mirror the import loop.
for re in mb.reexports {
// Same reasoning as the type-only import above.
let spec = re.module_spec
if re.type_only && !mtsc_is_local_specifier(spec) {
continue
}
if mtsc_is_local_specifier(spec) {
let combined = if spec.has_prefix("/") {
mtsc_normalize(spec)
} else {
mtsc_normalize(mtsc_join_path(mtsc_dirname(resolved), spec))
}
edges.push({
importerPath: resolved,
moduleSpecifier: spec,
targetPath: timed_resolve(combined),
})
queue.push(combined)
continue
}
if externals.contains(spec) {
continue
}
if aliases.contains(spec) {
continue
}
let bare_started = @bench.monotonic_clock_start()
let bare = @parser.resolve_runtime_module_specifier(resolved, spec)
bare_micros = bare_micros + @bench.monotonic_clock_end(bare_started)
match bare {
Some(found) => {
aliases[spec] = found
edges.push({
importerPath: resolved,
moduleSpecifier: spec,
targetPath: found,
})
queue.push(found)
}
None => ()
}
}
edge_micros = edge_micros +
@bench.monotonic_clock_end(edges_started) -
(resolve_micros + bare_micros - resolve_before_edges)
}
// Ambient declaration files (`declare const MyGlobal: …`) belong to the
// program even though nothing imports them — that is the whole point of
// an ambient declaration. `tsc` picks them up through tsconfig's
// `include`; with no tsconfig to read, take the `.d.ts` files sitting in
// the entry's own directory, which is where the `env.d.ts` /
// `globals.d.ts` convention puts them.
let ambient_started = @bench.monotonic_clock_start()
for ambient in mtsc_sibling_declaration_files(entry_path) {
if files.contains(ambient) {
continue
}
let source = @fs.read_file(ambient).text() catch {
// A directory read that fails is not fatal here: the file is
// supplementary, not part of the module graph.
_ => continue
}
files[ambient] = source
// The PARSE is only here to harvest `type_props`, and only
// `--reserve-typed-props` reads that — the same gate the main loop
// already applies to its own second parse, which this scan never
// got. Next to `typescript.js` that is 98 files and 3.2 MB parsed
// to fill a map nobody was going to look at. Fourth time in this
// pipeline that a condition was applied in one place and not in the
// second consumer.
//
// The READ stays unconditional: `files[ambient]` is what puts an
// ambient declaration into the program for the checker, which is
// the point of the scan (`case03`).
if want_type_props {
let parsed = try
@parser.Parser::from_source(source).parse_module()
catch {
e => Err(e)
} noraise {
m => Ok(m)
}
match parsed {
Ok(module_) =>
for n in @transform.type_declared_property_names(module_) {
type_props[n] = true
}
Err(_) => ()
}
}
}
ambient_micros = ambient_micros + @bench.monotonic_clock_end(ambient_started)
parse_split.push(tokenize_micros)
parse_split.push(parse_micros)
parse_split.push(read_micros)
parse_split.push(resolve_micros)
parse_split.push(ambient_micros)
parse_split.push(edge_micros)
parse_split.push(bare_micros)
Ok(())
}
///|
/// `.d.ts` files in the same directory as `entry_path`, sorted for a
/// deterministic program. Nested directories are not scanned: an ambient
/// file deeper in the tree belongs to whatever entry sits next to it.
async fn mtsc_sibling_declaration_files(entry_path : String) -> Array[String] {
let dir = mtsc_dirname(entry_path)
let names = @fs.readdir(dir, sort=true) catch { _ => return [] }
let out : Array[String] = []
for name in names {
if name.has_suffix(".d.ts") {
out.push(mtsc_normalize(mtsc_join_path(dir, name)))
}
}
out
}
///|
/// The TypeScript files a JS-spelled specifier may actually name.
/// `./x.js` -> `./x.ts`, `./x.tsx`, `./x.d.ts`; `./x.mjs` -> `./x.mts`;
/// `./x.cjs` -> `./x.cts`. Empty when the path does not end in a JS
/// extension, so the ordinary extension search runs unchanged.
fn mtsc_ts_equivalents(base : String) -> Array[String] {
for
pair in [
(".js", [".ts", ".tsx", ".d.ts"]),
(".mjs", [".mts", ".d.mts"]),
(".cjs", [".cts", ".d.cts"]),
] {
if base.has_suffix(pair.0) {
let stem = base[0:base.length() - pair.0.length()].to_owned()
let out : Array[String] = []
for ext in pair.1 {
out.push(stem + ext)
}
return out
}
}
[]
}
///|
/// Resolution caches. A compile is one process over a snapshot of the
/// tree, so nothing here can go stale within a run.
///
/// They exist because module resolution, not parsing, was the largest
/// phase on every multi-file target once `--timing` could separate it
/// from reading: 235 ms of valibot's 394 ms (59.6%) and 30 ms of hono's
/// 71 ms (42.4%), against 12-18% for tokenize plus recursive descent
/// combined. The cause is arithmetic — `mtsc_resolve_existing` probed up
/// to thirteen candidate paths per import, and every probe was an
/// `@fs.exists` plus an `@fs.kind`, so one import cost ~26 syscalls to
/// find one file.
///
/// `mtsc_dir_entries` turns that around: list a directory once, and a
/// candidate that does not exist is then a map lookup rather than a
/// syscall. Only a candidate the listing admits costs an `@fs.kind`, and
/// that is at most one per import.
let mtsc_dir_entries : Map[String, Map[String, Bool]] = Map([])
///|
/// `@fs.kind(path) is Regular`, memoized. Distinct from the listing:
/// the listing says a *name* is there, this says it is a file and not a
/// directory.
let mtsc_regular_files : Map[String, Bool] = Map([])
///|
/// `mtsc_resolve_existing` results. The loader resolves the same path
/// twice per edge — once to record the edge, once when the queue reaches
/// it — and a module imported from n places is resolved n times.
let mtsc_resolved_paths : Map[String, String] = Map([])
///|
/// Drop everything the three caches above hold.
///
/// Their stated premise is that "a compile is one process over a
/// snapshot of the tree, so nothing here can go stale within a run", and
/// `--watch` is the one caller for which that is false: a rebuild exists
/// because the tree changed. Without this, a file created after the
/// first pass stays cached as absent and the rebuild cannot see it.
fn mtsc_clear_resolution_caches() -> Unit {
mtsc_dir_entries.clear()
mtsc_regular_files.clear()
mtsc_resolved_paths.clear()
}
///|
/// The names directly inside `dir`, read once. A directory that cannot
/// be read caches as empty: that is the same answer every candidate
/// under it would have got, and it stops a missing directory from being
/// re-probed per candidate.
async fn mtsc_dir_listing(dir : String) -> Map[String, Bool] {
let key = if dir == "" { "." } else { dir }
match mtsc_dir_entries.get(key) {
Some(m) => m
None => {
let m : Map[String, Bool] = Map([])
let names = @fs.readdir(key) catch { _ => [] }
for n in names {
m[n] = true
}
mtsc_dir_entries[key] = m
m
}
}
}
///|
async fn mtsc_resolve_existing(base : String) -> String {
match mtsc_resolved_paths.get(base) {
Some(r) => return r
None => ()
}
let r = mtsc_resolve_existing_uncached(base)
mtsc_resolved_paths[base] = r
r
}
///|
async fn mtsc_resolve_existing_uncached(base : String) -> String {
if mtsc_file_exists(base) {
return base
}
// A specifier that spells `.js` may name a `.ts` file. That is not a
// quirk — it is what TypeScript's `node16` / `nodenext` / `bundler`
// resolution prescribes, so a modern TS codebase writes
// `import … from "./external.js"` next to `external.ts`. Without this
// rule the whole class of TypeScript-source targets is unreachable:
// zod, and TypeScript's own compiler (`./_namespaces/ts.js`), both
// fail on their first import.
for cand in mtsc_ts_equivalents(base) {
if mtsc_file_exists(cand) {
return cand
}
}
// `.d.ts` is in the list because a declaration-only module is a
// legitimate target of `import type { X } from "./types"`.
let exts = [".ts", ".tsx", ".d.ts", ".js", ".mjs", ".jsx"]
for ext in exts {
let cand = base + ext
if mtsc_file_exists(cand) {
return cand
}
}
for ext in exts {
let cand = base + "/index" + ext
if mtsc_file_exists(cand) {
return cand
}
}
base
}
///|
/// A path that names a readable FILE. `@fs.exists` also answers `true`
/// for a directory, and a directory is not a module: `import "./client"`
/// next to a `client/` directory used to resolve to the directory
/// itself, and the read then failed with "Is a directory" instead of
/// falling through to `client/index.ts`.
async fn mtsc_file_exists(path : String) -> Bool {
match mtsc_regular_files.get(path) {
Some(v) => return v
None => ()
}
let base = mtsc_basename(path)
// `.` and `..` are not in a listing (`readdir` drops the special
// entries) and are directories anyway, so answer without one. An empty
// base means a trailing slash, which likewise names a directory.
if base == "" || base == "." || base == ".." {
mtsc_regular_files[path] = false
return false
}
// The parent's listing settles non-existence for free. Only a name
// that IS there is worth a `kind` syscall.
if !mtsc_dir_listing(mtsc_dirname(path)).contains(base) {
mtsc_regular_files[path] = false
return false
}
let v = (@fs.kind(path) catch {
_ => {
mtsc_regular_files[path] = false
return false
}
})
is Regular
mtsc_regular_files[path] = v
v
}
///|
/// Compatibility wrapper for the command's focused tests. The checker itself
/// lives in `mizchi/ts/mtsc` so the JavaScript module and CLI use one strict
/// implementation.
fn mtsc_collect_type_issues(
source : String,
allow_jsx : Bool,
) -> Result[Array[@mtsc_core.MtscTypeIssue], String] {
@mtsc_core.collect_type_issues(source, allow_jsx)
}
///|
/// Print checker diagnostics for one input and return whether it is clean.
fn mtsc_typecheck_source(source : String, path : String) -> Bool {
let issues = match mtsc_collect_type_issues(source, path.has_suffix(".tsx")) {
Ok(issues) => issues
Err(e) => {
println("mtsc: parse error in \{path}: \{e}")
return false
}
}
if issues.length() == 0 {
return true
}
for issue in issues {
println("mtsc: type error in \{path} [\{issue.path}]: \{issue.message}")
}
false
}
///|
/// Print checker diagnostics for a whole resolved module graph and
/// return whether it is clean.
///
/// Only files that look like real modules are handed over: the loader
/// keys `files` by both the resolved path and the extension-less form a
/// specifier spelled (`./sub` -> `/dir/sub`), and passing the same
/// source twice would double every diagnostic.
fn mtsc_typecheck_graph(
files : Map[String, String],
edges : Array[@mtsc_core.MtscModuleEdge],
) -> Bool {
let modules : Array[@mtsc_core.MtscModuleSource] = []
for path in files.keys() {
if !mtsc_is_module_path(path) {
continue
}
match files.get(path) {
Some(source) =>
modules.push({
path,
source,
allowJsx: path.has_suffix(".tsx") || path.has_suffix(".jsx"),
})
None => ()
}
}
let reported : Array[@mtsc_core.MtscGraphIssue] = []
for issue in @mtsc_core.collect_module_graph_issues({ modules, edges, }) {
// Import-resolution diagnostics are dropped here. They are part of
// the stricter contract the Vite integration wants, but this CLI
// cannot tell a type-only use of a value-form import
// (`import { LocalObj } from "./types"` where `LocalObj` is a type
// alias — legal TypeScript) from a genuinely missing export, and
// reporting the first would reject valid programs.
if issue.path == "import" {
continue
}
reported.push(issue)
}
if reported.length() == 0 {
return true
}
for issue in reported {
println(
"mtsc: type error in \{issue.modulePath} [\{issue.path}]: \{issue.message}",
)
}
false
}
///|
/// True for a path that names a source file we should type-check.
fn mtsc_is_module_path(path : String) -> Bool {
path.has_suffix(".ts") ||
path.has_suffix(".tsx") ||
path.has_suffix(".mts") ||
path.has_suffix(".js") ||
path.has_suffix(".mjs") ||
path.has_suffix(".jsx")
}
///|
#cfg(any(target="native", target="llvm"))
extern "c" fn mtsc_native_exit(code : Int) = "exit"
///|
#cfg(target="js")
extern "js" fn mtsc_js_try_exit(code : Int) -> Bool =
#| function(code) {
#| if (typeof process !== "undefined" && typeof process.exit === "function") {
#| process.exit(code);
#| return true;
#| }
#| return false;
#| }
///|
#cfg(target="wasm")
fn mtsc_wasm_exit(code : Int) -> Unit = "wasi_snapshot_preview1" "proc_exit"
///|
#cfg(target="wasm-gc")
fn mtsc_wasm_gc_exit(code : Int) -> Unit = "__moonbit_sys_unstable" "exit"
///|
/// Leave the process with `code`. See `MTSC_EXIT_*` in
/// `tsc_options.mbt` for what each value means to a caller.
///
/// This used to be `mtsc_exit_type_error()`, hard-wired to 1 and called
/// from inside the compile. Both properties had to go: `tsc`
/// distinguishes "diagnostics, nothing written" (1) from "diagnostics,
/// written anyway" (2), and a compile that exits the process cannot be
/// re-run, which is the whole of `--watch`.
#cfg(any(target="native", target="llvm"))
fn mtsc_exit(code : Int) -> Unit {
mtsc_native_exit(code)
}
///|
#cfg(target="js")
fn mtsc_exit(code : Int) -> Unit {
if !mtsc_js_try_exit(code) {
panic()
}
}
///|
#cfg(target="wasm")
fn mtsc_exit(code : Int) -> Unit {
mtsc_wasm_exit(code)
}
///|
#cfg(target="wasm-gc")
fn mtsc_exit(code : Int) -> Unit {
mtsc_wasm_gc_exit(code)
}
///|
/// `fflush(NULL)`, which flushes every open output stream.
///
/// The argument is declared `Int64` and passed `0L` because `fflush`
/// takes a `FILE*` and MoonBit has no pointer type to hand it. On both
/// ABIs this targets (x86-64 SysV and aarch64 AAPCS) a 64-bit integer
/// and a pointer are passed in the same register, so `0L` arrives as
/// `NULL` — the same assumption the `exit` binding above already makes
/// about an `Int` argument.
#cfg(any(target="native", target="llvm"))
extern "c" fn mtsc_native_fflush(stream : Int64) -> Int = "fflush"
///|
/// Push anything buffered in stdout out to the terminal or pipe.
///
/// Only `--watch` needs it, and it needs it badly: C stdio block-buffers
/// stdout when it is not a terminal, and a watcher never exits, so
/// `mtsc --watch > build.log` produced an EMPTY log while it was working
/// correctly — every line appeared at once when the process was finally
/// killed. Measured before and after: with the flush, the first pass's
/// summary lands in a redirected log while the watcher is still idling;
/// without it, nothing does.
///
/// A one-shot compile does not need this, because exiting flushes.
#cfg(any(target="native", target="llvm"))
fn mtsc_flush_stdout() -> Unit {
let _ = mtsc_native_fflush(0L)
}
///|
/// Nothing to flush: `console.log` is unbuffered, and the wasm targets
/// have no stdio layer of their own.
#cfg(not(any(target="native", target="llvm")))
fn mtsc_flush_stdout() -> Unit {
}
///|
/// The command line, with the first user argument at index 1.
///
/// `@env.args()` does NOT have the same shape on every backend, and
/// `driver.mbt` reads that shape at six places (`argv[1]` as the verb,
/// `mtsc_parse_args(argv, 1)`, `mtsc_run_check(argv, 2)` and its three
/// siblings). Normalizing once here is what keeps those six from each
/// needing to know — the alternative is the one-offset-assumed-in-
/// several-places defect, and it would be found the way this one was:
/// by the CLI type-checking its own 19 MB bundle.
#cfg(not(target="js"))
fn mtsc_argv() -> Array[String] {
@env.args()
}
///|
/// The same list on the JavaScript backend, where it is shaped
/// differently.
///
/// `moonbitlang/core/env` returns `process.argv` VERBATIM on `js`, and
/// Node puts two entries in front of the user's: the Node executable
/// and the script. The native runtime's argv has one, the program. So
/// dropping the Node executable makes the script the program name and
/// lands the first user argument on index 1 — the shape the rest of the
/// CLI already assumes.
///
/// This was a real bug rather than a hypothetical: without it
/// `mtsc ok.ts --noEmit` under Node read the script path as a second
/// entry and compiled `mtsc.js` — its own bundle — reporting
/// `ParseError("Unexpected token: Gt")` against itself and exiting 1 on
/// a clean file.
///
/// `node -e '…' a b` has no script path, so the drop would eat `a`
/// there. That is stated rather than guarded: a built bundle is never
/// run through `-e`, and under `-e` there is no script for argv[0] to
/// mean anyway.
#cfg(target="js")
fn mtsc_argv() -> Array[String] {
let argv = @env.args()
if argv.is_empty() {
return argv
}
let out : Array[String] = []
for index in 1.. Bool {
let effective_out = match out_override {
Some(p) => Some(p)
None => opts.out
}
if opts.mangle && opts.sourcemap {
println(
"mtsc: --mangle and --sourcemap can't be combined yet (source positions don't survive the rename pass)",
)
return false
}
let jsx_cfg = mtsc_build_jsx_config(opts, input_path)
let output_path = match effective_out {
Some(p) => p
None => "output.js"
}
if opts.bundle {
let files : Map[String, String] = Map([])
let edges : Array[@mtsc_core.MtscModuleEdge] = []
let aliases : Map[String, String] = Map([])
let type_props : Map[String, Bool] = Map([])
let parsed_blocks : Map[String, @ast.TsModuleBlock] = Map([])
let parse_split : Array[Double] = []
// `--timing` covers the CLI's own work too. The transform pipeline
// reports its own phases, but the first measurement of `checker.ts`
// put 731 ms of 31.6 s inside them — everything that mattered was
// out here.
let cli_timer = if opts.timing {
@transform.PhaseTimer::new()
} else {
@transform.PhaseTimer::disabled()
}
let load_started = @bench.monotonic_clock_start()
match
mtsc_load_bundle_files(
input_path,
files,
aliases,
opts.externals,
type_props,
opts.reserve_typed_props,
parse_split,
parsed_blocks,
edges,
) {
Ok(_) => ()
Err(e) => {
println("mtsc: \{e}")
return false
}
}
// Report the lexer and the recursive descent separately, and keep
// the remainder (disk reads, path resolution) as its own row.
let load_total = @bench.monotonic_clock_end(load_started)
if parse_split.length() == 7 {
cli_timer.record("cli: tokenize", parse_split[0])
cli_timer.record("cli: parse (recursive descent)", parse_split[1])
cli_timer.record("cli: read + decode files", parse_split[2])
cli_timer.record("cli: resolve relative paths", parse_split[3])
cli_timer.record("cli: ambient .d.ts scan", parse_split[4])
cli_timer.record("cli: import edge walk", parse_split[5])
cli_timer.record("cli: resolve bare specifiers", parse_split[6])
// Whatever the measured spans do not account for: queue
// bookkeeping, mostly. The sibling `.d.ts` scan used to be in
// here and was most of it — on `typescript.js` it read and parsed
// 98 files and 3.2 MB. A residual is a place to look, not an
// answer, and this one was labelled after the wrong thing.
let mut rest = load_total
for span in parse_split {
rest = rest - span
}
if rest > 0.0 {
cli_timer.record("cli: load, unattributed", rest)
}
} else {
cli_timer.record("cli: read + parse files", load_total)
}
// Type-check the whole graph in one go. Checking each file alone
// leaves every imported type unresolvable, which both hides real
// errors and invents fake ones — an identical shape compared against
// an imported alias reads as a mismatch.
if !opts.no_check {
let check_started = @bench.monotonic_clock_start()
let check_ok = mtsc_typecheck_graph(files, edges)
cli_timer.record(
"cli: typecheck",
@bench.monotonic_clock_end(check_started),
)
if !check_ok {
return false
}
}
// `--listFiles` names the program; `--listFilesOnly` stops there.
// Both read the loader's `files` map rather than re-walking, and
// both filter it the way the type-check does: the loader keys a
// module by BOTH its resolved path and the extension-less form a
// specifier spelled, so an unfiltered dump names half the files
// twice.
if opts.list_files {
mtsc_list_program_files(files)
}
if opts.list_files_only {
return true
}
// `--noEmit` stops after the check. Deliberately here and not
// before the load: the graph walk is what makes the check a
// PROGRAM check rather than a per-file one, which is the thing
// `tsc --noEmit` is asked for in CI.
if opts.no_emit {
return true
}
// `type_props` collects every property name declared in an
// interface / type alias / class across the input. We DON'T
// feed it to the bundler by default: in bundle mode the type
// contract is erased at runtime (the bundle is self-contained,
// so no consumer can see the original property names through
// TypeScript reflection). The harvested list lives behind
// `--reserve-typed-props` for the library-emission scenario
// where the bundle's types DO escape and would type-check
// against a downstream consumer.
let extra_reserved : Array[String] = []
if opts.reserve_entry_exports {
// Re-parse the entry to walk its `export` declarations + the
// type signatures they reach. This is what the user is
// actually publishing in the companion `.d.ts`, so everything
// else (helper interfaces, internal type aliases) is fair
// game for the property mangler.
let entry_source = match files.get(input_path) {
Some(s) => s
None => ""
}
if entry_source != "" {
let block = (try
@parser.parse_module_block_from_source(entry_source)
catch {
e => Err(e)
} noraise {
v => Ok(v)
}).or({
block: { stmts: [], stmt_positions: [], },
imports: [],
exports: [],
reexports: [],
enums: [],
internal_names: {},
deprecations: {},
pure_declarations: {},
type_aliases: [],
interfaces: [],
class_fields: [],
module_request_order: [],
type_only_exports: {},
has_export_equals: false,
})
let module_ = (try
@parser.parse_module_from_source_with_const_values(entry_source, {})
catch {
e => Err(e)
} noraise {
v => Ok(v)
}).or(@ast.TsModule::{
funcs: [],
interfaces: [],
type_aliases: [],
imports: [],
values: [],
classes: [],
local_classes: [],
namespaces: [],
enums: [],
module_augmentations: [],
strict_property_initialization: true,
no_implicit_any: true,
strict_null_checks: true,
use_define_for_class_fields: false,
deprecated_compiler_options: [],
parameter_property_misuses: [],
strict_mode_misuses: [],
invalid_decorator_uses: [],
grammar_misuses: [],
imported_binding_names: [],
import_module_specs: [],
class_base_type_args: [],
param_optional_initializer_misuses: [],
interface_member_modifier_misuses: [],
class_dup_diagnostics: [],
top_level_stmts: [],
})
let local_names : Array[String] = []
for ex in block.exports {
local_names.push(ex.local_name)
}
for n in @transform.entry_export_property_names(module_, local_names) {
extra_reserved.push(n)
}
}
} else if opts.reserve_typed_props {
for n in type_props.keys() {
extra_reserved.push(n)
}
}
let bundle_opts = @transform.BundleOptions::default()
.with_mangle(opts.mangle)
.with_mangle_properties(opts.mangle_properties)
.with_mangle_properties_shape_color(opts.mangle_properties_shape_color)
.with_sourcemap(opts.sourcemap)
.with_treeshake(opts.treeshake)
.with_minify(opts.minify)
.with_fold(opts.fold)
.with_aliases(aliases)
.with_extra_reserved_props(extra_reserved)
.with_explain(opts.explain_mangle)
// The loader above already asked the filesystem about every
// relative import and printed a line for each one that is not
// there, so the bundler doesn't need to fail over them a second
// time — it keeps them verbatim.
.with_allow_unresolved_imports(true)
.with_disabled_phases(opts.disabled_phases)
.with_timing(opts.timing)
// Hand the loader's parses over rather than have the bundler
// repeat them.
.with_parsed_blocks(parsed_blocks)
let bundle_opts = { ..bundle_opts, jsx: jsx_cfg, }
let bundle_started = @bench.monotonic_clock_start()
let result = @transform.bundle_modules(files, input_path, bundle_opts)
let bundle_micros = @bench.monotonic_clock_end(bundle_started)
let out = match result {
Ok(o) => o
Err(e) => {
println("mtsc: \{e}")
return false
}
}
if opts.warn_impure_getters {
for w in out.warnings {
println("mtsc: " + w)
}
}
// Reflection that stopped an optimization. Default on, except when
// the JS is going to stdout and the line would land in the program.
let want_reflection = match opts.warn_reflection {
Some(v) => v
None => effective_out is Some(_)
}
if want_reflection && out.reflection_warnings.length() > 0 {
println(
"mtsc: warning: reflection keeps every class method. These enumerate NON-enumerable",
)
println(
"mtsc: properties, which is exactly what a class prototype method is, so no",
)
println("mtsc: analysis can see through them:")
for r in out.reflection_warnings {
println("mtsc: " + r)
}
}
for line in out.explain {
println(line)
}
// Verify the OUTPUT. Deliberately after every pass and from the
// emitted TEXT: a check that shares the pipeline's in-memory state
// can share its bugs.
if opts.verify {
let violations = @transform.verify_emitted_bundle(out.js)
for v in violations {
println("mtsc: verify: \{v.detail}")
}
if violations.length() > 0 {
println("mtsc: verify: \{violations.length()} violation(s)")
} else {
println("mtsc: verify: ok")
}
}
if opts.timing {
// The bundle's own phases, then the difference — what
// `bundle_modules` spent outside any instrumented pass. Linking,
// escape analysis and the emit all used to be in here and all
// now have rows of their own; what is left is the statement
// concatenation and the per-pass bookkeeping between phases.
let mut inner = 0.0
for t in out.timings {
cli_timer.record(t.0, t.1)
inner = inner + t.1
}
let unattributed = bundle_micros - inner
if unattributed > 0.0 {
cli_timer.record("bundle: unattributed", unattributed)
}
for line in cli_timer.report() {
println(line)
}
}
match effective_out {
Some(path) => {
let js = match out.sourcemap {
Some(_) => {
let base = match path.rev_find("/") {
Some(i) => path[i + 1:path.length()].to_owned()
None => path
}
out.js + "\n//# sourceMappingURL=" + base + ".map\n"
}
None => out.js
}
if !write_text(path, js) {
return false
}
match out.sourcemap {
Some(map_json) => {
let _ = write_text(path + ".map", map_json)
}
None => ()
}
if opts.dts {
// Emit the companion `.d.ts`. Strip a trailing `.js`
// from the output path (if present) before appending
// `.d.ts` so `bundle.js` becomes `bundle.d.ts` rather
// than `bundle.js.d.ts`.
let dts_path = if path.has_suffix(".js") {
path[0:path.length() - 3].to_owned() + ".d.ts"
} else {
path + ".d.ts"
}
let entry_source = match files.get(input_path) {
Some(s) => s
None => ""
}
let entry_block = (try
@parser.parse_module_block_from_source(entry_source)
catch {
e => Err(e)
} noraise {
v => Ok(v)
}).or({
block: { stmts: [], stmt_positions: [], },
imports: [],
exports: [],
reexports: [],
enums: [],
internal_names: {},
deprecations: {},
pure_declarations: {},
type_aliases: [],
interfaces: [],
class_fields: [],
module_request_order: [],
type_only_exports: {},
has_export_equals: false,
})
let entry_module = (try
@parser.parse_module_from_source_with_const_values(entry_source, {})
catch {
e => Err(e)
} noraise {
v => Ok(v)
}).or(@ast.TsModule::{
funcs: [],
interfaces: [],
type_aliases: [],
imports: [],
values: [],
classes: [],
local_classes: [],
namespaces: [],
enums: [],
module_augmentations: [],
strict_property_initialization: true,
no_implicit_any: true,
strict_null_checks: true,
use_define_for_class_fields: false,
deprecated_compiler_options: [],
parameter_property_misuses: [],
strict_mode_misuses: [],
invalid_decorator_uses: [],
grammar_misuses: [],
imported_binding_names: [],
import_module_specs: [],
class_base_type_args: [],
param_optional_initializer_misuses: [],
interface_member_modifier_misuses: [],
class_dup_diagnostics: [],
top_level_stmts: [],
})
// Filter exports through the entry's `@internal` set so
// anything tagged `/** @internal */` is invisible to
// downstream consumers' type-checks even when the JS
// bundle still ships the runtime binding.
let local_names : Array[String] = []
for ex in entry_block.exports {
if !entry_block.internal_names.contains(ex.local_name) {
local_names.push(ex.local_name)
}
}
let dts_text = @transform.emit_entry_dts(
entry_module,
local_names,
entry_block.deprecations,
)
let _ = write_text(dts_path, dts_text)
}
}
None => println(out.js)
}
return true
}
let source = @fs.read_file(input_path).text() catch {
e => {
println("mtsc: read error \{input_path}: \{e}")
return false
}
}
if !mtsc_typecheck_source(source, input_path) && !opts.no_check {
return false
}
// The single-file path's program is the one file, so the two list
// options degenerate to printing it.
if opts.list_files {
println(input_path)
}
if opts.list_files_only {
return true
}
if opts.no_emit {
return true
}
let result = @transform.transform_source_full(
source,
jsx_cfg,
with_sourcemap=opts.sourcemap,
source_path=input_path,
output_path~,
mangle=opts.mangle,
mangle_properties=opts.mangle_properties,
minify=opts.minify,
)
let out = match result {
Ok(o) => o
Err(e) => {
println("mtsc: \{e}")
return false
}
}
let js = match (out.sourcemap, effective_out) {
(Some(_), Some(p)) => {
let base = match p.rev_find("/") {
Some(i) => p[i + 1:p.length()].to_owned()
None => p
}
out.js + "\n//# sourceMappingURL=" + base + ".map\n"
}
_ => out.js
}
match effective_out {
Some(path) => {
if !write_text(path, js) {
return false
}
match out.sourcemap {
Some(map_json) => {
let _ = write_text(path + ".map", map_json)
}
None => ()
}
}
None => println(js)
}
true
}