• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1// @ts-check
2/// <reference path="../types/ambient.d.ts" />
3
4const fs = require("fs");
5const path = require("path");
6const log = require("fancy-log");
7const mkdirp = require("mkdirp");
8const del = require("del");
9const File = require("vinyl");
10const ts = require("../../lib/typescript");
11const chalk = require("chalk");
12const { spawn } = require("child_process");
13const { CancellationToken, CancelError, Deferred } = require("prex");
14const { Readable, Duplex } = require("stream");
15
16const isWindows = /^win/.test(process.platform);
17
18/**
19 * Executes the provided command once with the supplied arguments.
20 * @param {string} cmd
21 * @param {string[]} args
22 * @param {ExecOptions} [options]
23 *
24 * @typedef ExecOptions
25 * @property {boolean} [ignoreExitCode]
26 * @property {import("prex").CancellationToken} [cancelToken]
27 * @property {boolean} [hidePrompt]
28 * @property {boolean} [waitForExit=true]
29 */
30function exec(cmd, args, options = {}) {
31    return /**@type {Promise<{exitCode: number}>}*/(new Promise((resolve, reject) => {
32        const { ignoreExitCode, cancelToken = CancellationToken.none, waitForExit = true } = options;
33        cancelToken.throwIfCancellationRequested();
34
35        // TODO (weswig): Update child_process types to add windowsVerbatimArguments to the type definition
36        const subshellFlag = isWindows ? "/c" : "-c";
37        const command = isWindows ? [possiblyQuote(cmd), ...args] : [`${cmd} ${args.join(" ")}`];
38
39        if (!options.hidePrompt) log(`> ${chalk.green(cmd)} ${args.join(" ")}`);
40        const proc = spawn(isWindows ? "cmd" : "/bin/sh", [subshellFlag, ...command], { stdio: waitForExit ? "inherit" : "ignore", windowsVerbatimArguments: true });
41        const registration = cancelToken.register(() => {
42            log(`${chalk.red("killing")} '${chalk.green(cmd)} ${args.join(" ")}'...`);
43            proc.kill("SIGINT");
44            proc.kill("SIGTERM");
45            reject(new CancelError());
46        });
47        if (waitForExit) {
48            proc.on("exit", exitCode => {
49                registration.unregister();
50                if (exitCode === 0 || ignoreExitCode) {
51                    resolve({ exitCode });
52                }
53                else {
54                    reject(new Error(`Process exited with code: ${exitCode}`));
55                }
56            });
57            proc.on("error", error => {
58                registration.unregister();
59                reject(error);
60            });
61        }
62        else {
63            proc.unref();
64            // wait a short period in order to allow the process to start successfully before Node exits.
65            setTimeout(() => resolve({ exitCode: undefined }), 100);
66        }
67    }));
68}
69exports.exec = exec;
70
71/**
72 * @param {string} cmd
73 */
74function possiblyQuote(cmd) {
75    return cmd.indexOf(" ") >= 0 ? `"${cmd}"` : cmd;
76}
77
78/**
79 * @param {ts.Diagnostic[]} diagnostics
80 * @param {{ cwd?: string, pretty?: boolean }} [options]
81 */
82function formatDiagnostics(diagnostics, options) {
83    return options && options.pretty
84        ? ts.formatDiagnosticsWithColorAndContext(diagnostics, getFormatDiagnosticsHost(options && options.cwd))
85        : ts.formatDiagnostics(diagnostics, getFormatDiagnosticsHost(options && options.cwd));
86}
87exports.formatDiagnostics = formatDiagnostics;
88
89/**
90 * @param {ts.Diagnostic[]} diagnostics
91 * @param {{ cwd?: string }} [options]
92 */
93function reportDiagnostics(diagnostics, options) {
94    log(formatDiagnostics(diagnostics, { cwd: options && options.cwd, pretty: process.stdout.isTTY }));
95}
96exports.reportDiagnostics = reportDiagnostics;
97
98/**
99 * @param {string | undefined} cwd
100 * @returns {ts.FormatDiagnosticsHost}
101 */
102function getFormatDiagnosticsHost(cwd) {
103    return {
104        getCanonicalFileName: fileName => fileName,
105        getCurrentDirectory: () => cwd,
106        getNewLine: () => ts.sys.newLine,
107    };
108}
109exports.getFormatDiagnosticsHost = getFormatDiagnosticsHost;
110
111/**
112 * Reads JSON data with optional comments using the LKG TypeScript compiler
113 * @param {string} jsonPath
114 */
115function readJson(jsonPath) {
116    const jsonText = fs.readFileSync(jsonPath, "utf8");
117    const result = ts.parseConfigFileTextToJson(jsonPath, jsonText);
118    if (result.error) {
119        reportDiagnostics([result.error]);
120        throw new Error("An error occurred during parse.");
121    }
122    return result.config;
123}
124exports.readJson = readJson;
125
126/**
127 * @param {File} file
128 */
129function streamFromFile(file) {
130    return file.isBuffer() ? streamFromBuffer(file.contents) :
131        file.isStream() ? file.contents :
132        fs.createReadStream(file.path, { autoClose: true });
133}
134exports.streamFromFile = streamFromFile;
135
136/**
137 * @param {Buffer} buffer
138 */
139function streamFromBuffer(buffer) {
140    return new Readable({
141        read() {
142            this.push(buffer);
143            this.push(null);
144        }
145    });
146}
147exports.streamFromBuffer = streamFromBuffer;
148
149/**
150 * @param {string | string[]} source
151 * @param {string | string[]} dest
152 * @returns {boolean}
153 */
154function needsUpdate(source, dest) {
155    if (typeof source === "string" && typeof dest === "string") {
156        if (fs.existsSync(dest)) {
157            const {mtime: outTime} = fs.statSync(dest);
158            const {mtime: inTime} = fs.statSync(source);
159            if (+inTime <= +outTime) {
160                return false;
161            }
162        }
163    }
164    else if (typeof source === "string" && typeof dest !== "string") {
165        const {mtime: inTime} = fs.statSync(source);
166        for (const filepath of dest) {
167            if (fs.existsSync(filepath)) {
168                const {mtime: outTime} = fs.statSync(filepath);
169                if (+inTime > +outTime) {
170                    return true;
171                }
172            }
173            else {
174                return true;
175            }
176        }
177        return false;
178    }
179    else if (typeof source !== "string" && typeof dest === "string") {
180        if (fs.existsSync(dest)) {
181            const {mtime: outTime} = fs.statSync(dest);
182            for (const filepath of source) {
183                if (fs.existsSync(filepath)) {
184                    const {mtime: inTime} = fs.statSync(filepath);
185                    if (+inTime > +outTime) {
186                        return true;
187                    }
188                }
189                else {
190                    return true;
191                }
192            }
193            return false;
194        }
195    }
196    else if (typeof source !== "string" && typeof dest !== "string") {
197        for (let i = 0; i < source.length; i++) {
198            if (!dest[i]) {
199                continue;
200            }
201            if (fs.existsSync(dest[i])) {
202                const {mtime: outTime} = fs.statSync(dest[i]);
203                const {mtime: inTime} = fs.statSync(source[i]);
204                if (+inTime > +outTime) {
205                    return true;
206                }
207            }
208            else {
209                return true;
210            }
211        }
212        return false;
213    }
214    return true;
215}
216exports.needsUpdate = needsUpdate;
217
218function getDiffTool() {
219    const program = process.env.DIFF;
220    if (!program) {
221        log.warn("Add the 'DIFF' environment variable to the path of the program you want to use.");
222        process.exit(1);
223    }
224    return program;
225}
226exports.getDiffTool = getDiffTool;
227
228/**
229 * Find the size of a directory recursively.
230 * Symbolic links can cause a loop.
231 * @param {string} root
232 * @returns {number} bytes
233 */
234function getDirSize(root) {
235    const stats = fs.lstatSync(root);
236
237    if (!stats.isDirectory()) {
238        return stats.size;
239    }
240
241    return fs.readdirSync(root)
242        .map(file => getDirSize(path.join(root, file)))
243        .reduce((acc, num) => acc + num, 0);
244}
245exports.getDirSize = getDirSize;
246
247/**
248 * Flattens a project with project references into a single project.
249 * @param {string} projectSpec The path to a tsconfig.json file or its containing directory.
250 * @param {string} flattenedProjectSpec The output path for the flattened tsconfig.json file.
251 * @param {FlattenOptions} [options] Options used to flatten a project hierarchy.
252 *
253 * @typedef FlattenOptions
254 * @property {string} [cwd] The path to use for the current working directory. Defaults to `process.cwd()`.
255 * @property {import("../../lib/typescript").CompilerOptions} [compilerOptions] Compiler option overrides.
256 * @property {boolean} [force] Forces creation of the output project.
257 * @property {string[]} [exclude] Files to exclude (relative to `cwd`)
258 */
259function flatten(projectSpec, flattenedProjectSpec, options = {}) {
260    const cwd = normalizeSlashes(options.cwd ? path.resolve(options.cwd) : process.cwd());
261    const files = [];
262    const resolvedOutputSpec = path.resolve(cwd, flattenedProjectSpec);
263    const resolvedOutputDirectory = path.dirname(resolvedOutputSpec);
264    const resolvedProjectSpec = resolveProjectSpec(projectSpec, cwd, undefined);
265    const project = readJson(resolvedProjectSpec);
266    const skipProjects = /**@type {Set<string>}*/(new Set());
267    const skipFiles = new Set(options && options.exclude && options.exclude.map(file => normalizeSlashes(path.resolve(cwd, file))));
268    recur(resolvedProjectSpec, project);
269
270    if (options.force || needsUpdate(files, resolvedOutputSpec)) {
271        const config = {
272            extends: normalizeSlashes(path.relative(resolvedOutputDirectory, resolvedProjectSpec)),
273            compilerOptions: options.compilerOptions || {},
274            files: files.map(file => normalizeSlashes(path.relative(resolvedOutputDirectory, file)))
275        };
276        mkdirp.sync(resolvedOutputDirectory);
277        fs.writeFileSync(resolvedOutputSpec, JSON.stringify(config, undefined, 2), "utf8");
278    }
279
280    /**
281     * @param {string} projectSpec
282     * @param {object} project
283     */
284    function recur(projectSpec, project) {
285        if (skipProjects.has(projectSpec)) return;
286        skipProjects.add(project);
287        if (project.references) {
288            for (const ref of project.references) {
289                const referencedSpec = resolveProjectSpec(ref.path, cwd, projectSpec);
290                const referencedProject = readJson(referencedSpec);
291                recur(referencedSpec, referencedProject);
292            }
293        }
294        if (project.include) {
295            throw new Error("Flattened project may not have an 'include' list.");
296        }
297        if (!project.files) {
298            throw new Error("Flattened project must have an explicit 'files' list.");
299        }
300        const projectDirectory = path.dirname(projectSpec);
301        for (let file of project.files) {
302            file = normalizeSlashes(path.resolve(projectDirectory, file));
303            if (skipFiles.has(file)) continue;
304            skipFiles.add(file);
305            files.push(file);
306        }
307    }
308}
309exports.flatten = flatten;
310
311/**
312 * @param {string} file
313 */
314function normalizeSlashes(file) {
315    return file.replace(/\\/g, "/");
316}
317
318/**
319 * @param {string} projectSpec
320 * @param {string} cwd
321 * @param {string | undefined} referrer
322 * @returns {string}
323 */
324function resolveProjectSpec(projectSpec, cwd, referrer) {
325    let projectPath = normalizeSlashes(path.resolve(cwd, referrer ? path.dirname(referrer) : "", projectSpec));
326    const stats = fs.statSync(projectPath);
327    if (stats.isFile()) return normalizeSlashes(projectPath);
328    return normalizeSlashes(path.resolve(cwd, projectPath, "tsconfig.json"));
329}
330
331/**
332 * @param {string | ((file: File) => string) | { cwd?: string }} [dest]
333 * @param {{ cwd?: string }} [opts]
334 */
335function rm(dest, opts) {
336    if (dest && typeof dest === "object") opts = dest, dest = undefined;
337    let failed = false;
338
339    const cwd = path.resolve(opts && opts.cwd || process.cwd());
340
341    /** @type {{ file: File, deleted: boolean, promise: Promise<any>, cb: Function }[]} */
342    const pending = [];
343
344    const processDeleted = () => {
345        if (failed) return;
346        while (pending.length && pending[0].deleted) {
347            const { file, cb } = pending.shift();
348            duplex.push(file);
349            cb();
350        }
351    };
352
353    const duplex = new Duplex({
354        objectMode: true,
355        /**
356         * @param {string|Buffer|File} file
357         */
358        write(file, _, cb) {
359            if (failed) return;
360            if (typeof file === "string" || Buffer.isBuffer(file)) return cb(new Error("Only Vinyl files are supported."));
361            const basePath = typeof dest === "string" ? path.resolve(cwd, dest) :
362                typeof dest === "function" ? path.resolve(cwd, dest(file)) :
363                file.base;
364            const filePath = path.resolve(basePath, file.relative);
365            file.cwd = cwd;
366            file.base = basePath;
367            file.path = filePath;
368            const entry = {
369                file,
370                deleted: false,
371                cb,
372                promise: del(file.path).then(() => {
373                    entry.deleted = true;
374                    processDeleted();
375                }, err => {
376                    failed = true;
377                    pending.length = 0;
378                    cb(err);
379                })
380            };
381            pending.push(entry);
382        },
383        final(cb) {
384            const endThenCb = () => (duplex.push(null), cb()); // signal end of read queue
385            processDeleted();
386            if (pending.length) {
387                Promise
388                    .all(pending.map(entry => entry.promise))
389                    .then(() => processDeleted())
390                    .then(() => endThenCb(), endThenCb);
391                return;
392            }
393            endThenCb();
394        },
395        read() {
396        }
397    });
398    return duplex;
399}
400exports.rm = rm;
401
402class Debouncer {
403    /**
404     * @param {number} timeout
405     * @param {() => Promise<any>} action
406     */
407    constructor(timeout, action) {
408        this._timeout = timeout;
409        this._action = action;
410    }
411
412    enqueue() {
413        if (this._timer) {
414            clearTimeout(this._timer);
415            this._timer = undefined;
416        }
417
418        if (!this._deferred) {
419            this._deferred = new Deferred();
420        }
421
422        this._timer = setTimeout(() => this.run(), 100);
423        return this._deferred.promise;
424    }
425
426    run() {
427        if (this._timer) {
428            clearTimeout(this._timer);
429            this._timer = undefined;
430        }
431
432        const deferred = this._deferred;
433        this._deferred = undefined;
434        this._projects = undefined;
435        try {
436            deferred.resolve(this._action());
437        }
438        catch (e) {
439            deferred.reject(e);
440        }
441    }
442}
443exports.Debouncer = Debouncer;