Skip to content

Commit

Permalink
fix: allow 'gts clean' to follow tsconfig.json dependency chain (#185)
Browse files Browse the repository at this point in the history
  • Loading branch information
jsamar17 authored and kjin committed Jul 17, 2018
1 parent f7c75ee commit d0d430d
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 9 deletions.
75 changes: 70 additions & 5 deletions src/util.ts
Expand Up @@ -38,12 +38,77 @@ export function nop() {
/**
* Find the tsconfig.json, read it, and return parsed contents.
* @param rootDir Directory where the tsconfig.json should be found.
* If the tsconfig.json file has an "extends" field hop down the dependency tree
* until it ends or a circular reference is found in which case an error will be
* thrown
*/
export async function getTSConfig(
rootDir: string, customReadFilep?: ReadFileP): Promise<{}> {
const tsconfigPath = path.join(rootDir, 'tsconfig.json');
rootDir: string, customReadFilep?: ReadFileP): Promise<ConfigFile> {
customReadFilep = customReadFilep || readFilep;
const json = await customReadFilep(tsconfigPath, 'utf8');
const contents = JSON.parse(json);
return contents;
const readArr = new Set();
return await getBase('tsconfig.json', customReadFilep, readArr, rootDir);
}

/**
* Recursively iterate through the dependency chain until we reach the end of
* the dependency chain or encounter a circular reference
* @param filePath Filepath of file currently being read
* @param customReadFilep The file reading function being used
* @param readFiles an array of the previously read files so we can check for
* circular references
* returns a ConfigFile object containing the data from all the dependencies
*/
async function getBase(
filePath: string, customReadFilep: ReadFileP, readFiles: Set<string>,
currentDir: string): Promise<ConfigFile> {
customReadFilep = customReadFilep || readFilep;

filePath = path.resolve(currentDir, filePath);

// An error is thrown if there is a circular reference as specified by the
// TypeScript doc
if (readFiles.has(filePath)) {
throw new Error(`Circular reference in ${filePath}`);
}
readFiles.add(filePath);
try {
const json = await customReadFilep(filePath, 'utf8');
let contents = JSON.parse(json);

if (contents.extends) {
const nextFile = await getBase(
contents.extends, customReadFilep, readFiles, path.dirname(filePath));
contents = combineTSConfig(nextFile, contents);
}

return contents;
} catch (err) {
throw new Error(`${filePath} Not Found`);
}
}

/**
* Takes in 2 config files
* @param base is loaded first
* @param inherited is then loaded and overwrites base
*/
function combineTSConfig(base: ConfigFile, inherited: ConfigFile): ConfigFile {
const result: ConfigFile = {compilerOptions: {}};

Object.assign(result, base, inherited);
Object.assign(
result.compilerOptions, base.compilerOptions, inherited.compilerOptions);
delete result.extends;
return result;
}

/**
* An interface containing the top level data fields present in Config Files
*/
export interface ConfigFile {
files?: string[];
compilerOptions?: {};
include?: string[];
exclude?: string[];
extends?: string[];
}
117 changes: 113 additions & 4 deletions test/test-util.ts
Expand Up @@ -16,19 +16,128 @@
import test from 'ava';
import * as path from 'path';

import {getTSConfig} from '../src/util';
import {ConfigFile, getTSConfig} from '../src/util';

/**
* Creates a fake promisified readFile function from a map
* @param myMap contains a filepath as the key and a ConfigFile object as the
* value.
* The returned function has the same interface as fs.readFile
*/
function createFakeReadFilep(myMap: Map<string, ConfigFile>) {
return (configPath: string) => {
const configFile = myMap.get(configPath);
if (configFile) {
return Promise.resolve(JSON.stringify(configFile));
} else {
return Promise.reject(`${configPath} Not Found`);
}
};
}

test('get should parse the correct tsconfig file', async t => {
const FAKE_DIRECTORY = '/some/fake/directory';
const FAKE_CONFIG = {a: 'b'};
const FAKE_CONFIG1 = {files: ['b']};

function fakeReadFilep(
configPath: string, encoding: string): Promise<string> {
t.is(configPath, path.join(FAKE_DIRECTORY, 'tsconfig.json'));
t.is(encoding, 'utf8');
return Promise.resolve(JSON.stringify(FAKE_CONFIG));
return Promise.resolve(JSON.stringify(FAKE_CONFIG1));
}
const contents = await getTSConfig(FAKE_DIRECTORY, fakeReadFilep);
t.deepEqual(contents, FAKE_CONFIG);
t.deepEqual(contents, FAKE_CONFIG1);
});

test('should throw an error if it finds a circular reference', async t => {
const FAKE_DIRECTORY = '/some/fake/directory';
const FAKE_CONFIG1 = {files: ['b'], extends: 'FAKE_CONFIG2'};
const FAKE_CONFIG2 = {extends: 'FAKE_CONFIG3'};
const FAKE_CONFIG3 = {extends: 'tsconfig.json'};
const myMap = new Map();
myMap.set('/some/fake/directory/tsconfig.json', FAKE_CONFIG1);
myMap.set('/some/fake/directory/FAKE_CONFIG2', FAKE_CONFIG2);
myMap.set('/some/fake/directory/FAKE_CONFIG3', FAKE_CONFIG3);


await t.throws(
getTSConfig(FAKE_DIRECTORY, createFakeReadFilep(myMap)), Error,
'Circular Reference Detected');
});

test('should follow dependency chain caused by extends files', async t => {
const FAKE_DIRECTORY = '/some/fake/directory';
const FAKE_CONFIG1 = {
compilerOptions: {a: 'n'},
files: ['b'],
extends: 'FAKE_CONFIG2'
};
const FAKE_CONFIG2 = {include: ['/stuff/*'], extends: 'FAKE_CONFIG3'};
const FAKE_CONFIG3 = {exclude: ['doesnt/look/like/anything/to/me']};
const combinedConfig = {
compilerOptions: {a: 'n'},
files: ['b'],
include: ['/stuff/*'],
exclude: ['doesnt/look/like/anything/to/me']
};

const myMap = new Map();
myMap.set('/some/fake/directory/tsconfig.json', FAKE_CONFIG1);
myMap.set('/some/fake/directory/FAKE_CONFIG2', FAKE_CONFIG2);
myMap.set('/some/fake/directory/FAKE_CONFIG3', FAKE_CONFIG3);

const contents =
await getTSConfig(FAKE_DIRECTORY, createFakeReadFilep(myMap));
t.deepEqual(contents, combinedConfig);
});

test(
'when a file contains an extends field, the base file is loaded first then overridden by the inherited files',
async t => {
const FAKE_DIRECTORY = '/some/fake/directory';
const FAKE_CONFIG1 = {files: ['b'], extends: 'FAKE_CONFIG2'};
const FAKE_CONFIG2 = {files: ['c'], extends: 'FAKE_CONFIG3'};
const FAKE_CONFIG3 = {files: ['d']};
const combinedConfig = {compilerOptions: {}, files: ['b']};
const myMap = new Map();
myMap.set('/some/fake/directory/tsconfig.json', FAKE_CONFIG1);
myMap.set('/some/fake/directory/FAKE_CONFIG2', FAKE_CONFIG2);
myMap.set('/some/fake/directory/FAKE_CONFIG3', FAKE_CONFIG3);

const contents =
await getTSConfig(FAKE_DIRECTORY, createFakeReadFilep(myMap));
t.deepEqual(contents, combinedConfig);
});

test(
'when reading a file, all filepaths should be relative to the config file currently being read',
async t => {
const FAKE_DIRECTORY = '/some/fake/directory';
const FAKE_CONFIG1 = {files: ['b'], extends: './foo/FAKE_CONFIG2'};
const FAKE_CONFIG2 = {include: ['c'], extends: './bar/FAKE_CONFIG3'};
const FAKE_CONFIG3 = {exclude: ['d']};
const combinedConfig =
{compilerOptions: {}, exclude: ['d'], files: ['b'], include: ['c']};
const myMap = new Map();
myMap.set('/some/fake/directory/tsconfig.json', FAKE_CONFIG1);
myMap.set('/some/fake/directory/foo/FAKE_CONFIG2', FAKE_CONFIG2);
myMap.set('/some/fake/directory/foo/bar/FAKE_CONFIG3', FAKE_CONFIG3);

const contents =
await getTSConfig(FAKE_DIRECTORY, createFakeReadFilep(myMap));
t.deepEqual(contents, combinedConfig);
});

test(
'function throws an error when reading a file that does not exist',
async t => {
const FAKE_DIRECTORY = '/some/fake/directory';
const myMap = new Map();

await t.throws(
getTSConfig(FAKE_DIRECTORY, createFakeReadFilep(myMap)), Error,
`${FAKE_DIRECTORY}/tsconfig.json Not Found`);
});


// TODO: test errors in readFile, JSON.parse.

0 comments on commit d0d430d

Please sign in to comment.