Skip to content

Commit b661eb8

Browse files
authored
Implement full tsconfig resolution (#677)
1 parent 7fe3b48 commit b661eb8

9 files changed

Lines changed: 171 additions & 11 deletions

File tree

lib/options-manager.js

Lines changed: 89 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,40 @@ const handleTSConfig = async options => {
173173
options.tsConfig = searchResults.config;
174174
}
175175

176-
// If there is no files of include property - ts uses **/* as default so all TS files are matched
177-
// TODO: Improve this matching - however, even if we get it wrong, it should still lint correctly as it will just extend the nearest tsconfig
178-
const hasMatch = options.tsConfig && !options.tsConfig.include && !options.tsConfig.files ? true : micromatch.contains(options.filePath, [
179-
...(options.tsConfig && Array.isArray(options.tsConfig.include) ? options.tsConfig.include : []),
180-
...(options.tsConfig && Array.isArray(options.tsConfig.files) ? options.tsConfig.files : []),
181-
]);
176+
if (options.tsConfig) {
177+
// If the tsconfig extends from another file, we need to ensure that the file is covered by the tsconfig
178+
// or not. The basefile could have includes/excludes/files properties that should be applied to the final tsconfig representation.
179+
options.tsConfig = await recursiveBuildTsConfig(options.tsConfig, options.tsConfigPath);
180+
}
181+
182+
let hasMatch;
183+
184+
// If there is no files or include property - ts uses **/* as default so all TS files are matched
185+
// in tsconfig, excludes override includes - so we need to prioritize that matching logic
186+
if (
187+
options.tsConfig
188+
&& !options.tsConfig.include
189+
&& !options.tsConfig.files
190+
) {
191+
// If we have an excludes property, we need to check it
192+
// If we match on excluded, then we definitively know that there is no tsconfig match
193+
if (Array.isArray(options.tsConfig.exclude)) {
194+
const exclude = options.tsConfig && Array.isArray(options.tsConfig.exclude) ? options.tsConfig.exclude : [];
195+
hasMatch = !micromatch.contains(options.filePath, exclude);
196+
} else {
197+
// Not explicitly excluded and included by tsconfig defaults
198+
hasMatch = true;
199+
}
200+
} else {
201+
// We have either and include or a files property in tsconfig
202+
const include = options.tsConfig && Array.isArray(options.tsConfig.include) ? options.tsConfig.include : [];
203+
const files = options.tsConfig && Array.isArray(options.tsConfig.files) ? options.tsConfig.files : [];
204+
const exclude = options.tsConfig && Array.isArray(options.tsConfig.exclude) ? options.tsConfig.exclude : [];
205+
// If we also have an exlcude we need to check all the arrays, (files, include, exclude)
206+
// this check not excluded and included in one of the file/include array
207+
hasMatch = !micromatch.contains(options.filePath, exclude)
208+
&& micromatch.contains(options.filePath, [...include, ...files]);
209+
}
182210

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

638+
async function recursiveBuildTsConfig(tsConfig, tsConfigPath) {
639+
tsConfig = tsConfigResolvePaths(tsConfig, tsConfigPath);
640+
641+
if (!tsConfig.extends || (typeof tsConfig.extends === 'string' && tsConfig.extends.includes('node_modules'))) {
642+
return tsConfig;
643+
}
644+
645+
// If any of the following are missing, then we need to look up the base config as it could apply
646+
const basePath = path.isAbsolute(tsConfig.extends)
647+
? tsConfig.extends
648+
: path.resolve(path.dirname(tsConfigPath), tsConfig.extends);
649+
650+
const baseTsConfig = await readJson(basePath);
651+
652+
delete tsConfig.extends;
653+
654+
tsConfig = {
655+
compilerOptions: {
656+
...baseTsConfig.compilerOptions,
657+
...tsConfig.compilerOptions,
658+
},
659+
...baseTsConfig,
660+
...tsConfig,
661+
};
662+
663+
return recursiveBuildTsConfig(tsConfig, basePath);
664+
}
665+
666+
// Convert all include, files, and exclude to absolute paths
667+
// and or globs. This works because ts only allows simple glob subset
668+
const tsConfigResolvePaths = (tsConfig, tsConfigPath) => {
669+
const tsConfigDirectory = path.dirname(tsConfigPath);
670+
671+
if (Array.isArray(tsConfig.files)) {
672+
tsConfig.files = tsConfig.files.map(
673+
filePath => path.resolve(tsConfigDirectory, filePath),
674+
);
675+
}
676+
677+
if (Array.isArray(tsConfig.include)) {
678+
tsConfig.include = tsConfig.include.map(
679+
globPath => path.resolve(tsConfigDirectory, globPath),
680+
);
681+
}
682+
683+
if (Array.isArray(tsConfig.exclude)) {
684+
tsConfig.exclude = tsConfig.exclude.map(
685+
globPath => path.resolve(tsConfigDirectory, globPath),
686+
);
687+
}
688+
689+
return tsConfig;
690+
};
691+
610692
export {
611693
parseOptions,
612694
getIgnores,
@@ -620,4 +702,5 @@ export {
620702
buildConfig,
621703
getOptionGroups,
622704
handleTSConfig,
705+
tsConfigResolvePaths,
623706
};
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"include": ["../included-file.ts"],
3+
"exclude": ["../excluded-file.ts"]
4+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"xo": {}
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"xo": {}
3+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"exclude": ["excluded-file.ts"]
3+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{
2-
"include": ["**/*.ts", "**/*.tsx"]
2+
"include": ["included-file.ts"],
3+
"exclude": ["excluded-file.ts"]
34
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"include": ["../included-file.ts"],
3+
"exclude": ["../excluded-file.ts"]
4+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"xo": {
3+
"parserOptions": {
4+
"project": "./config/tsconfig.json"
5+
}
6+
}
7+
}

test/options-manager.js

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -564,7 +564,7 @@ test('mergeWithFileConfig: resolves expected typescript file options', async t =
564564
ts: true,
565565
tsConfigPath,
566566
eslintConfigId,
567-
tsConfig,
567+
tsConfig: manager.tsConfigResolvePaths(tsConfig, tsConfigPath),
568568
};
569569
t.deepEqual(options, expected);
570570
});
@@ -585,20 +585,52 @@ test('mergeWithFileConfig: resolves expected tsx file options', async t => {
585585
ts: true,
586586
tsConfigPath,
587587
eslintConfigId,
588-
tsConfig,
588+
tsConfig: manager.tsConfigResolvePaths(tsConfig, tsConfigPath),
589589
};
590590
t.deepEqual(options, expected);
591591
});
592592

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

601-
test('mergeWithFileConfig: extends ts config if needed', async t => {
601+
test('mergeWithFileConfig: correctly resolves relative tsconfigs excluded file', async t => {
602+
const cwd = path.resolve('fixtures', 'typescript', 'relative-configs');
603+
const excludedFilePath = path.resolve(cwd, 'excluded-file.ts');
604+
const excludeTsConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
605+
const {options} = await manager.mergeWithFileConfig({cwd, filePath: excludedFilePath});
606+
t.regex(options.tsConfigPath, excludeTsConfigPath);
607+
});
608+
609+
test('mergeWithFileConfig: correctly resolves relative tsconfigs included file', async t => {
610+
const cwd = path.resolve('fixtures', 'typescript', 'relative-configs');
611+
const includedFilePath = path.resolve(cwd, 'included-file.ts');
612+
const includeTsConfigPath = path.resolve(cwd, 'config/tsconfig.json');
613+
const {options} = await manager.mergeWithFileConfig({cwd, filePath: includedFilePath});
614+
t.is(options.tsConfigPath, includeTsConfigPath);
615+
});
616+
617+
test('mergeWithFileConfig: uses generated tsconfig if specified parserOptions.project excludes file', async t => {
618+
const cwd = path.resolve('fixtures', 'typescript', 'parseroptions-project');
619+
const filePath = path.resolve(cwd, 'excluded-file.ts');
620+
const expectedTsConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
621+
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
622+
t.regex(options.tsConfigPath, expectedTsConfigPath);
623+
});
624+
625+
test('mergeWithFileConfig: uses generated tsconfig if specified parserOptions.project misses file', async t => {
626+
const cwd = path.resolve('fixtures', 'typescript', 'parseroptions-project');
627+
const filePath = path.resolve(cwd, 'missed-by-options-file.ts');
628+
const expectedTsConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
629+
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
630+
t.regex(options.tsConfigPath, expectedTsConfigPath);
631+
});
632+
633+
test('mergeWithFileConfig: auto generated ts config extends found ts config if file is not covered', async t => {
602634
const cwd = path.resolve('fixtures', 'typescript', 'extends-config');
603635
const filePath = path.resolve(cwd, 'does-not-matter.ts');
604636
const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
@@ -610,6 +642,26 @@ test('mergeWithFileConfig: extends ts config if needed', async t => {
610642
t.deepEqual(expected, options.tsConfig);
611643
});
612644

645+
test('mergeWithFileConfig: used found ts config if file is covered', async t => {
646+
const cwd = path.resolve('fixtures', 'typescript', 'extends-config');
647+
const filePath = path.resolve(cwd, 'foo.ts');
648+
const expectedConfigPath = path.resolve(cwd, 'tsconfig.json');
649+
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
650+
t.is(slash(options.tsConfigPath), expectedConfigPath);
651+
});
652+
653+
test('mergeWithFileConfig: auto generated ts config extends found ts config if file is explicitly excluded', async t => {
654+
const cwd = path.resolve('fixtures', 'typescript', 'excludes');
655+
const filePath = path.resolve(cwd, 'excluded-file.ts');
656+
const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');
657+
const expected = {
658+
extends: path.resolve(cwd, 'tsconfig.json'),
659+
};
660+
const {options} = await manager.mergeWithFileConfig({cwd, filePath});
661+
t.regex(slash(options.tsConfigPath), expectedConfigPath);
662+
t.deepEqual(expected, options.tsConfig);
663+
});
664+
613665
test('mergeWithFileConfig: creates temp tsconfig if none present', async t => {
614666
const cwd = path.resolve('fixtures', 'typescript');
615667
const expectedConfigPath = new RegExp(`${slash(cwd)}/node_modules/.cache/xo-linter/tsconfig\\..*\\.json[\\/]?$`, 'u');

0 commit comments

Comments
 (0)