ttsc
Version:
General-purpose TypeScript-Go compiler, runtime, plugin host, and LSP host.
1,526 lines (1,457 loc) • 96.8 kB
text/typescript
import {
type SpawnSyncOptionsWithStringEncoding,
type SpawnSyncReturns,
type StdioOptions,
spawnSync,
} from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { captureProcessOutput } from "../../compiler/internal/captureProcessOutput";
import { findNearestGoMod } from "../../compiler/internal/paths";
const GO_MOD_SEARCH_MAX_DEPTH = 3;
const TTSC_GO_MODULE_PATH = "github.com/samchon/ttsc/packages/ttsc";
const TSGO_GO_MODULE_PATH = "github.com/microsoft/typescript-go";
const PRUNE_DIRS = new Set(["node_modules", ".git", ".ttsc"]);
const GENERATED_WORKSPACE_FILES = new Set(["go.work", "go.work.sum"]);
// Go build environment values that can change the produced binary or decide
// whether `go build` succeeds. Hashed into the plugin cache key so target,
// build-tag, cgo, FIPS, and external-link variants never collide.
const GO_BUILD_ENV_KEYS: readonly string[] = [
"GOOS",
"GOARCH",
"GOAMD64",
"GOARM",
"GOARM64",
"GO386",
"GOMIPS",
"GOMIPS64",
"GOPPC64",
"GORISCV64",
"GOWASM",
"GOFLAGS",
"GOEXPERIMENT",
"GOFIPS140",
"GO_EXTLINK_ENABLED",
"GCCGO",
"GCCGOTOOLDIR",
"CGO_ENABLED",
"AR",
"CC",
"CXX",
"FC",
"PKG_CONFIG",
"CGO_CFLAGS",
"CGO_CFLAGS_ALLOW",
"CGO_CFLAGS_DISALLOW",
"CGO_CPPFLAGS",
"CGO_CPPFLAGS_ALLOW",
"CGO_CPPFLAGS_DISALLOW",
"CGO_CXXFLAGS",
"CGO_CXXFLAGS_ALLOW",
"CGO_CXXFLAGS_DISALLOW",
"CGO_FFLAGS",
"CGO_FFLAGS_ALLOW",
"CGO_FFLAGS_DISALLOW",
"CGO_LDFLAGS",
"CGO_LDFLAGS_ALLOW",
"CGO_LDFLAGS_DISALLOW",
"GOTOOLCHAIN",
"GOROOT",
];
const GO_BUILD_COMMAND_ENV_KEYS = new Set([
"AR",
"CC",
"CXX",
"FC",
"GCCGO",
"PKG_CONFIG",
]);
const EXTERNAL_GO_BUILD_ENV_KEYS: readonly string[] = [
"CPATH",
"C_INCLUDE_PATH",
"CPLUS_INCLUDE_PATH",
"DYLD_LIBRARY_PATH",
"INCLUDE",
"LD_LIBRARY_PATH",
"LIB",
"LIBRARY_PATH",
"LIBPATH",
"MACOSX_DEPLOYMENT_TARGET",
"OBJC_INCLUDE_PATH",
"PKG_CONFIG_ALLOW_SYSTEM_CFLAGS",
"PKG_CONFIG_ALLOW_SYSTEM_LIBS",
"PKG_CONFIG_LIBDIR",
"PKG_CONFIG_PATH",
"PKG_CONFIG_SYSROOT_DIR",
"PKG_CONFIG_TOP_BUILD_DIR",
"SDKROOT",
];
const CONTRIBUTIONS_FILE_NAME = "ttsc_contributions.go";
const CONTRIB_DIRNAME = "contrib";
// A cold source-plugin build is a multi-second-to-minutes `go build`. When a
// program fans out into many processes (a `pnpm -r` running several suites in
// parallel, a benchmark, a worker pool), each inherits the same cold cache and
// would otherwise launch its own full build of the SAME cache key at the same
// instant. The atomic lock below lets one process build while the rest poll for
// its published binary, so the toolchain runs once per cache key instead of N
// times. A waiter steals an abandoned lock (builder crashed) after this timeout
// so a fan-out never wedges; it matches the dependency-build lock in
// runtimeHooks.ts.
const PLUGIN_BUILD_LOCK_STEAL_MS = 600_000;
const PLUGIN_BUILD_LOCK_POLL_MS = 50;
const PLUGIN_BUILD_LOCK_LEGACY_STALE_MS = 30_000;
const PLUGIN_BUILD_LOCK_STATUS_MS = 30_000;
const PLUGIN_BUILD_LOCK_OWNER_FILE = "owner.json";
const PLUGIN_BUILD_LOCK_PROTOCOL_FILE = "protocol-v2";
const PLUGIN_BUILD_LOCK_GENERATION_FILE = "generation";
const PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR = "legacy-generation";
const PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD = "fence.json";
const PLUGIN_BUILD_LOCK_CURRENT_DIR = "current";
const PLUGIN_BUILD_LOCK_RETIRED_DIR = "retired";
const PLUGIN_BUILD_LOCK_V2_SUFFIX = ".v2";
const PLUGIN_BUILD_LOCK_PROTOCOL = "ttsc-plugin-build-lock-v2\n";
// The default cache lives INSIDE the workspace, at
// `<workspaceRoot>/node_modules/.cache/ttsc`, so `rm -rf node_modules` (or
// deleting the repo) reclaims every compiled plugin binary and Go object file.
// This is the `find-cache-dir` convention (Babel, webpack, ESLint, Nuxt, …): a
// disposable build cache under `node_modules/.cache/<tool>`. ttsc keeps NO
// global (`~/.cache`) cache — a machine-wide cache silently grew to hundreds of
// GB across tsgo/plugin version bumps, so it was removed outright. See
// resolveSourceBuildCacheRoot for the (override → workspace-local) priority.
const NODE_MODULES_DIRNAME = "node_modules";
const LOCAL_CACHE_PARENT_DIRNAME = ".cache";
const TTSC_CACHE_DIRNAME = "ttsc";
const PLUGIN_CACHE_DIRNAME = "plugins";
const GO_BUILD_CACHE_DIRNAME = "go-build";
// Directories whose presence marks a monorepo/workspace root, so every package
// in the workspace shares ONE cache and a plugin builds once, not once per
// package. `package.json` with a `workspaces` field (yarn/npm/bun) is checked
// separately in isWorkspaceRootDir.
const WORKSPACE_ROOT_MARKER_FILES: readonly string[] = ["pnpm-workspace.yaml"];
const CACHE_LAST_USED_FILE = ".last-used";
const CACHE_GC_MARKER_FILE = ".gc-last-run";
// The plugin binary cache is content-keyed, so a project that bumps tsgo/typia
// many times leaves one stale entry per superseded key. An opportunistic GC
// (once/day) evicts entries unused for 30 days and, past a 2 GB ceiling, the
// least-recently-used down to 80%. It is scoped to the resolved cache root only
// — ttsc never scans a shared or global location.
const PLUGIN_CACHE_GC_INTERVAL_MS = 24 * 60 * 60 * 1000;
const PLUGIN_CACHE_ENTRY_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
const PLUGIN_CACHE_MAX_BYTES = 2 * 1024 * 1024 * 1024;
const PLUGIN_CACHE_TARGET_BYTES = Math.floor(PLUGIN_CACHE_MAX_BYTES * 0.8);
const PLUGIN_CACHE_PROTECTED_AGE_MS = 60 * 60 * 1000;
/** One contributor's resolved Go source plus its target sub-package name. */
export interface ITtscBuildContributor {
/** Sub-package suffix: scratch lands at `<host>/contrib/<name>/`. */
name: string;
/** Absolute path to the contributor's source directory. */
source: string;
}
/** Source-plugin cache locations resolved for one ttsc invocation. */
export interface ITtscSourceBuildCachePaths {
/** Root directory containing all ttsc-owned source build caches. */
root: string;
/** Directory containing content-addressed compiled plugin binaries. */
pluginRoot: string;
/** Directory passed to Go as `GOCACHE` for source-plugin builds. */
goBuildRoot: string;
/** How `goBuildRoot` was selected. */
goBuildRootSource: "ttsc-cache" | "TTSC_GO_CACHE_DIR" | "GOCACHE";
}
/**
* Build one Go source plugin into a cached executable.
*
* `opts.env` is the effective environment for this build — the caller merges `{
* ...process.env, ...context.env }` so a programmatic `TtscCompiler` instance
* can pin its own Go toolchain (`TTSC_GO_BINARY`), Go build cache
* (`TTSC_GO_CACHE_DIR`), and Go build variables (`GOFLAGS`, `CGO_*`, …) without
* mutating the shared `process.env`. CLI callers omit it and inherit
* `process.env`, so ambient behavior is unchanged.
*/
export function buildSourcePlugin(opts: {
source: string;
pluginName: string;
baseDir: string;
cacheDir?: string;
contributors?: readonly ITtscBuildContributor[];
env?: NodeJS.ProcessEnv;
label?: string;
overlayDirs?: readonly string[];
quiet?: boolean;
ttscVersion: string;
tsgoVersion: string;
}): string {
const env = opts.env ?? process.env;
const { dir, entry, source } = resolveSourceBuildTarget(opts);
const overlayDirs = [...(opts.overlayDirs ?? findTtscOverlayDirs())].sort();
const contributors = opts.contributors ?? [];
const goBinary = resolveGoToolForBuild(resolveGoCompiler(env), env, dir);
ensureExecutableGoToolchain(goBinary);
const key = computeCacheKey({
contributors,
dir,
entry,
env,
goBinary,
overlayDirs,
ttscVersion: opts.ttscVersion,
tsgoVersion: opts.tsgoVersion,
});
const paths = resolveSourceBuildCachePaths(opts.baseDir, opts.cacheDir, env);
maybePrunePluginCache(paths, opts.cacheDir, env);
const cacheDir = path.join(paths.pluginRoot, key);
const binaryName = process.platform === "win32" ? "plugin.exe" : "plugin";
const binaryPath = path.join(cacheDir, binaryName);
if (fs.existsSync(binaryPath)) {
touchCacheEntry(cacheDir);
return binaryPath;
}
fs.mkdirSync(cacheDir, { recursive: true });
const label = opts.label ?? "source plugin";
const quiet = opts.quiet === true;
return buildUnderPluginLock(
cacheDir,
binaryPath,
{ label, pluginName: opts.pluginName, quiet },
() =>
compileSourcePlugin({
binaryPath,
cacheDir,
contributors,
dir,
entry,
env,
goBinary,
key,
label,
goBuildCacheRoot: paths.goBuildRoot,
overlayDirs,
pluginName: opts.pluginName,
quiet,
source,
}),
);
}
/** Run the actual `go build` and publish the binary; assumes the lock is held. */
function compileSourcePlugin(opts: {
binaryPath: string;
cacheDir: string;
contributors: readonly ITtscBuildContributor[];
dir: string;
entry: string;
env: NodeJS.ProcessEnv;
goBinary: string;
goBuildCacheRoot: string;
key: string;
label: string;
overlayDirs: readonly string[];
pluginName: string;
quiet: boolean;
source: string;
}): string {
if (!opts.quiet) {
const extra =
opts.contributors.length === 0
? ""
: ` + ${opts.contributors.length} contributor(s): ${opts.contributors
.map((c) => c.name)
.join(", ")}`;
process.stderr.write(
`ttsc: building ${opts.label} "${opts.pluginName}" from ${opts.source}${extra} ` +
`(this runs once per cache key and can take several minutes on a cold Go cache) ` +
`See https://ttsc.dev/docs/ttsc/compile#plugin-cache to persist it across builds.\n`,
);
}
const scratchDir = fs.mkdtempSync(
path.join(os.tmpdir(), `ttsc-plugin-${opts.key}-`),
);
try {
materializeScratchDir(opts.dir, scratchDir);
const goModReader = createGoModReader(
opts.goBinary,
opts.pluginName,
opts.env,
);
if (opts.contributors.length > 0) {
mergeContributors({
contributors: opts.contributors,
entry: opts.entry,
goModReader,
pluginName: opts.pluginName,
scratchDir,
});
}
writeGoWork(
scratchDir,
opts.overlayDirs,
opts.goBinary,
opts.pluginName,
opts.env,
);
const scratchBinaryName =
process.platform === "win32" ? ".ttsc-plugin.exe" : ".ttsc-plugin";
runGoBuild(
scratchDir,
opts.entry,
scratchBinaryName,
opts.pluginName,
opts.goBinary,
opts.goBuildCacheRoot,
opts.env,
);
const builtBinary = path.join(scratchDir, scratchBinaryName);
publishBuiltBinary(builtBinary, opts.binaryPath);
touchCacheEntry(opts.cacheDir);
return opts.binaryPath;
} finally {
fs.rmSync(scratchDir, { recursive: true, force: true });
}
}
/**
* Build a source plugin while holding an exclusive cross-process lock for its
* cache key, so concurrent fan-out (parallel suites, a benchmark, a worker
* pool) runs the `go build` once instead of once per process.
*
* `<cacheDir>.lock.v2` is a persistent coordination directory. The adjacent
* `<cacheDir>.lock` path remains reserved for legacy holders and is never
* reused for a v2 generation: an old holder or stale legacy reclaimer can
* therefore remove only the legacy path, never a v2 successor. A contender
* writes a non-empty candidate and atomically renames it to `current`; only one
* rename wins. The winner builds and publishes while every loser polls and
* reuses the resulting binary. A loser distinguishes two ways a generation
* stops blocking:
*
* - `released`: the holder retired `current` itself — it published, or its build
* threw and its `finally` freed the key. The loser simply retries the
* ordinary acquisition; nothing is stale and nothing is reported.
* - `abandoned`: `current` still exists but its owner is provably dead, it is an
* old metadata-less legacy lock, or the wait budget
* (`PLUGIN_BUILD_LOCK_STEAL_MS`) expired. Only then does the loser report and
* retire precisely that generation before retrying.
*
* Retired generations remain as non-empty tombstones. Release and reclaim both
* rename `current` to the observed generation's deterministic tombstone path.
* Once generation A is retired, a stale observer or old finalizer for A cannot
* rename successor B there because replacing the non-empty tombstone fails
* atomically. `publishBuiltBinary`'s atomic rename remains defense in depth.
*/
function buildUnderPluginLock(
cacheDir: string,
binaryPath: string,
lockInfo: {
label: string;
pluginName: string;
quiet: boolean;
},
build: () => string,
): string {
const lockDir = `${cacheDir}.lock`;
for (;;) {
if (fs.existsSync(binaryPath)) {
touchCacheEntry(cacheDir);
return binaryPath;
}
let lease: PluginBuildLockLease | null;
try {
lease = acquirePluginBuildLock(lockDir);
} catch {
// An unusable coordination directory must not silently skip the build.
// Atomic publication still preserves binary integrity.
return build();
}
if (lease === null) {
const waited = waitForPluginBinary({
binaryPath,
lockDir,
lockInfo,
timeoutMs: PLUGIN_BUILD_LOCK_STEAL_MS,
});
if (waited.outcome === "published") {
touchCacheEntry(cacheDir);
return binaryPath;
}
if (waited.outcome === "abandoned") {
// Retire only the generation that produced this observation. Losing
// the rename race means another waiter (or the holder's normal
// finalizer) already made progress, so do not report a stale result as
// an abandonment.
if (reclaimPluginBuildLock(lockDir, waited.fence)) {
reportPluginLockSteal(lockDir, binaryPath, lockInfo, waited.reason);
}
}
// "released" needs no repair: the holder freed the key normally (its
// build published or failed), so retry the ordinary atomic acquisition.
// Reporting a steal or force-removing the path here would misclassify a
// routine handoff as abandonment (issue #421).
continue;
}
try {
// Re-check under the lock: a previous holder may have just published.
if (fs.existsSync(binaryPath)) {
touchCacheEntry(cacheDir);
return binaryPath;
}
return build();
} finally {
releasePluginBuildLock(lockDir, lease);
}
}
}
/** Opaque identity of one observed lock generation. */
export type PluginBuildLockFence = {
protocol: "legacy" | "v2";
generation: string;
};
/** Ownership token returned only to the process that acquired `current`. */
export type PluginBuildLockLease = {
protocol: "v2";
generation: string;
};
/**
* Atomically acquire the current generation in a v2 coordination directory.
*
* A non-empty candidate is renamed to `current`. Directory rename cannot
* replace a non-empty `current`, so exactly one contender wins without an
* empty-owner publication window. `null` means either another v2 holder won or
* the path is a legacy lock that must be observed before it can be reclaimed.
*
* Exported for deterministic multi-process tests.
*/
export function acquirePluginBuildLock(
lockDir: string,
): PluginBuildLockLease | null {
// A legacy holder owns the old path. Never publish v2 ownership into that
// deletable namespace; wait until the legacy generation is released or
// reclaimed, then use the orthogonal persistent v2 directory.
if (pluginBuildLockPathExists(lockDir)) {
return null;
}
const protocolDir = pluginBuildLockProtocolDir(lockDir);
ensurePluginBuildLockProtocol(protocolDir);
// Close the initialization window as far as the legacy protocol permits. A
// legacy holder that appeared while v2 was initialized still blocks this
// acquisition. (A legacy executable cannot provide a true cross-path CAS.)
if (pluginBuildLockPathExists(lockDir)) {
return null;
}
const generation = crypto.randomBytes(16).toString("hex");
const candidateDir = path.join(protocolDir, `candidate-${generation}`);
fs.mkdirSync(candidateDir);
try {
fs.writeFileSync(
path.join(candidateDir, PLUGIN_BUILD_LOCK_GENERATION_FILE),
`${generation}\n`,
{ encoding: "utf8", flag: "wx" },
);
writePluginBuildLockOwner(candidateDir, generation);
try {
fs.renameSync(
candidateDir,
path.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR),
);
} catch (error) {
if (
isMissingPathError(error) ||
isRenameDestinationOccupied(
error,
path.join(protocolDir, PLUGIN_BUILD_LOCK_CURRENT_DIR),
)
) {
return null;
}
throw error;
}
return { protocol: "v2", generation };
} finally {
// The candidate name contains this process's random generation and can
// never alias `current` or another contender's candidate.
fs.rmSync(candidateDir, { force: true, recursive: true });
}
}
/** Retire a held generation during the holder's `finally`. */
export function releasePluginBuildLock(
lockDir: string,
lease: PluginBuildLockLease,
): boolean {
return retireV2PluginBuildLock(
pluginBuildLockProtocolDir(lockDir),
lease.generation,
);
}
/**
* Retire exactly the generation carried by an abandoned observation.
*
* Exported for deterministic multi-process tests.
*/
export function reclaimPluginBuildLock(
lockDir: string,
fence: PluginBuildLockFence,
): boolean {
if (fence.protocol === "v2") {
return retireV2PluginBuildLock(
pluginBuildLockProtocolDir(lockDir),
fence.generation,
);
}
return retireLegacyPluginBuildLock(lockDir, fence.generation);
}
function pluginBuildLockProtocolDir(lockDir: string): string {
return `${lockDir}${PLUGIN_BUILD_LOCK_V2_SUFFIX}`;
}
function ensurePluginBuildLockProtocol(protocolDir: string): void {
if (isPluginBuildLockProtocolV2(protocolDir)) {
return;
}
const generation = crypto.randomBytes(16).toString("hex");
const candidateDir = `${protocolDir}.candidate-${generation}`;
fs.mkdirSync(candidateDir);
try {
fs.mkdirSync(path.join(candidateDir, PLUGIN_BUILD_LOCK_RETIRED_DIR));
fs.writeFileSync(
path.join(candidateDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE),
PLUGIN_BUILD_LOCK_PROTOCOL,
{ encoding: "utf8", flag: "wx" },
);
try {
fs.renameSync(candidateDir, protocolDir);
} catch (error) {
if (
isRenameDestinationOccupied(error, protocolDir) &&
isPluginBuildLockProtocolV2(protocolDir)
) {
return;
}
throw error;
}
} finally {
fs.rmSync(candidateDir, { force: true, recursive: true });
}
}
function isPluginBuildLockProtocolV2(lockDir: string): boolean {
try {
return (
fs.readFileSync(
path.join(lockDir, PLUGIN_BUILD_LOCK_PROTOCOL_FILE),
"utf8",
) === PLUGIN_BUILD_LOCK_PROTOCOL
);
} catch {
return false;
}
}
function retireV2PluginBuildLock(lockDir: string, generation: string): boolean {
if (!isPluginBuildLockGeneration(generation)) return false;
const retiredDir = path.join(lockDir, PLUGIN_BUILD_LOCK_RETIRED_DIR);
try {
fs.mkdirSync(retiredDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
if (isMissingPathError(error)) return false;
throw error;
}
}
const destination = path.join(retiredDir, generation);
try {
fs.renameSync(
path.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR),
destination,
);
return true;
} catch (error) {
if (
isMissingPathError(error) ||
isRenameDestinationOccupied(error, destination)
) {
return false;
}
throw error;
}
}
function retireLegacyPluginBuildLock(
lockDir: string,
generation: string,
): boolean {
if (!isPluginBuildLockGeneration(generation)) return false;
const captured = readLegacyPluginBuildLockFence(
path.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR),
);
if (captured?.fence.generation !== generation) {
return false;
}
const destination = `${lockDir}.retired-${generation}`;
try {
fs.renameSync(lockDir, destination);
return true;
} catch (error) {
if (
isMissingPathError(error) ||
isRenameDestinationOccupied(error, destination)
) {
return false;
}
throw error;
}
}
function isMissingPathError(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code;
return code === "ENOENT" || code === "ENOTDIR";
}
function isRenameDestinationOccupied(
error: unknown,
destination: string,
): boolean {
const code = (error as NodeJS.ErrnoException).code;
if (code === "EEXIST" || code === "ENOTEMPTY") {
return true;
}
return (code === "EACCES" || code === "EPERM") && fs.existsSync(destination);
}
/**
* Outcome of one waiting session on another process's plugin build lock.
*
* - `published`: the binary exists and can be reused.
* - `released`: the observed generation no longer exists and no binary appeared —
* the holder freed the key normally, so the caller should retry ordinary
* acquisition without reporting or removing anything.
* - `abandoned`: the lock still exists but is provably stale (dead owner, old
* legacy lock) or the wait budget expired; the caller may report and retire
* precisely the attached generation.
*
* Exported for unit tests.
*/
export type PluginBinaryWaitResult =
| { outcome: "published" }
| { outcome: "released" }
| {
outcome: "abandoned";
reason: string;
fence: PluginBuildLockFence;
};
/**
* Poll for the locked builder to publish its binary, up to `timeoutMs`.
*
* Exported for unit tests.
*/
export function waitForPluginBinary(opts: {
binaryPath: string;
lockDir: string;
lockInfo: {
label: string;
pluginName: string;
quiet: boolean;
};
timeoutMs: number;
}): PluginBinaryWaitResult {
const startedAt = Date.now();
let nextStatusAt = startedAt + PLUGIN_BUILD_LOCK_STATUS_MS;
for (;;) {
if (fs.existsSync(opts.binaryPath)) {
return { outcome: "published" };
}
const now = Date.now();
const lock = inspectPluginBuildLock(opts.lockDir, now);
if (lock.state === "released") {
// The holder retired its generation between the binary check above and
// this observation. That is a normal release, not abandonment: prefer the
// binary when it landed inside that window, otherwise hand the free key
// back to the caller.
return fs.existsSync(opts.binaryPath)
? { outcome: "published" }
: { outcome: "released" };
}
if (lock.state === "abandoned") {
return {
outcome: "abandoned",
reason: lock.reason,
fence: lock.fence,
};
}
if (now - startedAt > opts.timeoutMs) {
return {
outcome: "abandoned",
reason: `timed out after ${formatDuration(now - startedAt)}`,
fence: lock.fence,
};
}
if (!opts.lockInfo.quiet && now >= nextStatusAt) {
reportPluginLockWait({
binaryPath: opts.binaryPath,
elapsedMs: now - startedAt,
lockDir: opts.lockDir,
lockInfo: opts.lockInfo,
owner: lock.owner,
});
nextStatusAt = now + PLUGIN_BUILD_LOCK_STATUS_MS;
}
sleepSync(PLUGIN_BUILD_LOCK_POLL_MS);
}
}
function writePluginBuildLockOwner(
generationDir: string,
generation: string,
): void {
fs.writeFileSync(
path.join(generationDir, PLUGIN_BUILD_LOCK_OWNER_FILE),
`${JSON.stringify(
{
generation,
hostname: os.hostname(),
pid: process.pid,
startedAt: new Date().toISOString(),
},
null,
2,
)}\n`,
"utf8",
);
}
/**
* One observation of a plugin build lock directory's state.
*
* - `active`: the lock exists and its owner is alive (or cannot be disproven:
* another host, no metadata but young). Keep waiting.
* - `abandoned`: the lock still exists and the evidence says nobody will ever
* release it — a same-host owner that is no longer running, or an old
* metadata-less legacy lock. Retiring its fenced generation is justified.
* - `released`: the observed generation no longer exists. In v2 the persistent
* coordination root remains while `current` is absent. This is a routine
* handoff, never an infinitely old abandoned lock (issue #421).
*
* Exported for unit tests.
*/
export type PluginBuildLockObservation =
| {
state: "active";
owner: string;
fence: PluginBuildLockFence;
}
| {
state: "abandoned";
reason: string;
fence: PluginBuildLockFence;
}
| { state: "released" };
/**
* Classify the current state of a plugin build lock directory.
*
* Exported for unit tests.
*/
export function inspectPluginBuildLock(
lockDir: string,
now: number,
): PluginBuildLockObservation {
const protocolDir = pluginBuildLockProtocolDir(lockDir);
for (;;) {
if (isPluginBuildLockProtocolV2(protocolDir)) {
const v2 = inspectV2PluginBuildLock(protocolDir, now);
if (v2.state !== "released") {
return v2;
}
}
const legacy = captureLegacyPluginBuildLockFence(lockDir);
if (legacy !== null) {
return inspectLegacyPluginBuildLock(lockDir, now, legacy);
}
if (pluginBuildLockAgeMs(lockDir, now) === null) {
return { state: "released" };
}
// The path changed while its legacy fence was being captured. Re-observe
// the replacement rather than attaching the old state to a new owner.
}
}
function inspectV2PluginBuildLock(
lockDir: string,
now: number,
): PluginBuildLockObservation {
const generationDir = path.join(lockDir, PLUGIN_BUILD_LOCK_CURRENT_DIR);
const generation = readPluginBuildLockGeneration(generationDir);
if (generation === null) {
if (pluginBuildLockAgeMs(generationDir, now) === null) {
return { state: "released" };
}
throw new Error(
`ttsc plugin build lock has no valid ${PLUGIN_BUILD_LOCK_GENERATION_FILE}: ${generationDir}`,
);
}
const fence: PluginBuildLockFence = { protocol: "v2", generation };
const owner = readPluginBuildLockOwner(generationDir);
if (owner !== null) {
const label = describePluginBuildLockOwner(owner);
if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
return {
state: "abandoned",
reason: `${label} is no longer running`,
fence,
};
}
return {
state: "active",
owner: label,
fence,
};
}
const ageMs = pluginBuildLockAgeMs(generationDir, now);
if (ageMs === null) {
return { state: "released" };
}
if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
return {
state: "abandoned",
reason:
`lock generation has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` +
`${formatDuration(ageMs)} old`,
fence,
};
}
return {
state: "active",
owner: `lock generation with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
fence,
};
}
interface LegacyPluginBuildLockFence {
fence: PluginBuildLockFence;
legacyMtimeMs: number;
}
function inspectLegacyPluginBuildLock(
lockDir: string,
now: number,
legacy: LegacyPluginBuildLockFence,
): PluginBuildLockObservation {
const owner = readPluginBuildLockOwner(lockDir);
if (owner !== null) {
const label = describePluginBuildLockOwner(owner);
if (isLocalHostName(owner.hostname) && !isProcessAlive(owner.pid)) {
return {
state: "abandoned",
reason: `${label} is no longer running`,
fence: legacy.fence,
};
}
return {
state: "active",
owner: label,
fence: legacy.fence,
};
}
const ageMs = Math.max(0, now - legacy.legacyMtimeMs);
if (ageMs > PLUGIN_BUILD_LOCK_LEGACY_STALE_MS) {
return {
state: "abandoned",
reason:
`legacy lock has no ${PLUGIN_BUILD_LOCK_OWNER_FILE} and is ` +
`${formatDuration(ageMs)} old`,
fence: legacy.fence,
};
}
return {
state: "active",
owner: `legacy lock with no ${PLUGIN_BUILD_LOCK_OWNER_FILE}`,
fence: legacy.fence,
};
}
function captureLegacyPluginBuildLockFence(
lockDir: string,
): LegacyPluginBuildLockFence | null {
if (isPluginBuildLockProtocolV2(lockDir)) {
return null;
}
let legacyMtimeMs: number;
try {
legacyMtimeMs = fs.statSync(lockDir).mtimeMs;
} catch (error) {
if (isMissingPathError(error)) return null;
throw error;
}
const fenceDir = path.join(lockDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_DIR);
let captured = readLegacyPluginBuildLockFence(fenceDir);
if (captured === null) {
const generation = crypto.randomBytes(16).toString("hex");
// Keep candidates beside the legacy lock. Creating one inside `lockDir`
// would advance its mtime before a contender publishes the shared fence;
// a concurrent contender could then record an old lock as freshly created.
const candidateDir = `${lockDir}.legacy-candidate-${generation}`;
try {
fs.mkdirSync(candidateDir);
fs.writeFileSync(
path.join(candidateDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD),
`${JSON.stringify({ generation, legacyMtimeMs })}\n`,
"utf8",
);
try {
fs.renameSync(candidateDir, fenceDir);
captured = {
fence: { protocol: "legacy", generation },
legacyMtimeMs,
};
} catch (error) {
if (isRenameDestinationOccupied(error, fenceDir)) {
captured = readLegacyPluginBuildLockFence(fenceDir);
} else if (isMissingPathError(error)) {
return null;
} else {
throw error;
}
}
} catch (error) {
if (isMissingPathError(error)) {
return null;
}
throw error;
} finally {
fs.rmSync(candidateDir, { force: true, recursive: true });
}
}
if (captured === null) {
throw new Error(`invalid legacy plugin build lock fence: ${fenceDir}`);
}
// A stale observer can resume after the legacy holder released or another
// process retired the path. Confirm both the legacy layout and token after
// publication; v2 ownership is kept in the orthogonal sibling directory.
const confirmed = readLegacyPluginBuildLockFence(fenceDir);
if (
isPluginBuildLockProtocolV2(lockDir) ||
confirmed === null ||
confirmed.fence.generation !== captured.fence.generation
) {
return null;
}
return captured;
}
function readLegacyPluginBuildLockFence(
fenceDir: string,
): LegacyPluginBuildLockFence | null {
try {
const parsed = JSON.parse(
fs.readFileSync(
path.join(fenceDir, PLUGIN_BUILD_LOCK_LEGACY_FENCE_RECORD),
"utf8",
),
) as Record<string, unknown>;
if (
!isPluginBuildLockGeneration(parsed.generation) ||
typeof parsed.legacyMtimeMs !== "number" ||
!Number.isFinite(parsed.legacyMtimeMs) ||
parsed.legacyMtimeMs < 0
) {
return null;
}
return {
fence: { protocol: "legacy", generation: parsed.generation },
legacyMtimeMs: parsed.legacyMtimeMs,
};
} catch {
return null;
}
}
function readPluginBuildLockGeneration(generationDir: string): string | null {
try {
const generation = fs
.readFileSync(
path.join(generationDir, PLUGIN_BUILD_LOCK_GENERATION_FILE),
"utf8",
)
.trim();
return isPluginBuildLockGeneration(generation) ? generation : null;
} catch {
return null;
}
}
function isPluginBuildLockGeneration(value: unknown): value is string {
return typeof value === "string" && /^[0-9a-f]{32}$/.test(value);
}
function readPluginBuildLockOwner(
lockDir: string,
): { hostname: string; pid: number; startedAt?: string } | null {
try {
const parsed = JSON.parse(
fs.readFileSync(path.join(lockDir, PLUGIN_BUILD_LOCK_OWNER_FILE), "utf8"),
) as Record<string, unknown>;
if (
typeof parsed.hostname !== "string" ||
!Number.isInteger(parsed.pid) ||
typeof parsed.pid !== "number" ||
parsed.pid <= 0
) {
return null;
}
return {
hostname: parsed.hostname,
pid: parsed.pid,
startedAt:
typeof parsed.startedAt === "string" ? parsed.startedAt : undefined,
};
} catch {
return null;
}
}
/**
* Age of an observed lock directory, or `null` when it no longer exists. The
* holder may have retired it between the caller's checks. "Missing" is a
* observation, never encoded as a numeric age: the previous
* `Number.POSITIVE_INFINITY` encoding made a just-released lock look like an
* infinitely old abandoned legacy lock (issue #421).
*
* A stat failure that does not prove absence (e.g. `EPERM`) clamps to age 0:
* the lock is treated as fresh so a waiter never steals on ambiguous evidence,
* while the caller's wait budget still bounds the stall.
*/
function pluginBuildLockAgeMs(lockDir: string, now: number): number | null {
try {
return Math.max(0, now - fs.statSync(lockDir).mtimeMs);
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
return code === "ENOENT" || code === "ENOTDIR" ? null : 0;
}
}
function pluginBuildLockPathExists(lockDir: string): boolean {
try {
fs.statSync(lockDir);
return true;
} catch (error) {
if (isMissingPathError(error)) return false;
throw error;
}
}
function isLocalHostName(hostname: string): boolean {
return hostname.toLowerCase() === os.hostname().toLowerCase();
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}
function describePluginBuildLockOwner(owner: {
hostname: string;
pid: number;
startedAt?: string;
}): string {
const started =
owner.startedAt === undefined ? "" : ` started at ${owner.startedAt}`;
return `pid ${owner.pid} on ${owner.hostname}${started}`;
}
function reportPluginLockWait(opts: {
binaryPath: string;
elapsedMs: number;
lockDir: string;
lockInfo: {
label: string;
pluginName: string;
quiet: boolean;
};
owner: string;
}): void {
process.stderr.write(
`ttsc: waiting for ${opts.lockInfo.label} "${opts.lockInfo.pluginName}" ` +
`cache lock after ${formatDuration(opts.elapsedMs)}; ` +
`lock=${opts.lockDir}; binary=${opts.binaryPath}; owner=${opts.owner}\n`,
);
}
function reportPluginLockSteal(
lockDir: string,
binaryPath: string,
lockInfo: {
label: string;
pluginName: string;
quiet: boolean;
},
reason: string,
): void {
if (lockInfo.quiet) return;
process.stderr.write(
`ttsc: reclaiming abandoned ${lockInfo.label} "${lockInfo.pluginName}" ` +
`cache lock at ${lockDir}; binary=${binaryPath} (${reason})\n`,
);
}
/**
* Render a millisecond duration for lock diagnostics (`137ms`, `42s`, `9m 3s`).
*
* Total over every number: no caller produces a non-finite duration anymore
* (the lock state machine reports "released" instead of an Infinity age), but
* as defense in depth a non-finite input renders as `an unknown time` so no
* public diagnostic can ever print `Infinitym NaNs` again (issue #421).
*
* Exported for unit tests.
*/
export function formatDuration(ms: number): string {
if (!Number.isFinite(ms)) {
return "an unknown time";
}
if (ms < 1_000) {
return `${Math.max(0, Math.round(ms))}ms`;
}
const seconds = Math.floor(ms / 1_000);
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
if (minutes === 0) {
return `${seconds}s`;
}
return `${minutes}m ${remainder}s`;
}
/** Block the current (synchronous) thread for `ms` without busy-spinning. */
function sleepSync(ms: number): void {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
/**
* Copy every contributor's Go source into a sub-package of the host module and
* synthesize a blank-import file alongside the host's entry package so each
* contributor's `init()` runs before `main`.
*
* - Sources land at `<scratch>/<CONTRIB_DIRNAME>/<name>/` (recursive copy with
* the same pruning rules used for the host source).
* - The entry directory receives `<CONTRIBUTIONS_FILE_NAME>` containing one
* blank-import per contributor. The host's module path is read from the
* materialized go.mod, so the import path is always correct for the host
* plugin's actual module declaration.
* - Contributors that ship their own `go.mod` are rejected — the design relies on
* the contributor living inside the host's module so that workspace overlay
* rules and the host's `go.sum` cover transitive dependencies. This also
* closes the supply-chain hole where a contributor could otherwise pull in
* arbitrary Go modules.
*/
function mergeContributors(opts: {
contributors: readonly ITtscBuildContributor[];
entry: string;
goModReader: GoModReader;
pluginName: string;
scratchDir: string;
}): void {
const hostModulePath = opts.goModReader.read(opts.scratchDir).modulePath;
if (hostModulePath === null || hostModulePath === "") {
throw new Error(
`ttsc: plugin "${opts.pluginName}" cannot accept contributors because its module ` +
`root has no resolvable go.mod module path`,
);
}
const contribRoot = path.join(opts.scratchDir, CONTRIB_DIRNAME);
// Refuse to merge when the host plugin's own source already owns a
// `contrib/` directory. We'd otherwise silently merge contributor
// files into a pre-populated host package and ship a hybrid binary
// whose contents nobody declared. Loud failure is the only safe
// option — the host plugin must rename its directory or the
// contributor system must use a different sub-package root.
if (fs.existsSync(contribRoot)) {
throw new Error(
`ttsc: plugin "${opts.pluginName}" already ships a ${CONTRIB_DIRNAME}/ directory in its source; ` +
`contributor merge would silently overwrite. Rename the host plugin's directory to a different name.`,
);
}
fs.mkdirSync(contribRoot, { recursive: true });
// Sort contributors by name so the synthesized `ttsc_contributions.go`
// emits blank imports in a deterministic order independent of
// declaration order. The cache key is already sort-stable
// (`computeCacheKey` sorts contributors by name), so without this
// matching sort the SAME cache key could correspond to two distinct
// binaries whose `init()` sequence across contributors differs by
// import order.
const sortedContributors = [...opts.contributors].sort((a, b) =>
a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
);
const imports: string[] = [];
for (const contributor of sortedContributors) {
if (fs.existsSync(path.join(contributor.source, "go.mod"))) {
throw new Error(
`ttsc: plugin "${opts.pluginName}" contributor "${contributor.name}" must ship Go ` +
`source as a package, not a module (go.mod found at ${contributor.source}/go.mod). ` +
`Remove go.mod so the contributor compiles inside the host module's dependency graph.`,
);
}
const target = path.join(contribRoot, contributor.name);
if (fs.existsSync(target)) {
// Defensive: validatePluginContributors already rejects duplicate
// names, and the contribRoot-existence guard above blocks the
// host plugin from pre-shipping a `contrib/` directory. Reaching
// this branch implies an upstream contract break. Fail loud
// rather than overwrite.
throw new Error(
`ttsc: plugin "${opts.pluginName}" contributor "${contributor.name}" target ${target} already exists; ` +
`contributor names must be unique within one plugin build`,
);
}
fs.cpSync(contributor.source, target, {
recursive: true,
filter: (src) => {
const base = path.basename(src);
if (shouldPruneDirectory(base)) return false;
if (shouldOmitSourceFile(base)) return false;
return true;
},
});
imports.push(`${hostModulePath}/${CONTRIB_DIRNAME}/${contributor.name}`);
}
const entryDir = path.resolve(opts.scratchDir, opts.entry);
fs.mkdirSync(entryDir, { recursive: true });
const contributionsPath = path.join(entryDir, CONTRIBUTIONS_FILE_NAME);
// Same reasoning as the contribRoot guard: when entry resolves to the
// module root (`entry === "."`), entryDir == scratchDir and a
// pre-existing `ttsc_contributions.go` from the host plugin's own
// source would be silently overwritten by the generator below.
if (fs.existsSync(contributionsPath)) {
throw new Error(
`ttsc: plugin "${opts.pluginName}" already ships ${CONTRIBUTIONS_FILE_NAME} in its entry package; ` +
`that filename is reserved for the contributor blank-import generator. Rename the host's file.`,
);
}
writeContributionsFile(contributionsPath, imports);
}
function writeContributionsFile(filePath: string, imports: string[]): void {
const importLines = imports
.map((spec) => `\t_ ${JSON.stringify(spec)}`)
.join("\n");
const body = `// Code generated by ttsc — DO NOT EDIT.
//
// This file is synthesized by ttsc's plugin builder when the host plugin
// descriptor declares "contributors". The blank imports below pull each
// contributor sub-package into the build so its init() runs before main.
package main
import (
${importLines}
)
`;
fs.writeFileSync(filePath, body, "utf8");
}
function publishBuiltBinary(builtBinary: string, binaryPath: string): void {
const pending = `${binaryPath}.${process.pid}.${Date.now()}-${Math.random()
.toString(16)
.slice(2)}.tmp`;
fs.copyFileSync(builtBinary, pending);
if (process.platform !== "win32") {
fs.chmodSync(pending, 0o755);
}
try {
fs.renameSync(pending, binaryPath);
} catch (error) {
fs.rmSync(pending, { force: true });
const code = (error as NodeJS.ErrnoException).code;
if (
(code === "EEXIST" || code === "EPERM" || code === "EACCES") &&
fs.existsSync(binaryPath)
) {
return;
}
throw error;
} finally {
// Best-effort sweep of any leftover `.tmp` siblings from a prior
// crash between copyFileSync and renameSync. Same-directory pending
// names guarantee the rename stays a same-filesystem atomic op, so
// we accept the GC cost rather than move pending files to os.tmpdir.
pruneOrphanPendingBinaries(binaryPath);
}
}
function pruneOrphanPendingBinaries(binaryPath: string): void {
// Only sweep pending files owned by THIS process. Concurrent ttsc
// invocations (two `ttsc --watch` shells against the same project)
// may have their own `<binary>.<their-pid>.*.tmp` mid-flight, and
// deleting them would race their renameSync into ENOENT.
try {
const dir = path.dirname(binaryPath);
const prefix = `${path.basename(binaryPath)}.${process.pid}.`;
for (const name of fs.readdirSync(dir)) {
if (name.startsWith(prefix) && name.endsWith(".tmp")) {
fs.rmSync(path.join(dir, name), { force: true });
}
}
} catch {
// Best-effort; never mask the underlying publish outcome.
}
}
function resolveSourceBuildTarget(opts: {
source: string;
pluginName: string;
baseDir: string;
}): {
dir: string;
entry: string;
source: string;
} {
const source = path.isAbsolute(opts.source)
? opts.source
: path.resolve(opts.baseDir, opts.source);
if (!fs.existsSync(source)) {
throw new Error(
`ttsc: plugin "${opts.pluginName}" source does not exist: ${source}`,
);
}
const stat = fs.statSync(source);
const packageDir =
stat.isFile() && path.basename(source) === "go.mod"
? path.dirname(source)
: stat.isDirectory()
? source
: null;
if (packageDir === null) {
throw new Error(
`ttsc: plugin "${opts.pluginName}" source must be a Go package directory or go.mod file: ${source}`,
);
}
const goMod = findNearestGoMod(packageDir, GO_MOD_SEARCH_MAX_DEPTH);
if (goMod === null) {
throw new Error(
`ttsc: plugin "${opts.pluginName}" source must be inside a Go module with go.mod within ${GO_MOD_SEARCH_MAX_DEPTH} parent directories: ${source}`,
);
}
const dir = path.dirname(goMod);
const rel = path.relative(dir, packageDir).replace(/\\/g, "/");
return {
dir,
entry: rel === "" ? "." : `./${rel}`,
source,
};
}
function materializeScratchDir(source: string, scratch: string): void {
fs.mkdirSync(scratch, { recursive: true });
fs.cpSync(source, scratch, {
recursive: true,
filter: (src) => {
const base = path.basename(src);
if (shouldPruneDirectory(base)) return false;
if (shouldOmitSourceFile(base)) return false;
return true;
},
});
}
function writeGoWork(
scratchDir: string,
useDirs: readonly string[],
goBinary: string,
pluginName: string,
env: NodeJS.ProcessEnv,
): void {
const goModReader = createGoModReader(goBinary, pluginName, env);
validateSourceReplacements(scratchDir, useDirs, goModReader, pluginName);
const sourceInfo = goModReader.read(scratchDir);
const effectiveUseDirs =
sourceInfo.modulePath === TTSC_GO_MODULE_PATH
? useDirs.filter((dir) => {
const modulePath = goModReader.read(dir).modulePath;
return modulePath !== null && !isTtscManagedModulePath(modulePath);
})
: useDirs;
const useLines = ["\t."];
for (const dir of effectiveUseDirs) {
useLines.push(`\t${formatGoWorkPath(dir)}`);
}
const replaceLines = sourceBuildWorkspaceReplacements(
effectiveUseDirs,
goModReader,
);
const replaceBlock =
replaceLines.length === 0 ? "" : `\n\n${replaceLines.join("\n")}\n`;
const goWork = `go 1.26\n\nuse (\n${useLines.join("\n")}\n)${replaceBlock}`;
fs.writeFileSync(path.join(scratchDir, "go.work"), goWork, "utf8");
}
function validateSourceReplacements(
scratchDir: string,
useDirs: readonly string[],
goModReader: GoModReader,
pluginName: string,
): void {
const sourceInfo = goModReader.read(scratchDir);
if (sourceInfo.modulePath === TTSC_GO_MODULE_PATH) {
return;
}
const sourceReplacements = sourceInfo.replacements;
if (sourceReplacements.length === 0) {
return;
}
const overlayModules = collectOverlayModulePaths(useDirs, goModReader);
for (const replacement of sourceReplacements) {
if (
isTtscManagedModulePath(replacement.modulePath) ||
overlayModules.has(replacement.modulePath)
) {
throw new Error(
`ttsc: plugin "${pluginName}" go.mod replaces ttsc-managed module ` +
`${JSON.stringify(replacement.modulePath)}. Remove this replace directive; ` +
`ttsc supplies its own compiler and shim modules while building source plugins.`,
);
}
}
}
function sourceBuildWorkspaceReplacements(
useDirs: readonly string[],
goModReader: GoModReader,
): string[] {
const ttscRoot = useDirs.find(
(dir) => goModReader.read(dir).modulePath === TTSC_GO_MODULE_PATH,
);
if (!ttscRoot) {
return [];
}
return [
`replace ${TTSC_GO_MODULE_PATH} v0.0.0 => ${formatGoWorkPath(ttscRoot)}`,
];
}
/**
* Format an absolute filesystem path as a single `go.work`/`go.mod` token.
*
* The modfile grammar shared by `go.mod` and `go.work` (parsed by
* `golang.org/x/mod/modfile`) is whitespace-tokenized, so a `use`/`replace`
* path that contains a space — a home or project directory such as `/Users/John
* Smith/...` or `C:\Users\John Smith\...` — must be emitted as a quoted string
* or `go` cannot parse the generated `go.work`. Normalize Windows separators to
* `/` (the workspace convention) and then delegate to
* {@link autoQuoteGoModToken}, which mirrors `modfile.AutoQuote`.
*
* Separator normalization is itself a quoting trigger. A Windows UNC
* (`\\server\share\...`) or extended-length (`\\?\C:\...`) path normalizes into
* a token that starts with `//`, and the modfile lexer reads `//` as a line
* comment wherever it appears. Emitted bare, such a token turns its whole
* `use`/`replace` line into a comment: `go` exits 0, reports nothing, and the
* overlay module simply disappears from the workspace.
*
* Exported for unit tests.
*/
export function formatGoWorkPath(p: string): string {
return autoQuoteGoModToken(p.replace(/\\/g, "/"));
}
/**
* Quote `token` for a `go.mod`/`go.work` line exactly as
* `golang.org/x/mod/modfile`'s `AutoQuote` does: return it unchanged when it is
* already a clean bare token, otherwise return its Go double-quoted form so the
* value round-trips through the modfile lexer. A clean bare token is therefore
* emitted byte-for-byte as before; only tokens that would otherwise be split or
* interpreted as comments are quoted.
*
* Exported for unit tests.
*/
export function autoQuoteGoModToken(token: string): string {
return mustQuoteGoModToken(token) ? goQuoteString(token) : token;
}
// Mirror `modfile.MustQuote`: report whether `s` must be quoted to appear as a
// single token on a modfile line.
function mustQuoteGoModToken(s: string): boolean {
for (const ch of s) {
if (ch === " " || ch === '"' || ch === "'" || ch === "`") {
return true;
}
if (
ch === "(" ||
ch === ")" ||
ch === "[" ||
ch === "]" ||
ch === "{" ||
ch === "}" ||
ch === ","
) {
// Go tests `len(s) > 1` (byte length): a lone bracket/comma is a legal
// bare token, but one embedded in a longer token forces quoting.
if (Buffer.byteLength(s, "utf8") > 1) {
return true;
}
continue;
}
if (!isGoPrintable(ch)) {
return true;
}
}
return s === "" || s.includes("//") || s.includes("/*");
}
// Mirror `strconv.Qu