Skip to content

Commit

Permalink
Implement full tsconfig resolution (#677)
Browse files Browse the repository at this point in the history
  • Loading branch information
spence-s committed Aug 25, 2022
1 parent 7fe3b48 commit b661eb8
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 11 deletions.
95 changes: 89 additions & 6 deletions lib/options-manager.js
Expand Up @@ -173,12 +173,40 @@ const handleTSConfig = async options => {
options.tsConfig = searchResults.config;
}

// If there is no files of include property - ts uses **/* as default so all TS files are matched
// TODO: Improve this matching - however, even if we get it wrong, it should still lint correctly as it will just extend the nearest tsconfig
const hasMatch = options.tsConfig && !options.tsConfig.include && !options.tsConfig.files ? true : micromatch.contains(options.filePath, [
...(options.tsConfig && Array.isArray(options.tsConfig.include) ? options.tsConfig.include : []),
...(options.tsConfig && Array.isArray(options.tsConfig.files) ? options.tsConfig.files : []),
]);
if (options.tsConfig) {
// If the tsconfig extends from another file, we need to ensure that the file is covered by the tsconfig
// or not. The basefile could have includes/excludes/files properties that should be applied to the final tsconfig representation.
options.tsConfig = await recursiveBuildTsConfig(options.tsConfig, options.tsConfigPath);
}

let hasMatch;

// If there is no files or include property - ts uses **/* as default so all TS files are matched
// in tsconfig, excludes override includes - so we need to prioritize that matching logic
if (
options.tsConfig
&& !options.tsConfig.include
&& !options.tsConfig.files
) {
// If we have an excludes property, we need to check it
// If we match on excluded, then we definitively know that there is no tsconfig match
if (Array.isArray(options.tsConfig.exclude)) {
const exclude = options.tsConfig && Array.isArray(options.tsConfig.exclude) ? options.tsConfig.exclude : [];
hasMatch = !micromatch.contains(options.filePath, exclude);
} else {
// Not explicitly excluded and included by tsconfig defaults
hasMatch = true;
}
} else {
// We have either and include or a files property in tsconfig
const include = options.tsConfig && Array.isArray(options.tsConfig.include) ? options.tsConfig.include : [];
const files = options.tsConfig && Array.isArray(options.tsConfig.files) ? options.tsConfig.files : [];
const exclude = options.tsConfig && Array.isArray(options.tsConfig.exclude) ? options.tsConfig.exclude : [];
// If we also have an exlcude we need to check all the arrays, (files, include, exclude)
// this check not excluded and included in one of the file/include array
hasMatch = !micromatch.contains(options.filePath, exclude)
&& micromatch.contains(options.filePath, [...include, ...files]);
}

if (!hasMatch) {
// Only use our default tsconfig if no other tsconfig is found - otherwise extend the found config for linting
Expand Down Expand Up @@ -607,6 +635,60 @@ const getOptionGroups = async (files, options) => {
return optionGroups;
};

async function recursiveBuildTsConfig(tsConfig, tsConfigPath) {
tsConfig = tsConfigResolvePaths(tsConfig, tsConfigPath);

if (!tsConfig.extends || (typeof tsConfig.extends === 'string' && tsConfig.extends.includes('node_modules'))) {
return tsConfig;
}

// If any of the following are missing, then we need to look up the base config as it could apply
const basePath = path.isAbsolute(tsConfig.extends)
? tsConfig.extends
: path.resolve(path.dirname(tsConfigPath), tsConfig.extends);

const baseTsConfig = await readJson(basePath);

delete tsConfig.extends;

tsConfig = {
compilerOptions: {
...baseTsConfig.compilerOptions,
...tsConfig.compilerOptions,
},
...baseTsConfig,
...tsConfig,
};

return recursiveBuildTsConfig(tsConfig, basePath);
}

// Convert all include, files, and exclude to absolute paths
// and or globs. This works because ts only allows simple glob subset
const tsConfigResolvePaths = (tsConfig, tsConfigPath) => {
const tsConfigDirectory = path.dirname(tsConfigPath);

if (Array.isArray(tsConfig.files)) {
tsConfig.files = tsConfig.files.map(
filePath => path.resolve(tsConfigDirectory, filePath),
);
}

if (Array.isArray(tsConfig.include)) {
tsConfig.include = tsConfig.include.map(
globPath => path.resolve(tsConfigDirectory, globPath),
);
}

if (Array.isArray(tsConfig.exclude)) {
tsConfig.exclude = tsConfig.exclude.map(
globPath => path.resolve(tsConfigDirectory, globPath),
);
}

return tsConfig;
};

export {
parseOptions,
getIgnores,
Expand All @@ -620,4 +702,5 @@ export {
buildConfig,
getOptionGroups,
handleTSConfig,
tsConfigResolvePaths,
};
4 changes: 4 additions & 0 deletions test/fixtures/typescript/deep-extends/config/tsconfig.json
@@ -0,0 +1,4 @@
{
"include": ["../included-file.ts"],
"exclude": ["../excluded-file.ts"]
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/deep-extends/package.json
@@ -0,0 +1,3 @@
{
"xo": {}
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/excludes/package.json
@@ -0,0 +1,3 @@
{
"xo": {}
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/excludes/tsconfig.json
@@ -0,0 +1,3 @@
{
"exclude": ["excluded-file.ts"]
}
3 changes: 2 additions & 1 deletion test/fixtures/typescript/parseroptions-project/tsconfig.json
@@ -1,3 +1,4 @@
{
"include": ["**/*.ts", "**/*.tsx"]
"include": ["included-file.ts"],
"exclude": ["excluded-file.ts"]
}
@@ -0,0 +1,4 @@
{
"include": ["../included-file.ts"],
"exclude": ["../excluded-file.ts"]
}
7 changes: 7 additions & 0 deletions test/fixtures/typescript/relative-configs/package.json
@@ -0,0 +1,7 @@
{
"xo": {
"parserOptions": {
"project": "./config/tsconfig.json"
}
}
}
60 changes: 56 additions & 4 deletions test/options-manager.js
Expand Up @@ -564,7 +564,7 @@ test('mergeWithFileConfig: resolves expected typescript file options', async t =
ts: true,
tsConfigPath,
eslintConfigId,
tsConfig,
tsConfig: manager.tsConfigResolvePaths(tsConfig, tsConfigPath),
};
t.deepEqual(options, expected);
});
Expand All @@ -585,20 +585,52 @@ test('mergeWithFileConfig: resolves expected tsx file options', async t => {
ts: true,
tsConfigPath,
eslintConfigId,
tsConfig,
tsConfig: manager.tsConfigResolvePaths(tsConfig, tsConfigPath),
};
t.deepEqual(options, expected);
});

test('mergeWithFileConfig: uses specified parserOptions.project as tsconfig', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'parseroptions-project');
const filePath = path.resolve(cwd, 'does-not-matter.ts');
const filePath = path.resolve(cwd, 'included-file.ts');
const expectedTsConfigPath = path.resolve(cwd, 'projectconfig.json');
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
t.is(options.tsConfigPath, expectedTsConfigPath);
});

test('mergeWithFileConfig: extends ts config if needed', async t => {
test('mergeWithFileConfig: correctly resolves relative tsconfigs excluded file', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'relative-configs');
const excludedFilePath = path.resolve(cwd, 'excluded-file.ts');
const excludeTsConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
const {options} = await manager.mergeWithFileConfig({cwd, filePath: excludedFilePath});
t.regex(options.tsConfigPath, excludeTsConfigPath);
});

test('mergeWithFileConfig: correctly resolves relative tsconfigs included file', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'relative-configs');
const includedFilePath = path.resolve(cwd, 'included-file.ts');
const includeTsConfigPath = path.resolve(cwd, 'config/tsconfig.json');
const {options} = await manager.mergeWithFileConfig({cwd, filePath: includedFilePath});
t.is(options.tsConfigPath, includeTsConfigPath);
});

test('mergeWithFileConfig: uses generated tsconfig if specified parserOptions.project excludes file', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'parseroptions-project');
const filePath = path.resolve(cwd, 'excluded-file.ts');
const expectedTsConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
t.regex(options.tsConfigPath, expectedTsConfigPath);
});

test('mergeWithFileConfig: uses generated tsconfig if specified parserOptions.project misses file', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'parseroptions-project');
const filePath = path.resolve(cwd, 'missed-by-options-file.ts');
const expectedTsConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
t.regex(options.tsConfigPath, expectedTsConfigPath);
});

test('mergeWithFileConfig: auto generated ts config extends found ts config if file is not covered', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'extends-config');
const filePath = path.resolve(cwd, 'does-not-matter.ts');
const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
Expand All @@ -610,6 +642,26 @@ test('mergeWithFileConfig: extends ts config if needed', async t => {
t.deepEqual(expected, options.tsConfig);
});

test('mergeWithFileConfig: used found ts config if file is covered', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'extends-config');
const filePath = path.resolve(cwd, 'foo.ts');
const expectedConfigPath = path.resolve(cwd, 'tsconfig.json');
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
t.is(slash(options.tsConfigPath), expectedConfigPath);
});

test('mergeWithFileConfig: auto generated ts config extends found ts config if file is explicitly excluded', async t => {
const cwd = path.resolve('fixtures', 'typescript', 'excludes');
const filePath = path.resolve(cwd, 'excluded-file.ts');
const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
const expected = {
extends: path.resolve(cwd, 'tsconfig.json'),
};
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
t.regex(slash(options.tsConfigPath), expectedConfigPath);
t.deepEqual(expected, options.tsConfig);
});

test('mergeWithFileConfig: creates temp tsconfig if none present', async t => {
const cwd = path.resolve('fixtures', 'typescript');
const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
Expand Down

0 comments on commit b661eb8

Please sign in to comment.