// `mtsc -p` — compiling a project rather than a named file.
//
// Two behaviours come from `tsc` and had no spelling in mtsc at all:
// `mtsc` with no file arguments looks for `tsconfig.json` in the working
// directory, and `-p` / `--project` takes either a directory holding one
// or the config path itself.
//
// `@parser.load_tsconfig_project` answers what the config SAYS. This
// file turns that into a list of entry paths, which is a directory walk
// and a glob matcher — deliberately on this side of the boundary,
// because the loader in `main.mbt` already owns the filesystem and its
// caches.
//
// The glob support is `tsc`'s three forms and nothing else: `*` (any
// characters within one path segment), `?` (one character within a
// segment) and `**/` (any number of segments). That is the whole of what
// `include` / `exclude` accept, so there is no fourth form to get wrong.
///|
/// Where a `-p` value, or a bare invocation, finds its config.
///
/// `tsc -p x` accepts both `x/tsconfig.json` and `x` naming a directory,
/// and distinguishing them needs the filesystem rather than the string —
/// a path with no `.json` suffix can still be a file.
async fn mtsc_resolve_project_config(project : String?) -> String? {
match project {
None => {
let cand = "tsconfig.json"
if mtsc_file_exists(cand) {
Some(cand)
} else {
None
}
}
Some(p) => {
if mtsc_file_exists(p) {
return Some(p)
}
let joined = mtsc_normalize(mtsc_join_path(p, "tsconfig.json"))
if mtsc_file_exists(joined) {
return Some(joined)
}
None
}
}
}
///|
/// Does `name` match glob `pattern` within a single path segment?
///
/// `*` and `?` only; `**` is handled a level up, by the segment walk,
/// because it is about the number of SEGMENTS rather than about
/// characters. Backtracking is the straightforward recursive form: a
/// pattern here is a handful of characters, so there is nothing to gain
/// from a table.
fn mtsc_glob_segment_matches(pattern : String, name : String) -> Bool {
fn go(pi : Int, ni : Int) -> Bool {
if pi >= pattern.length() {
return ni >= name.length()
}
// A `String` indexes UTF-16 code units, so the character comes back
// through `to_int()`. Comparison stays unit-by-unit on both sides,
// which is equality for every path a filesystem can hand back.
match pattern[pi].to_int().unsafe_to_char() {
'*' => {
// Try every split, shortest first.
let mut skip = ni
while skip <= name.length() {
if go(pi + 1, skip) {
return true
}
skip = skip + 1
}
false
}
'?' => ni < name.length() && go(pi + 1, ni + 1)
_ =>
ni < name.length() &&
name[ni].to_int() == pattern[pi].to_int() &&
go(pi + 1, ni + 1)
}
}
go(0, 0)
}
///|
/// Does the path `segments` match the glob `pattern_segments`?
///
/// A `**` segment matches any number of segments, including none, which
/// is why this is a segment-level recursion rather than one pass.
fn mtsc_glob_path_matches(
pattern_segments : Array[String],
segments : Array[String],
) -> Bool {
fn go(pi : Int, si : Int) -> Bool {
if pi >= pattern_segments.length() {
return si >= segments.length()
}
if pattern_segments[pi] == "**" {
let mut skip = si
while skip <= segments.length() {
if go(pi + 1, skip) {
return true
}
skip = skip + 1
}
return false
}
if si >= segments.length() {
return false
}
if !mtsc_glob_segment_matches(pattern_segments[pi], segments[si]) {
return false
}
go(pi + 1, si + 1)
}
go(0, 0)
}
///|
/// Split a path into its non-empty segments.
fn mtsc_path_segments(path : String) -> Array[String] {
let out : Array[String] = []
for part in path.split("/") {
let p = part.to_owned()
if p != "" && p != "." {
out.push(p)
}
}
out
}
///|
/// Expand one `include` pattern the way `tsc` does before matching it.
///
/// A pattern with no extension and no wildcard names a DIRECTORY, and
/// `tsc` reads it as "everything under it" — `"include": ["src"]` is
/// `src/**/*`. Getting this wrong is silent: the pattern matches the
/// directory itself, nothing under it, and the program comes out empty.
fn mtsc_expand_include_pattern(pattern : String) -> String {
if pattern.contains("*") || pattern.contains("?") {
return pattern
}
for
ext in [
".ts", ".tsx", ".mts", ".cts", ".d.ts", ".js", ".jsx", ".mjs", ".cjs",
] {
if pattern.has_suffix(ext) {
return pattern
}
}
if pattern.has_suffix("/") {
pattern + "**/*"
} else {
pattern + "/**/*"
}
}
///|
/// Does this path name a file `tsc` would put in a program?
///
/// `.d.ts` is in, because a declaration file is part of the program even
/// though it emits nothing. `.json` is not: `resolveJsonModule` pulls one
/// in through an import, never through `include`.
fn mtsc_is_program_source(path : String) -> Bool {
if path.has_suffix(".d.ts") {
return true
}
for ext in [".ts", ".tsx", ".mts", ".cts"] {
if path.has_suffix(ext) {
return true
}
}
false
}
///|
/// The default `exclude` set, which applies when the config names none.
///
/// `tsc` also excludes the configured `outDir`, which is why that is a
/// parameter rather than a constant here: compiling a project whose
/// previous output sits under `dist/` would otherwise read its own
/// emitted `.d.ts` files back in as sources.
fn mtsc_default_excludes(out_dir : String?) -> Array[String] {
let out = [
"**/node_modules/**", "**/bower_components/**", "**/jspm_packages/**",
]
match out_dir {
Some(d) => {
let trimmed = if d.has_suffix("/") {
d[0:d.length() - 1].to_owned()
} else {
d
}
if trimmed != "" && trimmed != "." {
out.push(trimmed + "/**")
}
}
None => ()
}
out
}
///|
/// Walk `dir` recursively, collecting every program source under it.
///
/// `node_modules` is pruned during the WALK rather than filtered after
/// it. The difference is not cosmetic: a project with dependencies
/// installed has more files under `node_modules` than in its own source
/// tree by two or three orders of magnitude, and an exclude applied
/// afterwards still pays for reading all of them.
async fn mtsc_collect_sources(dir : String, out : Array[String]) -> Unit {
let names = @fs.readdir(dir, sort=true) catch { _ => return }
for name in names {
if name == "node_modules" ||
name == "bower_components" ||
name == "jspm_packages" ||
name.has_prefix(".") {
continue
}
let path = mtsc_normalize(mtsc_join_path(dir, name))
let kind = @fs.kind(path) catch { _ => @fs.FileKind::Unknown }
match kind {
@fs.FileKind::Directory => mtsc_collect_sources(path, out)
@fs.FileKind::Regular =>
if mtsc_is_program_source(path) {
out.push(path)
}
_ => ()
}
}
}
///|
/// The entry paths a project names, in a deterministic order.
///
/// `files` wins over `include` the way `tsc` orders them: an explicit
/// `files` list is the program, and `include` only adds to it. A config
/// that names neither gets TypeScript's default of everything under the
/// config's own directory.
async fn mtsc_project_entries(
project : @parser.TsconfigProject,
out_dir : String?,
) -> Array[String] {
let root = if project.root_dir == "" { "." } else { project.root_dir }
let selected : Array[String] = []
let seen : Map[String, Bool] = Map([])
fn add(path : String) -> Unit {
let p = mtsc_normalize(path)
if seen.contains(p) {
return
}
seen[p] = true
selected.push(p)
}
// `files` entries are exact paths and are NOT filtered by `exclude`
// — `tsc` treats them as a deliberate listing.
for f in project.files {
add(mtsc_join_path(root, f))
}
let patterns : Array[String] = []
for p in project.include_ {
patterns.push(mtsc_expand_include_pattern(p))
}
// No `include` and no `files` means everything under the config's
// directory. No `include` but a `files` list means exactly that list.
if patterns.length() == 0 {
if project.files.length() > 0 {
return selected
}
patterns.push("**/*")
}
let excludes = if project.exclude.length() > 0 {
let e : Array[String] = []
for x in project.exclude {
e.push(mtsc_expand_include_pattern(x))
}
e
} else {
mtsc_default_excludes(out_dir)
}
let candidates : Array[String] = []
mtsc_collect_sources(root, candidates)
let root_segments = mtsc_path_segments(root)
for cand in candidates {
// Match patterns against the path RELATIVE to the config's
// directory, which is what `include` is written against.
let all = mtsc_path_segments(cand)
let rel : Array[String] = []
let skip = if all.length() >= root_segments.length() {
root_segments.length()
} else {
0
}
for i in skip.. Unit {
match @parser.tsconfig_compiler_option_at(config_path, "outDir") {
Some(d) =>
if opts.out_dir is None && opts.out is None {
opts.out_dir = Some(
mtsc_normalize(mtsc_join_path(mtsc_dirname(config_path), d)),
)
}
None => ()
}
match @parser.tsconfig_compiler_option_at(config_path, "outFile") {
Some(f) =>
if opts.out is None && opts.out_dir is None {
opts.out = Some(
mtsc_normalize(mtsc_join_path(mtsc_dirname(config_path), f)),
)
}
None => ()
}
match @parser.tsconfig_compiler_option_bool_at(config_path, "noEmit") {
Some(v) => if !opts.no_emit { opts.no_emit = v }
None => ()
}
match @parser.tsconfig_compiler_option_bool_at(config_path, "declaration") {
Some(v) =>
if !opts.dts && v {
opts.dts = true
opts.bundle = true
}
None => ()
}
match
@parser.tsconfig_compiler_option_bool_at(config_path, "emitDeclarationOnly") {
Some(v) =>
if v {
opts.dts = true
opts.dts_only = true
opts.bundle = true
}
None => ()
}
match @parser.tsconfig_compiler_option_bool_at(config_path, "sourceMap") {
Some(v) => if !opts.sourcemap { opts.sourcemap = v }
None => ()
}
match @parser.tsconfig_compiler_option_bool_at(config_path, "noCheck") {
Some(v) => if !opts.no_check { opts.no_check = v }
None => ()
}
// The JSX settings are NOT read here. `mtsc_build_jsx_config` already
// reads them per entry, searching up from the entry's own directory,
// which is the more precise answer in a monorepo where a package's
// config overrides the base it extends. Reading them twice would mean
// two precedence orders that can disagree.
}
///|
/// `--showConfig`: print what the resolved configuration comes to.
///
/// `tsc --showConfig` prints the merged tsconfig as JSON. This prints
/// the same information in the same shape for the options mtsc acts on,
/// and does not invent entries for the ones it accepted without effect —
/// a config dump that lists `"strict": true` when nothing reads it would
/// be the silent-acceptance failure this CLI exists to avoid.
fn mtsc_print_show_config(
opts : MtscOptions,
config_path : String?,
entries : Array[String],
) -> Unit {
println("{")
match config_path {
Some(p) => println(" \"configFilePath\": \"\{p}\",")
None => ()
}
println(" \"compilerOptions\": {")
let rows : Array[String] = []
match opts.out {
Some(v) => rows.push(" \"outFile\": \"\{v}\"")
None => ()
}
match opts.out_dir {
Some(v) => rows.push(" \"outDir\": \"\{v}\"")
None => ()
}
rows.push(" \"noEmit\": \{opts.no_emit}")
rows.push(" \"noCheck\": \{opts.no_check}")
rows.push(" \"declaration\": \{opts.dts}")
rows.push(" \"emitDeclarationOnly\": \{opts.dts_only}")
rows.push(" \"sourceMap\": \{opts.sourcemap}")
match opts.jsx_runtime {
Some(v) => rows.push(" \"jsx\": \"\{v}\"")
None => ()
}
match opts.jsx_import_source {
Some(v) => rows.push(" \"jsxImportSource\": \"\{v}\"")
None => ()
}
println(rows.join(",\n"))
println(" },")
let quoted : Array[String] = []
for e in entries {
quoted.push(" \"\{e}\"")
}
println(" \"files\": [")
println(quoted.join(",\n"))
println(" ]")
println("}")
}
///|
/// The starter `tsconfig.json` `--init` writes.
///
/// Deliberately short, and every entry in it is one mtsc honours. `tsc
/// --init` writes a long commented template covering its whole option
/// surface; reproducing that here would fill a new project's config with
/// options this compiler accepts and ignores.
fn mtsc_init_tsconfig_text() -> String {
let lines = [
"{", " \"compilerOptions\": {", " // mtsc always type-checks strictly and always emits",
" // current-syntax ESM, so `strict`, `target` and `module` are", " // accepted for tsc compatibility and have no effect.",
" \"strict\": true,", " \"target\": \"es2022\",", " \"module\": \"esnext\",",
" \"moduleResolution\": \"bundler\",", " // Honoured by mtsc:", " \"outDir\": \"dist\",",
" \"declaration\": true,", " \"sourceMap\": false,", " \"jsx\": \"react-jsx\"",
" },", " \"include\": [\"src\"],", " \"exclude\": [\"node_modules\", \"dist\"]",
"}", "",
]
lines.join("\n")
}