Skip to content

Commit

Permalink
Allow extends property in tsconfig.json files
Browse files Browse the repository at this point in the history
`tsc` supports `tsconfig.json` files having an `extends` property, which
references another JSON file that can contain additional properties that
will be used.

Update `parseOutput.mts` to output the list of config files as part of
the `dyndep` file so we will re-typecheck/transpile whenever the config
file is updated.  Note that is is not perfect - it will break if you
change any option that controls the number or paths of the output files,
or you add/remove additional files in `extends`.  However, if you are
changing warning levels or anything to do with typechecking it should
work well.

Additionally, use the parser inside `typescript` instead of `JSON.parse`
as the format is `jsonc` (JSON with comments) and not pure JSON.
  • Loading branch information
elliotgoodrich committed Jun 24, 2024
1 parent 82e8745 commit 0cebc1e
Show file tree
Hide file tree
Showing 4 changed files with 259 additions and 81 deletions.
49 changes: 38 additions & 11 deletions integration/src/tsc.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -219,21 +219,37 @@ describe("tsc", (suiteCtx) => {
"function greet(msg): void { console.log(msg); }\ngreet('Hello World!');\n",
);

const baseConfig = join(dir, "base.json");
writeFileSync(
baseConfig,
"// tsconfig is a jsonc file and allows comments\n" +
JSON.stringify(
{
compilerOptions: {
outDir: "myOutput",
},
},
undefined,
4,
),
);

const tsConfig = join(dir, "tsconfig.json");
writeFileSync(
tsConfig,
JSON.stringify(
{
files: [script],
compilerOptions: {
noImplicitAny: false,
outDir: "myOutput",
skipLibCheck: true,
"// tsconfig is a jsonc file and allows comments\n" +
JSON.stringify(
{
files: [script],
extends: ["./base"],
compilerOptions: {
noImplicitAny: false,
skipLibCheck: true,
},
},
},
undefined,
4,
),
undefined,
4,
),
);

const ninja = new NinjaBuilder({}, dir);
Expand Down Expand Up @@ -287,6 +303,17 @@ describe("tsc", (suiteCtx) => {

assert.strictEqual(callNinja(dir).trimEnd(), "ninja: no work to do.");

const deps = getDeps(dir);
assert.notDeepEqual(
deps["myOutput/script.mjs"].indexOf("tsconfig.json"),
-1,
"Missing tsconfig.json dependency",
);
assert.notDeepEqual(
deps["myOutput/script.mjs"].indexOf("base.json"),
"Missing base.json dependency",
);

{
const { stdout } = callNinjaWithFailure(dir, err);
assert.match(
Expand Down
2 changes: 1 addition & 1 deletion packages/tsc/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ninjutsu-build/tsc",
"version": "0.12.5",
"version": "0.12.6",
"description": "Create a ninjutsu-build rule for running the TypeScript compiler (tsc)",
"author": "Elliot Goodrich",
"scripts": {
Expand Down
75 changes: 65 additions & 10 deletions packages/tsc/src/parseOutput.mts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createInterface } from "node:readline";
import { isAbsolute, relative } from "node:path";
import { realpath } from "node:fs/promises";
import { writeFileSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { realpath, readFile, writeFile } from "node:fs/promises";
import { parseArgs } from "node:util";
import { fileURLToPath, pathToFileURL } from "node:url";
import ts from "typescript";

const cwd = process.cwd();

Expand Down Expand Up @@ -35,19 +36,66 @@ async function convertToPath(line: string): Promise<string> {
.replaceAll("$", "$$$$");
}

// Get a list of all `tsconfig.json` files (relative to `parentAbsolutePath`)
// containing `tsconfigPath` and all the `.json` files it extends.
async function getTSConfigDynDeps(
tsconfigAbsPath: string | undefined,
parentAbsolutePath: string,
): Promise<string[]> {
if (tsconfigAbsPath === undefined) {
return [];
}

const contents = await readFile(tsconfigAbsPath);
const { config, error } = ts.parseConfigFileTextToJson(
tsconfigAbsPath,
contents.toString(),
);
if (config === undefined) {
if (error !== undefined) {
console.log(error.messageText);
} else {
console.log(`Unknown error while parsing ${tsconfigAbsPath}`);
}
process.exit(1);
}
const obj = config as { extends?: string };
if (obj.extends === undefined) {
return [await convertToPath(tsconfigAbsPath)];
}

const base = fileURLToPath(
import.meta.resolve(`${obj.extends}.json`, pathToFileURL(tsconfigAbsPath)),
);
return (
await Promise.all([
convertToPath(tsconfigAbsPath),
getTSConfigDynDeps(base, resolve(parentAbsolutePath, tsconfigAbsPath)),
])
).flat();
}

async function main() {
const {
positionals: [out],
values: { touch },
values: { touch, tsconfig },
} = parseArgs({
allowPositionals: true,
options: { touch: { type: "boolean" } },
options: {
touch: { type: "boolean" },
tsconfig: { type: "string" },
},
});

// Build up dependencies on any tsconfig.json files and
// any that it extends.
const tsconfigDyndeps = getTSConfigDynDeps(
tsconfig === undefined ? undefined : resolve(tsconfig),
process.cwd(),
);

const lines: string[] = [];
for await (const line of createInterface({
input: process.stdin,
})) {
for await (const line of createInterface({ input: process.stdin })) {
lines.push(line);
}

Expand All @@ -58,11 +106,18 @@ async function main() {
lines.filter((l) => l !== "").map(convertToPath),
);

const tsconfigs = await tsconfigDyndeps;

// The ".depfile" suffix must match what's used in `node.ts`
writeFileSync(out + ".depfile", out + ": " + paths.join(" "));
const depfile = writeFile(
out + ".depfile",
out + ": " + paths.concat(tsconfigs).join(" "),
);
if (touch) {
writeFileSync(out, "");
await writeFile(out, "");
}

await depfile;
} else {
// Drop the `--listFiles` content printed at the end until we get to the
// error messages. Assume that all the paths end in `ts` (e.g. `.d.ts`
Expand Down
Loading

0 comments on commit 0cebc1e

Please sign in to comment.