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

Add TypeScript support #426

Merged
merged 10 commits into from
Feb 24, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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'));
});