Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: full tsconfig resolution (closes #637 closes #661) #677

Merged
merged 3 commits into from
Aug 25, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 89 additions & 6 deletions lib/options-manager.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"xo": {}
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/excludes/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"xo": {}
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/excludes/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"exclude": ["excluded-file.ts"]
}
3 changes: 2 additions & 1 deletion test/fixtures/typescript/parseroptions-project/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
{
"include": ["**/*.ts", "**/*.tsx"]
"include": ["included-file.ts"],
"exclude": ["excluded-file.ts"]
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"xo": {
"parserOptions": {
"project": "./config/tsconfig.json"
}
}
}
60 changes: 56 additions & 4 deletions test/options-manager.js
Original file line number Diff line number Diff line change
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