Skip to content

Commit

Permalink
Add TypeScript support (#426)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <sindresorhus@gmail.com>
  • Loading branch information
pvdlg and sindresorhus committed Feb 24, 2020
1 parent 4cefdbc commit b0dfcbd
Show file tree
Hide file tree
Showing 13 changed files with 322 additions and 24 deletions.
5 changes: 5 additions & 0 deletions config/default.tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"include": [
"**/*d.ts"
]
}
20 changes: 17 additions & 3 deletions lib/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ const DEFAULT_IGNORES = [
*/
const MERGE_OPTIONS_CONCAT = ['extends', 'envs', 'globals', 'plugins'];

const DEFAULT_EXTENSION = ['js', 'jsx'];
const TYPESCRIPT_EXTENSION = ['ts', 'tsx'];
const DEFAULT_EXTENSION = ['js', 'jsx', ...TYPESCRIPT_EXTENSION];

/**
* Define the rules config that are overwritten only for specific version of Node.js based on `engines.node` in package.json or the `node-version` option.
Expand Down Expand Up @@ -90,7 +91,6 @@ const ENGINE_RULES = {
};

const PRETTIER_CONFIG_OVERRIDE = {
'@typescript-eslint/eslint-plugin': 'prettier/@typescript-eslint',
'eslint-plugin-babel': 'prettier/babel',
'eslint-plugin-flowtype': 'prettier/flowtype',
'eslint-plugin-react': 'prettier/react',
Expand All @@ -108,12 +108,26 @@ const CONFIG_FILES = [
`${MODULE_NAME}.config.js`
];

const TSCONFIG_DEFFAULTS = {
compilerOptions: {
target: 'es2018',
newLine: 'lf',
strict: true,
noImplicitReturns: true,
noUnusedLocals: true,
noUnusedParameters: true,
noFallthroughCasesInSwitch: true
}
};

module.exports = {
DEFAULT_IGNORES,
DEFAULT_EXTENSION,
TYPESCRIPT_EXTENSION,
ENGINE_RULES,
PRETTIER_CONFIG_OVERRIDE,
MODULE_NAME,
CONFIG_FILES,
MERGE_OPTIONS_CONCAT
MERGE_OPTIONS_CONCAT,
TSCONFIG_DEFFAULTS
};
109 changes: 94 additions & 15 deletions lib/options-manager.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';
const os = require('os');
const path = require('path');
const {outputJson, outputJsonSync} = require('fs-extra');
const arrify = require('arrify');
const mergeWith = require('lodash/mergeWith');
const flow = require('lodash/flow');
Expand All @@ -12,13 +13,19 @@ const semver = require('semver');
const {cosmiconfig, cosmiconfigSync, defaultLoaders} = require('cosmiconfig');
const pReduce = require('p-reduce');
const micromatch = require('micromatch');
const JSON5 = require('json5');
const toAbsoluteGlob = require('to-absolute-glob');
const tempy = require('tempy');
const {
DEFAULT_IGNORES,
DEFAULT_EXTENSION,
TYPESCRIPT_EXTENSION,
ENGINE_RULES,
PRETTIER_CONFIG_OVERRIDE,
MODULE_NAME, CONFIG_FILES,
MERGE_OPTIONS_CONCAT
MODULE_NAME,
CONFIG_FILES,
MERGE_OPTIONS_CONCAT,
TSCONFIG_DEFFAULTS
} = require('./constants');

const DEFAULT_CONFIG = {
Expand Down Expand Up @@ -57,6 +64,8 @@ const mergeFn = (previousValue, value, key) => {
}
};

const isTypescript = file => TYPESCRIPT_EXTENSION.includes(path.extname(file).slice(1));

/**
* Find config for `lintText`.
* The config files are searched starting from `options.filename` if defined or `options.cwd` otherwise.
Expand All @@ -83,6 +92,15 @@ const mergeWithFileConfig = options => {

const prettierOptions = options.prettier ? prettier.resolveConfig.sync(searchPath) || {} : {};

if (options.filename && isTypescript(options.filename)) {
const tsConfigExplorer = cosmiconfigSync([], {searchPlaces: ['tsconfig.json'], loaders: {'.json': (_, content) => JSON5.parse(content)}});
const {config: tsConfig, filepath: tsConfigPath} = tsConfigExplorer.search(options.filename) || {};

options.tsConfigPath = tempy.file({name: 'tsconfig.json'});
options.ts = true;
outputJsonSync(options.tsConfigPath, makeTSConfig(tsConfig, tsConfigPath, [options.filename]));
}

return {options, prettierOptions};
};

Expand All @@ -92,38 +110,73 @@ const mergeWithFileConfig = options => {
*/
const mergeWithFileConfigs = async (files, options) => {
options.cwd = path.resolve(options.cwd || process.cwd());
return [...(await pReduce(files, async (configs, file) => {

return Promise.all([...(await pReduce(files.map(file => path.resolve(options.cwd, file)), async (configs, file) => {
const configExplorer = cosmiconfig(MODULE_NAME, {searchPlaces: CONFIG_FILES, loaders: {noExt: defaultLoaders['.json']}, stopDir: options.cwd});
const pkgConfigExplorer = cosmiconfig('engines', {searchPlaces: ['package.json'], stopDir: options.cwd});
const filepath = path.resolve(options.cwd, file);

const {config: xoOptions, filepath: xoConfigPath} = await configExplorer.search(filepath) || {};
const {config: enginesOptions, filepath: enginesConfigPath} = await pkgConfigExplorer.search(filepath) || {};
const {config: xoOptions, filepath: xoConfigPath} = await configExplorer.search(file) || {};
const {config: enginesOptions, filepath: enginesConfigPath} = await pkgConfigExplorer.search(file) || {};

let fileOptions = mergeOptions(xoOptions, enginesOptions, options);
fileOptions.cwd = xoConfigPath && path.dirname(xoConfigPath) !== fileOptions.cwd ? path.resolve(fileOptions.cwd, path.dirname(xoConfigPath)) : fileOptions.cwd;

if (!fileOptions.extensions.includes(path.extname(filepath).replace('.', '')) || isFileIgnored(filepath, fileOptions)) {
if (!fileOptions.extensions.includes(path.extname(file).replace('.', '')) || isFileIgnored(file, fileOptions)) {
// File extension/path is ignored, skip it
return configs;
}

const {hash, options: optionsWithOverrides} = applyOverrides(filepath, fileOptions);
const {hash, options: optionsWithOverrides} = applyOverrides(file, fileOptions);

const prettierConfigPath = optionsWithOverrides.prettier ? await prettier.resolveConfigFile(file) : undefined;
const prettierOptions = prettierConfigPath ? await prettier.resolveConfig(file, {config: prettierConfigPath}) : {};

const prettierConfigPath = optionsWithOverrides.prettier ? await prettier.resolveConfigFile(filepath) : undefined;
const prettierOptions = prettierConfigPath ? await prettier.resolveConfig(filepath, {config: prettierConfigPath}) : {};
let tsConfigPath;
if (isTypescript(file)) {
let tsConfig;
// Override cosmiconfig `loaders` as we look only for the path of tsconfig.json, but not its content
const tsConfigExplorer = cosmiconfig([], {searchPlaces: ['tsconfig.json'], loaders: {'.json': (_, content) => JSON5.parse(content)}});
({config: tsConfig, filepath: tsConfigPath} = await tsConfigExplorer.search(file) || {});

const cacheKey = JSON.stringify({xoConfigPath, enginesConfigPath, prettierConfigPath, hash});
optionsWithOverrides.tsConfigPath = tsConfigPath;
optionsWithOverrides.tsConfig = tsConfig;
optionsWithOverrides.ts = true;
}

const cacheKey = JSON.stringify({xoConfigPath, enginesConfigPath, prettierConfigPath, hash, tsConfigPath: fileOptions.tsConfigPath, ts: fileOptions.ts});
const cachedGroup = configs.get(cacheKey);

configs.set(cacheKey, {
files: [filepath, ...(cachedGroup ? cachedGroup.files : [])],
files: [file, ...(cachedGroup ? cachedGroup.files : [])],
options: cachedGroup ? cachedGroup.options : optionsWithOverrides,
prettierOptions
});

return configs;
}, new Map())).values()];
}, new Map())).values()].map(async group => {
const {files, options} = group;
if (options.ts) {
const tsConfigPath = tempy.file({name: 'tsconfig.json'});
await outputJson(tsConfigPath, makeTSConfig(options.tsConfig, options.tsConfigPath, files));
group.options.tsConfigPath = tsConfigPath;
delete group.options.tsConfig;
}

return group;
}));
};

const makeTSConfig = (tsConfig, tsConfigPath, files) => {
const config = {files: files.filter(isTypescript)};

if (tsConfigPath) {
config.extends = tsConfigPath;
config.include = arrify(tsConfig.include).map(pattern => toAbsoluteGlob(pattern, {cwd: path.dirname(tsConfigPath)}));
} else {
Object.assign(config, TSCONFIG_DEFFAULTS);
}

return config;
};

const normalizeOptions = options => {
Expand Down Expand Up @@ -191,7 +244,8 @@ const buildConfig = (options, prettierOptions) =>
flow(
buildXOConfig(options),
buildExtendsConfig(options),
buildPrettierConfig(options, prettierOptions)
buildPrettierConfig(options, prettierOptions),
buildTSConfig(options)
)(mergeWith(getEmptyOptions(), DEFAULT_CONFIG, normalizeOptions(options), mergeFn));

const buildXOConfig = options => config => {
Expand All @@ -212,7 +266,11 @@ const buildXOConfig = options => config => {
}

if (options.space && !options.prettier) {
config.rules.indent = ['error', spaces, {SwitchCase: 1}];
if (options.ts) {
config.rules['@typescript-eslint/indent'] = ['error', spaces, {SwitchCase: 1}];
} else {
config.rules.indent = ['error', spaces, {SwitchCase: 1}];
}

// Only apply if the user has the React plugin
if (options.cwd && resolveFrom.silent(options.cwd, 'eslint-plugin-react')) {
Expand Down Expand Up @@ -338,6 +396,27 @@ const mergeWithPrettierConfig = (options, prettierOptions) => {
);
};

const buildTSConfig = options => config => {
if (options.ts) {
config.baseConfig.extends = config.baseConfig.extends.concat('xo-typescript');
config.baseConfig.parser = require.resolve('@typescript-eslint/parser');
config.baseConfig.parserOptions = {
warnOnUnsupportedTypeScriptVersion: false,
ecmaFeatures: {jsx: true},
project: options.tsConfigPath
};

if (options.prettier) {
config.baseConfig.extends = config.baseConfig.extends.concat('prettier/@typescript-eslint');
}

delete config.tsConfigPath;
delete config.ts;
}

return config;
};

const applyOverrides = (file, options) => {
if (options.overrides && options.overrides.length > 0) {
const {overrides} = options;
Expand Down
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "xo",
"version": "0.26.1",
"description": "JavaScript linter with great defaults",
"description": "JavaScript/TypeScript linter with great defaults",
"license": "MIT",
"repository": "xojs/xo",
"funding": "https://github.com/sponsors/sindresorhus",
Expand Down Expand Up @@ -46,15 +46,20 @@
"verify",
"enforce",
"hint",
"simple"
"simple",
"javascript",
"typescript"
],
"dependencies": {
"@typescript-eslint/eslint-plugin": "^2.19.2",
"@typescript-eslint/parser": "^2.19.2",
"arrify": "^2.0.1",
"cosmiconfig": "^6.0.0",
"debug": "^4.1.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.10.0",
"eslint-config-xo": "^0.29.0",
"eslint-config-xo-typescript": "^0.26.0",
"eslint-formatter-pretty": "^3.0.1",
"eslint-plugin-ava": "^10.0.1",
"eslint-plugin-eslint-comments": "^3.1.2",
Expand All @@ -65,9 +70,11 @@
"eslint-plugin-promise": "^4.2.1",
"eslint-plugin-unicorn": "^16.1.1",
"find-cache-dir": "^3.0.0",
"fs-extra": "^8.1.0",
"get-stdin": "^7.0.0",
"globby": "^9.0.0",
"has-flag": "^4.0.0",
"json5": "^2.1.1",
"lodash": "^4.17.15",
"meow": "^5.0.0",
"micromatch": "^4.0.2",
Expand All @@ -79,6 +86,8 @@
"resolve-from": "^5.0.0",
"semver": "^7.1.3",
"slash": "^3.0.0",
"tempy": "^0.4.0",
"to-absolute-glob": "^2.0.2",
"update-notifier": "^4.0.0"
},
"devDependencies": {
Expand All @@ -91,7 +100,8 @@
"nyc": "^15.0.0",
"pify": "^4.0.0",
"proxyquire": "^2.1.3",
"temp-write": "^4.0.0"
"temp-write": "^4.0.0",
"typescript": "^3.7.5"
},
"eslintConfig": {
"extends": "eslint-config-xo"
Expand Down
9 changes: 6 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<br>
</h1>

> JavaScript linter with great defaults
> JavaScript/TypeScript linter with great defaults
[![Build Status](https://travis-ci.org/xojs/xo.svg?branch=master)](https://travis-ci.org/xojs/xo) [![Coverage Status](https://coveralls.io/repos/github/xojs/xo/badge.svg?branch=master)](https://coveralls.io/github/xojs/xo?branch=master) [![XO code style](https://img.shields.io/badge/code_style-XO-5ed9c7.svg)](https://github.com/xojs/xo) [![Gitter](https://badges.gitter.im/join_chat.svg)](https://gitter.im/xojs/Lobby)

Expand All @@ -25,8 +25,9 @@ Uses [ESLint](https://eslint.org) underneath, so issues regarding rules should b
- Beautiful output.
- Zero-config, but [configurable when needed](#config).
- Enforces readable code, because you read more code than you write.
- No need to specify file paths to lint as it lints all JS files except for [commonly ignored paths](#ignores).
- No need to specify file paths to lint as it lints all JS/TS files except for [commonly ignored paths](#ignores).
- [Config overrides per files/globs.](#config-overrides)
- [TypeScript supported by default](#typescript)
- Includes many useful ESLint plugins, like [`unicorn`](https://github.com/sindresorhus/eslint-plugin-unicorn), [`import`](https://github.com/benmosher/eslint-plugin-import), [`ava`](https://github.com/avajs/eslint-plugin-ava), [`node`](https://github.com/mysticatea/eslint-plugin-node) and more.
- Automatically enables rules based on the [`engines`](https://docs.npmjs.com/files/package.json#engines) field in your `package.json`.
- Caches results between runs for much better performance.
Expand Down Expand Up @@ -272,7 +273,9 @@ Enforce ES2015+ rules. Disabling this will make it not *enforce* ES2015+ syntax

### TypeScript

See [eslint-config-xo-typescript#use-with-xo](https://github.com/xojs/eslint-config-xo-typescript#use-with-xo)
XO will automatically lint TypeScript files (`.ts`, `.d.ts` and `.tsx`) with the rules defined in [eslint-config-xo-typescript#use-with-xo](https://github.com/xojs/eslint-config-xo-typescript#use-with-xo).

XO will handle the [@typescript-eslint/parser `project` option](https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/parser#parseroptionsproject) automatically even if you don't have a `tsconfig.json` in your project.

### Flow

Expand Down
1 change: 1 addition & 0 deletions test/fixtures/typescript/child/extra-semicolon.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
console.log('extra-semicolon');;
5 changes: 5 additions & 0 deletions test/fixtures/typescript/child/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"xo": {
"semicolon": false
}
}
6 changes: 6 additions & 0 deletions test/fixtures/typescript/child/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"include": [
"**/*.ts",
"**/*.tsx"
]
}
5 changes: 5 additions & 0 deletions test/fixtures/typescript/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"xo": {
"space": 4
}
}
3 changes: 3 additions & 0 deletions test/fixtures/typescript/two-spaces.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
console.log([
2
]);
20 changes: 20 additions & 0 deletions test/lint-files.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,23 @@ test('find configurations close to linted file', async t => {
)
);
});

test('typescript files', async t => {
const {results} = await fn.lintFiles('**/*', {cwd: 'fixtures/typescript'});

t.true(
hasRule(
results,
path.resolve('fixtures/typescript/two-spaces.tsx'),
'@typescript-eslint/indent'
)
);

t.true(
hasRule(
results,
path.resolve('fixtures/typescript/child/extra-semicolon.ts'),
'@typescript-eslint/no-extra-semi'
)
);
});
10 changes: 10 additions & 0 deletions test/lint-text.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,13 @@ test('find configurations close to linted file', t => {
]);\n`, {filename: 'fixtures/nested-configs/child-override/two-spaces.js'}));
t.true(hasRule(results, 'indent'));
});

test('typescript files', t => {
let {results} = fn.lintText('console.log(\'extra-semicolon\');;\n', {filename: 'fixtures/typescript/child/extra-semicolon.ts'});
t.true(hasRule(results, '@typescript-eslint/no-extra-semi'));

({results} = fn.lintText(`console.log([
2
]);`, {filename: 'fixtures/typescript/two-spaces.tsx'}));
t.true(hasRule(results, '@typescript-eslint/indent'));
});

0 comments on commit b0dfcbd

Please sign in to comment.