{
2 | [key: string]: any[];
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.tsbuildinfo
3 | *.d.ts
4 | *.map
5 | *.vsix
6 | packages/*/index.js
7 | packages/*/lib/**/*.js
8 | .tsslint/
--------------------------------------------------------------------------------
/fixtures/meta-frameworks-support/fixture.tsx:
--------------------------------------------------------------------------------
1 | function MyComponent() {
2 | return { console.log('Hello, world!') }
3 | }
4 |
--------------------------------------------------------------------------------
/fixtures/typescript-plugin/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "unwantedRecommendations": [
3 | "johnsoncodehk.vscode-tsslint"
4 | ]
5 | }
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "allowJs": true,
4 | "strictNullChecks": true
5 | },
6 | "include": [ "cases/**/*" ],
7 | }
8 |
--------------------------------------------------------------------------------
/packages/config/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "include": [ "*", "lib/**/*" ],
4 | "references": [
5 | { "path": "../types" },
6 | ],
7 | }
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/consistent-type-imports.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { Foo } from 'Foo';
3 | import Bar from 'Bar';
4 | type T = Foo;
5 | const x: Bar = 1;
6 |
--------------------------------------------------------------------------------
/fixtures/typescript-plugin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": [ "fixture.ts" ],
3 | "compilerOptions": {
4 | "plugins": [{ "name": "@tsslint/typescript-plugin" }],
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/packages/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "include": [ "*", "lib/**/*" ],
4 | "references": [
5 | { "path": "../config/tsconfig.json" },
6 | ],
7 | }
--------------------------------------------------------------------------------
/packages/tslint/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "include": [ "*", "lib/**/*" ],
4 | "references": [
5 | { "path": "../types/tsconfig.json" },
6 | ],
7 | }
--------------------------------------------------------------------------------
/fixtures/meta-frameworks-support/fixture.mdx:
--------------------------------------------------------------------------------
1 | {/** Description of `Component` */}
2 | export function Component() {
3 | console.log('Hello, world!');
4 | }
5 |
6 | Hello
7 |
--------------------------------------------------------------------------------
/packages/typescript-plugin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "include": [ "*", "lib/**/*" ],
4 | "references": [
5 | { "path": "../config/tsconfig.json" },
6 | ],
7 | }
--------------------------------------------------------------------------------
/fixtures/convert-eslint-config/fixture.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | let value = 9001;
3 | // ^? let value: number
4 |
5 | // $ExpectError
6 | value = "over nine thousand";
7 |
8 | // $ExpectType number
9 | 9001;
10 |
--------------------------------------------------------------------------------
/fixtures/typescript-plugin/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 |
3 | export default defineConfig({
4 | rules: {
5 | 'no-console': (await import('../noConsoleRule')).create(),
6 | },
7 | });
8 |
--------------------------------------------------------------------------------
/packages/cli/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "include": [ "*", "lib/**/*" ],
4 | "references": [
5 | { "path": "../core/tsconfig.json" },
6 | { "path": "../config/tsconfig.json" },
7 | ],
8 | }
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/no-console.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | console.log("Log a debug level message.");
3 | console.warn("Log a warn level message.");
4 | console.error("Log an error level message.");
5 | console.log = foo();
6 |
--------------------------------------------------------------------------------
/fixtures/meta-frameworks-support/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 |
3 | export default defineConfig({
4 | rules: {
5 | 'no-console': (await import('../noConsoleRule')).create(),
6 | },
7 | });
8 |
--------------------------------------------------------------------------------
/packages/eslint/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.base.json",
3 | "include": [ "*", "lib/**/*" ],
4 | "references": [
5 | { "path": "../config/tsconfig.json" },
6 | { "path": "../types/tsconfig.json" },
7 | ],
8 | }
--------------------------------------------------------------------------------
/fixtures/meta-frameworks-support/fixture.vine.ts:
--------------------------------------------------------------------------------
1 | function MyComponent() {
2 | return vine`{{ console.log('Hello, world!') }}
`;
3 | }
4 |
5 | // This is also valid
6 | const AnotherComponent = () => vine`Hello World
`
7 |
--------------------------------------------------------------------------------
/fixtures/config-build-error/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 | import noConsoleRule from './noExist.ts';
3 |
4 | export default defineConfig({
5 | rules: {
6 | 'no-console': noConsoleRule,
7 | },
8 | });
9 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/no-import-type-side-effects.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | import { type A } from 'mod';
3 | import { type A as AA } from 'mod';
4 | import { type A, type B } from 'mod';
5 | import { type A as AA, type B as BB } from 'mod';
6 |
--------------------------------------------------------------------------------
/fixtures/typescript-plugin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@tsslint-fixtures/typescript-plugin",
4 | "version": "2.0.7",
5 | "devDependencies": {
6 | "@tsslint/typescript-plugin": "2.0.7",
7 | "typescript": "latest"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-config/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 | import { defineRules } from '@tsslint/eslint';
3 |
4 | export default defineConfig({
5 | rules: await defineRules({
6 | 'expect-type/expect': true,
7 | }),
8 | });
9 |
--------------------------------------------------------------------------------
/lerna.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://raw.githubusercontent.com/lerna-lite/lerna-lite/main/packages/cli/schemas/lerna-schema.json",
3 | "npmClient": "pnpm",
4 | "packages": [
5 | ".",
6 | "packages/*",
7 | "fixtures/*"
8 | ],
9 | "version": "2.0.7"
10 | }
11 |
--------------------------------------------------------------------------------
/fixtures/convert-a-tslint-rule/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@tsslint-fixtures/convert-a-tslint-rule",
4 | "version": "2.0.7",
5 | "devDependencies": {
6 | "@tsslint/config": "2.0.7",
7 | "@tsslint/tslint": "2.0.7",
8 | "tslint": "^6.1.3"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/fixtures/define-a-rule/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 |
3 | export default defineConfig({
4 | exclude: ['exclude.ts'],
5 | include: ['fixture.ts'],
6 | rules: {
7 | 'no-console': (await import('../noConsoleRule')).create(),
8 | },
9 | });
10 |
--------------------------------------------------------------------------------
/fixtures/error-rule/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 |
3 | export default defineConfig({
4 | exclude: ['exclude.ts'],
5 | rules: {
6 | 'no-console': () => {
7 | throw new Error('no-console rule is not allowed');
8 | },
9 | },
10 | });
11 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@tsslint-fixtures/convert-eslint-config",
4 | "version": "2.0.7",
5 | "devDependencies": {
6 | "@tsslint/config": "2.0.7",
7 | "@tsslint/eslint": "2.0.7",
8 | "eslint-plugin-expect-type": "^0.4.0"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@tsslint-fixtures/convert-eslint-rules",
4 | "version": "2.0.7",
5 | "devDependencies": {
6 | "@tsslint/config": "2.0.7",
7 | "@tsslint/eslint": "2.0.7",
8 | "@typescript-eslint/eslint-plugin": "^8.16.0"
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/packages/types/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tsslint/types",
3 | "version": "2.0.7",
4 | "license": "MIT",
5 | "files": [
6 | "**/*.js",
7 | "**/*.d.ts"
8 | ],
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/johnsoncodehk/tsslint.git",
12 | "directory": "packages/types"
13 | }
14 | }
--------------------------------------------------------------------------------
/fixtures/convert-a-tslint-rule/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 | import { convertRule } from '@tsslint/tslint';
3 |
4 | export default defineConfig({
5 | rules: {
6 | 'strict-boolean-expressions': convertRule((await import('tslint/lib/rules/strictBooleanExpressionsRule.js')).Rule),
7 | },
8 | });
9 |
--------------------------------------------------------------------------------
/packages/eslint/lib/plugins/disableNextLine.ts:
--------------------------------------------------------------------------------
1 | import { createIgnorePlugin } from '@tsslint/config';
2 |
3 | /**
4 | * @deprecated Use `createIgnorePlugin` from `@tsslint/config` instead.
5 | */
6 | export function create(reportsUnusedComments = true) {
7 | return createIgnorePlugin('eslint-disable-next-line', reportsUnusedComments);
8 | }
9 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/switch-exhaustiveness-check.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | type Day =
3 | | 'Monday'
4 | | 'Tuesday'
5 | | 'Wednesday'
6 | | 'Thursday'
7 | | 'Friday'
8 | | 'Saturday'
9 | | 'Sunday';
10 |
11 | declare const day: Day;
12 | let result = 0;
13 |
14 | switch (day) {
15 | case 'Monday':
16 | result = 1;
17 | break;
18 | }
19 |
--------------------------------------------------------------------------------
/fixtures/meta-frameworks-support/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "@tsslint-fixtures/meta-frameworks-support",
4 | "version": "2.0.7",
5 | "devDependencies": {
6 | "@astrojs/ts-plugin": "latest",
7 | "@mdx-js/language-service": "latest",
8 | "@ts-macro/language-plugin": "latest",
9 | "@vue-vine/language-service": "latest",
10 | "@vue/language-core": "latest"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/fixtures/multiple-configs/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 |
3 | export default defineConfig([
4 | {
5 | include: ['**/*.ts'],
6 | rules: {
7 | 'no-console-ts': (await import('../noConsoleRule')).create(),
8 | },
9 | },
10 | {
11 | include: ['**/*.vue'],
12 | rules: {
13 | 'no-console-vue': (await import('../noConsoleRule')).create(),
14 | },
15 | },
16 | ]);
17 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.base.json",
3 | "compilerOptions": {
4 | "noEmit": true,
5 | },
6 | "include": [ "tsslint.config.ts" ],
7 | "references": [
8 | { "path": "./packages/cli/tsconfig.json" },
9 | { "path": "./packages/tslint/tsconfig.json" },
10 | { "path": "./packages/eslint/tsconfig.json" },
11 | { "path": "./packages/typescript-plugin/tsconfig.json" },
12 | ],
13 | }
--------------------------------------------------------------------------------
/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from '@tsslint/config';
2 | import { defineRules } from '@tsslint/eslint';
3 |
4 | export default defineConfig({
5 | rules: await defineRules({
6 | '@typescript-eslint/consistent-type-imports': [{
7 | disallowTypeAnnotations: false,
8 | fixStyle: 'inline-type-imports',
9 | }],
10 | '@typescript-eslint/no-unnecessary-type-assertion': true,
11 | }),
12 | });
13 |
--------------------------------------------------------------------------------
/tsconfig.base.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2021",
4 | "lib": [
5 | "ES2021",
6 | ],
7 | "module": "Node16",
8 | "sourceMap": true,
9 | "composite": true,
10 | "strict": true,
11 | "skipLibCheck": true,
12 | "noUnusedLocals": true,
13 | "noUnusedParameters": true,
14 | "esModuleInterop": false,
15 | "forceConsistentCasingInFileNames": true,
16 | },
17 | "include": [ ],
18 | }
--------------------------------------------------------------------------------
/packages/tslint/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tsslint/tslint",
3 | "version": "2.0.7",
4 | "license": "MIT",
5 | "files": [
6 | "**/*.js",
7 | "**/*.d.ts"
8 | ],
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/johnsoncodehk/tsslint.git",
12 | "directory": "packages/tslint"
13 | },
14 | "devDependencies": {
15 | "@tsslint/types": "2.0.7",
16 | "tslint": "^6.0.0",
17 | "typescript": "latest"
18 | }
19 | }
--------------------------------------------------------------------------------
/packages/config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tsslint/config",
3 | "version": "2.0.7",
4 | "license": "MIT",
5 | "files": [
6 | "**/*.js",
7 | "**/*.d.ts"
8 | ],
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/johnsoncodehk/tsslint.git",
12 | "directory": "packages/config"
13 | },
14 | "dependencies": {
15 | "@tsslint/types": "2.0.7",
16 | "minimatch": "^10.0.1",
17 | "ts-api-utils": "^2.0.0"
18 | }
19 | }
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/prefer-ts-expect-error.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | // @ts-ignore
3 | const str: string = 1;
4 |
5 | /**
6 | * Explaining comment
7 | *
8 | * @ts-ignore */
9 | const multiLine: number = 'value';
10 |
11 | /** @ts-ignore */
12 | const block: string = 1;
13 |
14 | const isOptionEnabled = (key: string): boolean => {
15 | // @ts-ignore: if key isn't in globalOptions it'll be undefined which is false
16 | return !!globalOptions[key];
17 | };
18 |
--------------------------------------------------------------------------------
/packages/core/scripts/cleanCache.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs');
2 | const path = require('path');
3 |
4 | let dir = __dirname;
5 |
6 | while (true) {
7 | const cachePath = path.join(dir, 'node_modules', '.tsslint');
8 | if (fs.existsSync(cachePath)) {
9 | console.log(`Removing ${cachePath}`);
10 | fs.rmSync(cachePath, { recursive: true });
11 | break;
12 | }
13 |
14 | const parentDir = path.resolve(dir, '..');
15 | if (parentDir === dir) {
16 | break;
17 | }
18 |
19 | dir = parentDir;
20 | }
21 |
--------------------------------------------------------------------------------
/packages/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tsslint/core",
3 | "version": "2.0.7",
4 | "license": "MIT",
5 | "files": [
6 | "**/*.js",
7 | "**/*.d.ts"
8 | ],
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/johnsoncodehk/tsslint.git",
12 | "directory": "packages/core"
13 | },
14 | "dependencies": {
15 | "@tsslint/types": "2.0.7",
16 | "esbuild": ">=0.17.0",
17 | "minimatch": "^10.0.1"
18 | },
19 | "scripts": {
20 | "postinstall": "node scripts/cleanCache.js"
21 | }
22 | }
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/no-unnecessary-type-assertion.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | {
3 | const foo = 3;
4 | const bar = foo!;
5 | }
6 | {
7 | const foo = (3 + 5);
8 | }
9 | {
10 | type Foo = number;
11 | const foo = (3 + 5);
12 | }
13 | {
14 | type Foo = number;
15 | const foo = (3 + 5) as Foo;
16 | }
17 | {
18 | const foo = 'foo' as const;
19 | }
20 | {
21 | function foo(x: number): number {
22 | return x!; // unnecessary non-null
23 | }
24 | }
25 | export { }
26 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/prefer-nullish-coalescing.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | const foo: any = 'bar';
3 | foo !== undefined && foo !== null ? foo : 'a string';
4 | foo === undefined || foo === null ? 'a string' : foo;
5 | foo == undefined ? 'a string' : foo;
6 | foo == null ? 'a string' : foo;
7 |
8 | const foo: string | undefined = 'bar';
9 | foo !== undefined ? foo : 'a string';
10 | foo === undefined ? 'a string' : foo;
11 |
12 | const foo: string | null = 'bar';
13 | foo !== null ? foo : 'a string';
14 | foo === null ? 'a string' : foo;
15 |
--------------------------------------------------------------------------------
/packages/typescript-plugin/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tsslint/typescript-plugin",
3 | "version": "2.0.7",
4 | "license": "MIT",
5 | "files": [
6 | "**/*.js",
7 | "**/*.d.ts"
8 | ],
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/johnsoncodehk/tsslint.git",
12 | "directory": "packages/typescript-plugin"
13 | },
14 | "dependencies": {
15 | "@tsslint/core": "2.0.7",
16 | "error-stack-parser": "^2.1.4",
17 | "source-map-support": "^0.5.21"
18 | },
19 | "devDependencies": {
20 | "@tsslint/config": "2.0.7"
21 | }
22 | }
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | // A launch configuration that compiles the extension and then opens it inside a new window
2 | {
3 | "version": "0.2.0",
4 | "configurations": [
5 | {
6 | "name": "Launch VSCode Extension",
7 | "type": "extensionHost",
8 | "request": "launch",
9 | "autoAttachChildProcesses": true,
10 | "runtimeExecutable": "${execPath}",
11 | "args": [
12 | "--extensionDevelopmentPath=${workspaceRoot}/packages/vscode",
13 | "--folder-uri=${workspaceRoot}/fixtures",
14 | ],
15 | "outFiles": [
16 | "${workspaceRoot}/**/*.js"
17 | ],
18 | },
19 | ],
20 | }
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "compile",
7 | "group": "build",
8 | "presentation": {
9 | "panel": "dedicated",
10 | "reveal": "never"
11 | },
12 | "problemMatcher": [
13 | "$tsc"
14 | ]
15 | },
16 | {
17 | "type": "npm",
18 | "script": "watch",
19 | "isBackground": true,
20 | "group": {
21 | "kind": "build",
22 | "isDefault": true
23 | },
24 | "presentation": {
25 | "panel": "dedicated",
26 | "reveal": "never"
27 | },
28 | "problemMatcher": [
29 | "$tsc-watch"
30 | ]
31 | }
32 | ]
33 | }
--------------------------------------------------------------------------------
/packages/eslint/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tsslint/eslint",
3 | "version": "2.0.7",
4 | "license": "MIT",
5 | "files": [
6 | "**/*.js",
7 | "**/*.d.ts"
8 | ],
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/johnsoncodehk/tsslint.git",
12 | "directory": "packages/eslint"
13 | },
14 | "scripts": {
15 | "postinstall": "node scripts/generateDts.js"
16 | },
17 | "devDependencies": {
18 | "@tsslint/types": "2.0.7",
19 | "@types/eslint": "^8.56.10",
20 | "typescript": "latest"
21 | },
22 | "dependencies": {
23 | "@tsslint/config": "2.0.7",
24 | "@typescript-eslint/parser": "^8.16.0",
25 | "eslint": ">=9.0.0 <9.28.0"
26 | }
27 | }
--------------------------------------------------------------------------------
/packages/eslint/scripts/generateDts.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const fs = require('fs');
3 | const nodeModulesDirs = [];
4 |
5 | let dir = __dirname;
6 |
7 | while (true) {
8 | const nodeModuleDir = path.join(dir, 'node_modules');
9 | if (fs.existsSync(nodeModuleDir)) {
10 | nodeModulesDirs.push(nodeModuleDir);
11 | }
12 | const parentDir = path.resolve(dir, '..');
13 | if (parentDir === dir) {
14 | break;
15 | }
16 | dir = parentDir;
17 | }
18 |
19 | try {
20 | const { generate } = require('../lib/dtsGenerate.js');
21 | generate(nodeModulesDirs).then(dts => {
22 | fs.writeFileSync(path.resolve(__dirname, '..', 'lib', 'types.d.ts'), dts);
23 | });
24 | } catch { }
25 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/no-unnecessary-condition.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | function head(items: T[]) {
3 | // items can never be nullable, so this is unnecessary
4 | if (items) {
5 | return items[0].toUpperCase();
6 | }
7 | }
8 |
9 | function foo(arg: 'bar' | 'baz') {
10 | // arg is never nullable or empty string, so this is unnecessary
11 | if (arg) {
12 | }
13 | }
14 |
15 | function bar(arg: string) {
16 | // arg can never be nullish, so ?. is unnecessary
17 | return arg?.length;
18 | }
19 |
20 | // Checks array predicate return types, where possible
21 | [
22 | [1, 2],
23 | [3, 4],
24 | ].filter(t => t); // number[] is always truthy
25 |
--------------------------------------------------------------------------------
/packages/config/index.ts:
--------------------------------------------------------------------------------
1 | export * from '@tsslint/types';
2 | export { create as createCategoryPlugin } from './lib/plugins/category.js';
3 | export { create as createDiagnosticsPlugin } from './lib/plugins/diagnostics.js';
4 | export { create as createIgnorePlugin } from './lib/plugins/ignore.js';
5 |
6 | import type { Config, Plugin, Rule } from '@tsslint/types';
7 |
8 | export function defineRule(rule: Rule) {
9 | return rule;
10 | }
11 |
12 | export function definePlugin(plugin: Plugin) {
13 | return plugin;
14 | }
15 |
16 | export function defineConfig(config: Config | Config[]) {
17 | return config;
18 | }
19 |
20 | export function isCLI() {
21 | return !!process.env.TSSLINT_CLI;
22 | }
23 |
--------------------------------------------------------------------------------
/packages/cli/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@tsslint/cli",
3 | "version": "2.0.7",
4 | "license": "MIT",
5 | "engines": {
6 | "node": ">=22"
7 | },
8 | "bin": {
9 | "tsslint": "./bin/tsslint.js"
10 | },
11 | "files": [
12 | "**/*.js",
13 | "**/*.d.ts"
14 | ],
15 | "repository": {
16 | "type": "git",
17 | "url": "https://github.com/johnsoncodehk/tsslint.git",
18 | "directory": "packages/cli"
19 | },
20 | "dependencies": {
21 | "@clack/prompts": "^0.8.2",
22 | "@tsslint/config": "2.0.7",
23 | "@tsslint/core": "2.0.7",
24 | "@volar/language-core": "~2.4.0",
25 | "@volar/language-hub": "0.0.1",
26 | "@volar/typescript": "~2.4.0",
27 | "minimatch": "^10.0.1"
28 | },
29 | "peerDependencies": {
30 | "typescript": "*"
31 | }
32 | }
--------------------------------------------------------------------------------
/fixtures/convert-a-tslint-rule/fixture.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | // nullable numbers are considered unsafe by default
3 | let num: number | undefined = 0;
4 | if (num) {
5 | console.log('num is defined');
6 | }
7 |
8 | // nullable strings are considered unsafe by default
9 | let str: string | null = null;
10 | if (!str) {
11 | console.log('str is empty');
12 | }
13 |
14 | // nullable booleans are considered unsafe by default
15 | function foo(bool?: boolean) {
16 | if (bool) {
17 | bar();
18 | }
19 | }
20 |
21 | // `any`, unconstrained generics and unions of more than one primitive type are disallowed
22 | const foo = (arg: T) => (arg ? 1 : 0);
23 |
24 | // always-truthy and always-falsy types are disallowed
25 | let obj = {};
26 | while (obj) {
27 | obj = getObj();
28 | }
29 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/strict-boolean-expressions.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 | // nullable numbers are considered unsafe by default
3 | let num: number | undefined = 0;
4 | if (num) {
5 | console.log('num is defined');
6 | }
7 |
8 | // nullable strings are considered unsafe by default
9 | let str: string | null = null;
10 | if (!str) {
11 | console.log('str is empty');
12 | }
13 |
14 | // nullable booleans are considered unsafe by default
15 | function foo(bool?: boolean) {
16 | if (bool) {
17 | bar();
18 | }
19 | }
20 |
21 | // `any`, unconstrained generics and unions of more than one primitive type are disallowed
22 | const foo = (arg: T) => (arg ? 1 : 0);
23 |
24 | // always-truthy and always-falsy types are disallowed
25 | let obj = {};
26 | while (obj) {
27 | obj = getObj();
28 | }
29 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.format.semicolons": "insert",
3 | "editor.insertSpaces": false,
4 | "editor.detectIndentation": false,
5 | "json.format.keepLines": true,
6 | "typescript.tsdk": "node_modules/typescript/lib",
7 | "[typescript]": {
8 | "editor.defaultFormatter": "vscode.typescript-language-features"
9 | },
10 | "[javascript]": {
11 | "editor.defaultFormatter": "vscode.typescript-language-features"
12 | },
13 | "[json]": {
14 | "editor.defaultFormatter": "vscode.json-language-features"
15 | },
16 | "[jsonc]": {
17 | "editor.defaultFormatter": "vscode.json-language-features"
18 | },
19 | "files.exclude": {
20 | "packages/*/index.d.ts": true,
21 | "packages/*/index.js": true,
22 | "packages/*/lib/**/*.d.ts": true,
23 | "packages/*/lib/**/*.js": true,
24 | "packages/**/*.map": true
25 | }
26 | }
--------------------------------------------------------------------------------
/.github/workflows/auto-fix.yml:
--------------------------------------------------------------------------------
1 | name: auto-fix
2 |
3 | on:
4 | push:
5 | branches:
6 | - 'master'
7 |
8 | jobs:
9 | auto-fix:
10 | runs-on: ubuntu-latest
11 | steps:
12 | - uses: actions/checkout@v4
13 |
14 | - uses: pnpm/action-setup@v4
15 |
16 | - uses: actions/setup-node@v4
17 | with:
18 | node-version: 22
19 | cache: pnpm
20 |
21 | - run: pnpm install
22 |
23 | # build
24 | - run: pnpm run build
25 |
26 | # lint
27 | - name: Auto-fix
28 | run: pnpm run lint:fix
29 |
30 | # commit
31 | - name: Commit
32 | uses: EndBug/add-and-commit@v9
33 | with:
34 | message: "ci(lint): auto-fix"
35 | default_author: github_actions
36 | env:
37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
38 |
--------------------------------------------------------------------------------
/fixtures/define-a-plugin/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, definePlugin } from '@tsslint/config';
2 | import { create as createNoConsoleRule } from '../noConsoleRule';
3 |
4 | export default defineConfig({
5 | plugins: [
6 | () => ({
7 | resolveRules(fileName, rules) {
8 | rules['no-console'] = createNoConsoleRule();
9 | return rules;
10 | },
11 | }),
12 | createIngorePlugin(/\/\/ @tsslint-ignore/g),
13 | ],
14 | });
15 |
16 | function createIngorePlugin(pattern: RegExp) {
17 | return definePlugin(() => ({
18 | resolveDiagnostics(file, results) {
19 | const comments = [...file.text.matchAll(pattern)];
20 | const lines = new Set(comments.map(comment => file.getLineAndCharacterOfPosition(comment.index).line));
21 | return results.filter(error => error.source !== 'tsslint' || !lines.has(file.getLineAndCharacterOfPosition(error.start).line - 1));
22 | },
23 | }));
24 | }
25 |
--------------------------------------------------------------------------------
/fixtures/noConsoleRule.ts:
--------------------------------------------------------------------------------
1 | import { defineRule } from '@tsslint/config';
2 |
3 | export function create() {
4 | return defineRule(({ typescript: ts, file, report }) => {
5 | ts.forEachChild(file, function cb(node) {
6 | if (
7 | ts.isPropertyAccessExpression(node) &&
8 | ts.isIdentifier(node.expression) &&
9 | node.expression.text === 'console'
10 | ) {
11 | report(
12 | `Calls to 'console.x' are not allowed.`,
13 | node.parent.getStart(file),
14 | node.parent.getEnd()
15 | ).withFix(
16 | `Remove 'console.${node.name.text}'`,
17 | () => [{
18 | fileName: file.fileName,
19 | textChanges: [{
20 | newText: '/* deleted */',
21 | span: {
22 | start: node.parent.getStart(file),
23 | length: node.parent.getWidth(file),
24 | },
25 | }],
26 | }]
27 | );
28 | }
29 | ts.forEachChild(node, cb);
30 | });
31 | });
32 | }
33 |
--------------------------------------------------------------------------------
/packages/config/lib/plugins/diagnostics.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from '@tsslint/types';
2 |
3 | type CheckMode = 'syntactic' | 'semantic' | 'declaration';
4 |
5 | export function create(mode: CheckMode | CheckMode[] = 'semantic'): Plugin {
6 | const modes = Array.isArray(mode) ? mode : [mode];
7 | return ({ languageService }) => ({
8 | resolveDiagnostics(file, diagnostics) {
9 | const program = languageService.getProgram()!;
10 | for (const mode of modes) {
11 | const diags = mode === 'syntactic'
12 | ? program.getSyntacticDiagnostics(file)
13 | : mode === 'semantic'
14 | ? program.getSemanticDiagnostics(file)
15 | : mode === 'declaration'
16 | ? program.getDeclarationDiagnostics(file)
17 | : [];
18 | for (const diag of diags) {
19 | diag.start ??= 0;
20 | diag.length ??= 0;
21 | diagnostics.push(diag as any);
22 | }
23 | }
24 | return diagnostics;
25 | },
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/packages/config/lib/plugins/category.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from '@tsslint/types';
2 | import type * as ts from 'typescript';
3 |
4 | import minimatch = require('minimatch');
5 |
6 | export function create(config: Record, source = 'tsslint'): Plugin {
7 | const matchCache = new Map();
8 | return () => ({
9 | resolveDiagnostics(_file, diagnostics) {
10 | for (const diagnostic of diagnostics) {
11 | if (diagnostic.source !== source) {
12 | continue;
13 | }
14 | const category = match(diagnostic.code.toString());
15 | if (category !== undefined) {
16 | diagnostic.category = category;
17 | }
18 | }
19 | return diagnostics;
20 | }
21 | });
22 | function match(code: string) {
23 | if (matchCache.has(code)) {
24 | return matchCache.get(code);
25 | }
26 | for (const pattern in config) {
27 | const category = config[pattern];
28 | if (minimatch.minimatch(code, pattern, { dot: true })) {
29 | matchCache.set(code, category);
30 | return category;
31 | }
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/packages/cli/lib/languagePlugins.ts:
--------------------------------------------------------------------------------
1 | import type { LanguagePlugin } from '@volar/language-core';
2 | import { createAstroPlugin, createMdxPlugin, createTsMacroPlugins, createVuePlugin, createVueVinePlugins } from '@volar/language-hub';
3 | import ts = require('typescript');
4 |
5 | const cache = new Map[]>();
6 |
7 | export async function load(tsconfig: string, languages: string[]) {
8 | if (cache.has(tsconfig)) {
9 | return cache.get(tsconfig)!;
10 | }
11 | const plugins: LanguagePlugin[] = [];
12 | if (languages.includes('vue')) {
13 | plugins.push(createVuePlugin(ts, tsconfig));
14 | }
15 | if (languages.includes('vue-vine')) {
16 | plugins.push(...createVueVinePlugins(ts, tsconfig));
17 | }
18 | if (languages.includes('mdx')) {
19 | plugins.push(await createMdxPlugin(ts, tsconfig));
20 | }
21 | if (languages.includes('astro')) {
22 | plugins.push(createAstroPlugin(ts, tsconfig));
23 | }
24 | if (languages.includes('ts-macro')) {
25 | plugins.push(...await createTsMacroPlugins(ts, tsconfig));
26 | }
27 | cache.set(tsconfig, plugins);
28 | return plugins;
29 | }
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/cli/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/core/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/config/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/eslint/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/tslint/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/types/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/vscode/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/packages/typescript-plugin/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023-present Johnson Chu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "version": "2.0.7",
4 | "packageManager": "pnpm@9.3.0",
5 | "scripts": {
6 | "build": "tsc -b",
7 | "watch": "tsc -b -w",
8 | "prerelease:base": "npm run build",
9 | "release": "npm run release:base && npm run release:vscode",
10 | "release:base": "lerna publish --exact --force-publish --yes --sync-workspace-lock",
11 | "release:vscode": "cd packages/vscode && npm run publish:all",
12 | "start": "node packages/cli/bin/tsslint.js",
13 | "lint": "node packages/cli/bin/tsslint.js --project {tsconfig.json,packages/*/tsconfig.json}",
14 | "lint:fix": "npm run lint -- --fix",
15 | "lint:fixtures": "node packages/cli/bin/tsslint.js --project fixtures/*/tsconfig.json --vue-project fixtures/meta-frameworks-support/tsconfig.json --mdx-project fixtures/meta-frameworks-support/tsconfig.json --astro-project fixtures/meta-frameworks-support/tsconfig.json --ts-macro-project fixtures/meta-frameworks-support/tsconfig.json"
16 | },
17 | "devDependencies": {
18 | "@lerna-lite/cli": "latest",
19 | "@lerna-lite/publish": "latest",
20 | "@tsslint/config": "2.0.7",
21 | "@types/node": "latest",
22 | "typescript": "latest"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/core/lib/build.ts:
--------------------------------------------------------------------------------
1 | import { watchConfig } from './watch';
2 | import _path = require('path');
3 |
4 | export function buildConfig(
5 | configFilePath: string,
6 | createHash?: (path: string) => string,
7 | message?: (message: string) => void,
8 | stopSnipper?: (message: string, code?: number) => void
9 | ): Promise {
10 | const buildStart = Date.now();
11 | const configFileDisplayPath = _path.relative(process.cwd(), configFilePath);
12 |
13 | message?.('Building ' + configFileDisplayPath);
14 |
15 | return new Promise(async resolve => {
16 | try {
17 | await watchConfig(
18 | configFilePath,
19 | builtConfig => {
20 | if (builtConfig) {
21 | stopSnipper?.('Built ' + configFileDisplayPath + ' in ' + (Date.now() - buildStart) + 'ms');
22 | } else {
23 | stopSnipper?.('Failed to build ' + configFileDisplayPath + ' in ' + (Date.now() - buildStart) + 'ms', 1);
24 | }
25 | resolve(builtConfig);
26 | },
27 | false,
28 | createHash,
29 | );
30 | } catch (e) {
31 | stopSnipper?.('Failed to build ' + configFileDisplayPath + ' in ' + (Date.now() - buildStart) + 'ms', 1);
32 | resolve(undefined);
33 | }
34 | });
35 | }
36 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/cases/return-await.ts:
--------------------------------------------------------------------------------
1 | // @ts-nocheck
2 |
3 | async function invalidInTryCatch1() {
4 | try {
5 | return Promise.reject('try');
6 | } catch (e) {
7 | // Doesn't execute due to missing await.
8 | }
9 | }
10 |
11 | async function invalidInTryCatch2() {
12 | try {
13 | throw new Error('error');
14 | } catch (e) {
15 | // Unnecessary await; rejections here don't impact control flow.
16 | return await Promise.reject('catch');
17 | }
18 | }
19 |
20 | // Prints 'starting async work', 'cleanup', 'async work done'.
21 | async function invalidInTryCatch3() {
22 | async function doAsyncWork(): Promise {
23 | console.log('starting async work');
24 | await new Promise(resolve => setTimeout(resolve, 1000));
25 | console.log('async work done');
26 | }
27 |
28 | try {
29 | throw new Error('error');
30 | } catch (e) {
31 | // Missing await.
32 | return doAsyncWork();
33 | } finally {
34 | console.log('cleanup');
35 | }
36 | }
37 |
38 | async function invalidInTryCatch4() {
39 | try {
40 | throw new Error('error');
41 | } catch (e) {
42 | throw new Error('error2');
43 | } finally {
44 | // Unnecessary await; rejections here don't impact control flow.
45 | return await Promise.reject('finally');
46 | }
47 | }
48 |
49 | async function invalidInTryCatch5() {
50 | return await Promise.resolve('try');
51 | }
52 |
53 | async function invalidInTryCatch6() {
54 | return await 'value';
55 | }
56 |
--------------------------------------------------------------------------------
/packages/cli/lib/cache.ts:
--------------------------------------------------------------------------------
1 | import core = require('@tsslint/core');
2 | import path = require('path');
3 | import fs = require('fs');
4 |
5 | export type CacheData = Record;
6 |
7 | export function loadCache(
8 | tsconfig: string,
9 | configFilePath: string,
10 | createHash: (path: string) => string = btoa
11 | ): CacheData {
12 | const outDir = core.getDotTsslintPath(configFilePath);
13 | const cacheFileName = createHash(path.relative(outDir, configFilePath)) + '_' + createHash(JSON.stringify(process.argv)) + '_' + createHash(path.relative(outDir, tsconfig)) + '.cache.json';
14 | const cacheFilePath = path.join(outDir, cacheFileName);
15 | const cacheFileStat = fs.statSync(cacheFilePath, { throwIfNoEntry: false });
16 | const configFileStat = fs.statSync(configFilePath, { throwIfNoEntry: false });
17 | if (cacheFileStat?.isFile() && cacheFileStat.mtimeMs > (configFileStat?.mtimeMs ?? 0)) {
18 | try {
19 | return require(cacheFilePath);
20 | } catch {
21 | return {};
22 | }
23 | }
24 | return {};
25 | }
26 |
27 | export function saveCache(
28 | tsconfig: string,
29 | configFilePath: string,
30 | cache: CacheData,
31 | createHash: (path: string) => string = btoa
32 | ): void {
33 | const outDir = core.getDotTsslintPath(configFilePath);
34 | const cacheFileName = createHash(path.relative(outDir, configFilePath)) + '_' + createHash(JSON.stringify(process.argv)) + '_' + createHash(path.relative(outDir, tsconfig)) + '.cache.json';
35 | const cacheFilePath = path.join(outDir, cacheFileName);
36 | fs.writeFileSync(cacheFilePath, JSON.stringify(cache));
37 | }
38 |
--------------------------------------------------------------------------------
/packages/tslint/index.ts:
--------------------------------------------------------------------------------
1 | import type * as TSSLint from '@tsslint/types';
2 | import type * as TSLint from 'tslint';
3 | import type * as ts from 'typescript';
4 |
5 | type TSLintRule = import('tslint/lib/language/rule/rule').RuleConstructor;
6 |
7 | export function convertRule | TSLintRule>(
8 | Rule: T,
9 | ruleArguments: any[] = [],
10 | category: ts.DiagnosticCategory = 3 satisfies ts.DiagnosticCategory.Message,
11 | ): TSSLint.Rule {
12 | const rule = new (Rule as TSLintRule)({
13 | ruleName: Rule.metadata?.ruleName ?? 'unknown',
14 | ruleArguments,
15 | ruleSeverity: 'warning',
16 | disabledIntervals: [],
17 | }) as TSLint.IRule | TSLint.ITypedRule;
18 | return ({ file, languageService, report }) => {
19 | const failures = 'applyWithProgram' in rule
20 | ? rule.applyWithProgram(file, languageService.getProgram()!)
21 | : rule.apply(file);
22 | for (const failure of new Set(failures)) {
23 | onAddFailure(failure);
24 | }
25 | function onAddFailure(failure: TSLint.RuleFailure) {
26 | const reporter = report(
27 | failure.getFailure(),
28 | failure.getStartPosition().getPosition(),
29 | failure.getEndPosition().getPosition(),
30 | category,
31 | Number.MAX_VALUE
32 | );
33 | if (failure.hasFix()) {
34 | const fix = failure.getFix();
35 | const replaces = Array.isArray(fix) ? fix : [fix];
36 | for (const replace of replaces) {
37 | if (replace) {
38 | reporter.withFix(
39 | replace.length === 0
40 | ? 'Insert ' + replace.text
41 | : replace.text.length === 0
42 | ? 'Delete ' + replace.start + ' to ' + replace.end
43 | : 'Replace with ' + replace.text,
44 | () => [{
45 | fileName: file.fileName,
46 | textChanges: [{
47 | newText: replace.text,
48 | span: {
49 | start: replace.start,
50 | length: replace.length,
51 | },
52 | }],
53 | }]
54 | );
55 | }
56 | }
57 | }
58 | };
59 | };
60 | }
61 |
--------------------------------------------------------------------------------
/packages/core/lib/watch.ts:
--------------------------------------------------------------------------------
1 | import esbuild = require('esbuild');
2 | import _path = require('path');
3 | import fs = require('fs');
4 | import url = require('url');
5 |
6 | export async function watchConfig(
7 | configFilePath: string,
8 | onBuild: (config: string | undefined, result: esbuild.BuildResult) => void,
9 | watch = true,
10 | createHash: (path: string) => string = btoa,
11 | ) {
12 | const outDir = getDotTsslintPath(configFilePath);
13 | const outFileName = createHash(_path.relative(outDir, configFilePath)) + '.mjs';
14 | const outFile = _path.join(outDir, outFileName);
15 | const resultHandler = (result: esbuild.BuildResult) => {
16 | if (!result.errors.length) {
17 | onBuild(outFile, result);
18 | } else {
19 | onBuild(undefined, result);
20 | }
21 | };
22 | const ctx = await esbuild.context({
23 | entryPoints: [configFilePath],
24 | bundle: true,
25 | sourcemap: true,
26 | outfile: outFile,
27 | format: 'esm',
28 | platform: 'node',
29 | plugins: [{
30 | name: 'tsslint',
31 | setup(build) {
32 | build.onResolve({ filter: /.*/ }, ({ path, resolveDir }) => {
33 | if (!isTsFile(path)) {
34 | try {
35 | const maybeJsPath = require.resolve(path, { paths: [resolveDir] });
36 | if (!isTsFile(maybeJsPath) && fs.existsSync(maybeJsPath)) {
37 | return {
38 | path: url.pathToFileURL(maybeJsPath).toString(),
39 | external: true,
40 | };
41 | }
42 | } catch { }
43 | }
44 | return {};
45 | });
46 | if (watch) {
47 | build.onEnd(resultHandler);
48 | }
49 | },
50 | }],
51 | });
52 | if (watch) {
53 | await ctx.watch();
54 | }
55 | else {
56 | try {
57 | const result = await ctx.rebuild(); // could throw
58 | await ctx.dispose();
59 | resultHandler(result);
60 | } catch (e) {
61 | throw e;
62 | }
63 | }
64 | return ctx;
65 | }
66 |
67 | function isTsFile(path: string) {
68 | return path.endsWith('.ts') || path.endsWith('.tsx') || path.endsWith('.cts') || path.endsWith('.mts');
69 | }
70 |
71 | export function getDotTsslintPath(configFilePath: string): string {
72 | return _path.resolve(configFilePath, '..', 'node_modules', '.tsslint');
73 | }
74 |
--------------------------------------------------------------------------------
/packages/eslint/lib/plugins/showDocsAction.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin, Rules } from '@tsslint/types';
2 | import { exec } from 'node:child_process';
3 | import type * as ts from 'typescript';
4 |
5 | interface Cmd {
6 | command: typeof cmd;
7 | file: string;
8 | url: string;
9 | }
10 |
11 | const cmd = 'eslint:open-eslint-rule-docs';
12 | const decorated = new WeakSet();
13 |
14 | export function create(): Plugin {
15 | return ({ languageService }) => {
16 | const ruleId2Meta = new Map();
17 |
18 | if (!decorated.has(languageService)) {
19 | decorated.add(languageService);
20 | const { applyCodeActionCommand } = languageService;
21 | languageService.applyCodeActionCommand = async (command, ...rest: any) => {
22 | if (typeof command === 'object' && (command as Cmd)?.command === cmd) {
23 | const start = process.platform == 'darwin' ? 'open' : process.platform == 'win32' ? 'start' : 'xdg-open';
24 | exec(`${start} ${(command as Cmd).url}`);
25 | return {};
26 | }
27 | return await applyCodeActionCommand(command, ...rest) as any;
28 | };
29 | }
30 |
31 | return {
32 | resolveRules(_fileName, rules) {
33 | collectMetadata(rules);
34 | return rules;
35 | },
36 | resolveCodeFixes(file, diagnostic, codeFixes) {
37 | const ruleMeta = ruleId2Meta.get(diagnostic.code as any);
38 | if (!ruleMeta?.docs?.url) {
39 | return codeFixes;
40 | }
41 | return [
42 | ...codeFixes,
43 | {
44 | changes: [],
45 | description: `Show documentation for ${diagnostic.code}`,
46 | fixName: 'Show documentation',
47 | commands: [{
48 | command: cmd,
49 | file: file.fileName,
50 | url: ruleMeta.docs.url,
51 | } satisfies Cmd],
52 | },
53 | ];
54 | },
55 | };
56 |
57 | function collectMetadata(rules: Rules, paths: string[] = []) {
58 | for (const [path, rule] of Object.entries(rules)) {
59 | if (typeof rule === 'object') {
60 | collectMetadata(rule, [...paths, path]);
61 | continue;
62 | }
63 | const meta = (rule as any).meta;
64 | if (typeof meta === 'object' && meta) {
65 | const ruleId = [...paths, path].join('/');
66 | ruleId2Meta.set(ruleId, meta);
67 | }
68 | }
69 | };
70 | };
71 | }
--------------------------------------------------------------------------------
/packages/types/index.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | CodeFixAction,
3 | Diagnostic,
4 | DiagnosticCategory,
5 | DiagnosticWithLocation,
6 | FileTextChanges,
7 | LanguageService,
8 | LanguageServiceHost,
9 | Program,
10 | SourceFile,
11 | } from 'typescript';
12 |
13 | export interface LinterContext {
14 | typescript: typeof import('typescript');
15 | languageServiceHost: LanguageServiceHost;
16 | languageService: LanguageService;
17 | }
18 |
19 | export interface Config {
20 | include?: string[];
21 | exclude?: string[];
22 | rules?: Rules;
23 | plugins?: Plugin[];
24 | }
25 |
26 | export interface Plugin {
27 | (ctx: LinterContext): PluginInstance;
28 | }
29 |
30 | export interface PluginInstance {
31 | resolveRules?(fileName: string, rules: Record): Record;
32 | resolveDiagnostics?(file: SourceFile, diagnostics: DiagnosticWithLocation[]): DiagnosticWithLocation[];
33 | resolveCodeFixes?(file: SourceFile, diagnostic: Diagnostic, codeFixes: CodeFixAction[]): CodeFixAction[];
34 | }
35 |
36 | export interface Rules {
37 | [name: string]: Rule | Rules;
38 | }
39 |
40 | export interface Rule {
41 | (ctx: RuleContext): void;
42 | }
43 |
44 | export interface RuleContext {
45 | typescript: typeof import('typescript');
46 | languageServiceHost: LanguageServiceHost;
47 | languageService: LanguageService;
48 | program: Program;
49 | file: SourceFile;
50 | report(message: string, start: number, end: number, category?: DiagnosticCategory, stackOffset?: number): Reporter;
51 |
52 | /**
53 | * @deprecated Use `file` instead.
54 | */
55 | sourceFile: SourceFile;
56 | /**
57 | * @deprecated Use `report` instead.
58 | */
59 | reportError(message: string, start: number, end: number, stackOffset?: number): Reporter;
60 | /**
61 | * @deprecated Use `report` instead.
62 | */
63 | reportWarning(message: string, start: number, end: number, stackOffset?: number): Reporter;
64 | /**
65 | * @deprecated Use `report` instead.
66 | */
67 | reportSuggestion(message: string, start: number, end: number, stackOffset?: number): Reporter;
68 | }
69 |
70 | export interface Reporter {
71 | withDeprecated(): Reporter;
72 | withUnnecessary(): Reporter;
73 | withFix(title: string, getChanges: () => FileTextChanges[]): Reporter;
74 | withRefactor(title: string, getChanges: () => FileTextChanges[]): Reporter;
75 | }
76 |
--------------------------------------------------------------------------------
/fixtures/convert-eslint-rules/tsslint.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, createIgnorePlugin } from '@tsslint/config';
2 | import { convertRule } from '@tsslint/eslint';
3 |
4 | export default defineConfig({
5 | plugins: [
6 | createIgnorePlugin('@tsslint-ignore', false),
7 | createIgnorePlugin('@tsslint-expect-error', true),
8 | ],
9 | rules: {
10 | 'no-console': convertRule((await import('../../packages/eslint/node_modules/eslint/lib/rules/no-console.js')).default),
11 | 'prefer-ts-expect-error': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/prefer-ts-expect-error.js')).default.default),
12 | 'return-await': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/return-await.js')).default.default),
13 | 'no-unnecessary-type-assertion': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/no-unnecessary-type-assertion.js')).default.default),
14 | 'prefer-nullish-coalescing': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/prefer-nullish-coalescing.js')).default.default, [{
15 | ignorePrimitives: {
16 | boolean: true,
17 | },
18 | }]),
19 | 'strict-boolean-expressions': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/strict-boolean-expressions.js')).default.default, [{
20 | allowNullableBoolean: true,
21 | allowString: false,
22 | allowAny: true,
23 | }]),
24 | 'switch-exhaustiveness-check': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/switch-exhaustiveness-check.js')).default.default, [{
25 | allowDefaultCaseForExhaustiveSwitch: true,
26 | requireDefaultForNonUnion: true,
27 | }]),
28 | 'no-unnecessary-condition': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/no-unnecessary-condition.js')).default.default, [{
29 | allowConstantLoopConditions: true,
30 | }]),
31 |
32 | // vuejs/core rules
33 | // 'prefer-ts-expect-error': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/prefer-ts-expect-error.js')).default.default, 1),
34 | 'consistent-type-imports': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/consistent-type-imports.js')).default.default, [{
35 | fixStyle: 'inline-type-imports',
36 | disallowTypeAnnotations: false,
37 | }]),
38 | 'no-import-type-side-effects': convertRule((await import('./node_modules/@typescript-eslint/eslint-plugin/dist/rules/no-import-type-side-effects.js')).default.default),
39 | },
40 | });
41 |
--------------------------------------------------------------------------------
/packages/vscode/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "vscode-tsslint",
4 | "version": "2.0.7",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/johnsoncodehk/tsslint.git",
8 | "directory": "packages/vscode"
9 | },
10 | "sponsor": {
11 | "url": "https://github.com/sponsors/johnsoncodehk"
12 | },
13 | "main": "./extension.js",
14 | "displayName": "TSSLint",
15 | "description": "The TSSLint VSCode Extension",
16 | "author": "johnsoncodehk",
17 | "publisher": "johnsoncodehk",
18 | "engines": {
19 | "vscode": "^1.82.0"
20 | },
21 | "activationEvents": [
22 | "onLanguage"
23 | ],
24 | "contributes": {
25 | "typescriptServerPlugins": [
26 | {
27 | "name": "@tsslint/typescript-plugin",
28 | "enableForWorkspaceTypeScriptVersions": true
29 | }
30 | ]
31 | },
32 | "scripts": {
33 | "pack:win32-x64": "rm -rf node_modules && npm install --no-package-lock --os=win32 --cpu=x64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce package",
34 | "pack:win32-arm64": "rm -rf node_modules && npm install --no-package-lock --os=win32 --cpu=arm64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce package",
35 | "pack:linux-x64": "rm -rf node_modules && npm install --no-package-lock --os=linux --cpu=x64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce package",
36 | "pack:linux-arm64": "rm -rf node_modules && npm install --no-package-lock --os=linux --cpu=arm64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce package",
37 | "pack:linux-armhf": "rm -rf node_modules && npm install --no-package-lock --os=linux --cpu=arm && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce package",
38 | "pack:darwin-x64": "rm -rf node_modules && npm install --no-package-lock --os=darwin --cpu=x64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce package",
39 | "pack:darwin-arm64": "rm -rf node_modules && npm install --no-package-lock --os=darwin --cpu=arm64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce package",
40 | "pack:all": "npm run pack:win32-x64 && npm run pack:win32-arm64 && npm run pack:linux-x64 && npm run pack:linux-arm64 && npm run pack:linux-armhf && npm run pack:darwin-x64 && npm run pack:darwin-arm64",
41 | "postpack:all": "rm -rf node_modules && pnpm install",
42 | "publish:win32-x64": "rm -rf node_modules && npm install --no-package-lock --os=win32 --cpu=x64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce publish --target win32-x64",
43 | "publish:win32-arm64": "rm -rf node_modules && npm install --no-package-lock --os=win32 --cpu=arm64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce publish --target win32-arm64",
44 | "publish:linux-x64": "rm -rf node_modules && npm install --no-package-lock --os=linux --cpu=x64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce publish --target linux-x64 alpine-x64",
45 | "publish:linux-arm64": "rm -rf node_modules && npm install --no-package-lock --os=linux --cpu=arm64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce publish --target linux-arm64 alpine-arm64",
46 | "publish:linux-armhf": "rm -rf node_modules && npm install --no-package-lock --os=linux --cpu=arm && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce publish --target linux-armhf",
47 | "publish:darwin-x64": "rm -rf node_modules && npm install --no-package-lock --os=darwin --cpu=x64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce publish --target darwin-x64",
48 | "publish:darwin-arm64": "rm -rf node_modules && npm install --no-package-lock --os=darwin --cpu=arm64 && rm -f {node_modules/esbuild/bin/esbuild,node_modules/esbuild/lib/downloaded-*} && vsce publish --target darwin-arm64",
49 | "publish:all": "npm run publish:win32-x64 && npm run publish:win32-arm64 && npm run publish:linux-x64 && npm run publish:linux-arm64 && npm run publish:linux-armhf && npm run publish:darwin-x64 && npm run publish:darwin-arm64",
50 | "postpublish:all": "rm -rf node_modules && pnpm install"
51 | },
52 | "dependencies": {
53 | "@tsslint/typescript-plugin": "2.0.7"
54 | },
55 | "devDependencies": {
56 | "@types/vscode": "^1.82.0",
57 | "@vscode/vsce": "latest"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/packages/vscode/extension.js:
--------------------------------------------------------------------------------
1 | module.exports.activate = () => { };
2 | module.exports.deactivate = () => { };
3 |
4 | const vscode = require('vscode');
5 | const tsExtension = vscode.extensions.getExtension('vscode.typescript-language-features');
6 | if (tsExtension.isActive) {
7 | vscode.window.showInformationMessage(
8 | 'TSSLint may not work properly if the TypeScript Language Features extension is activated first.' +
9 | ' Try restarting the Extension Host in VSCode, or let us know if you keep seeing this issue.',
10 | 'Restart Extension Host',
11 | 'Report Issue'
12 | ).then(selection => {
13 | if (selection === 'Restart Extension Host') {
14 | vscode.commands.executeCommand('workbench.action.restartExtensionHost');
15 | } else if (selection === 'Report Issue') {
16 | vscode.commands.executeCommand('vscode.open', vscode.Uri.parse('https://github.com/johnsoncodehk/tsslint/issues/new'));
17 | }
18 | });
19 | } else {
20 | const extensionJsPath = require.resolve('./dist/extension.js', { paths: [tsExtension.extensionPath] });
21 | const readFileSync = require('fs').readFileSync;
22 |
23 | require('assert')(!require.cache[extensionJsPath]);
24 | require('fs').readFileSync = (...args) => {
25 | if (args[0] === extensionJsPath) {
26 | let text = readFileSync(...args);
27 |
28 | // Fix DiagnosticCategory.Message display
29 | text = text.replace('.category){case', '.category){case "message":return 2;case')
30 |
31 | // Patch getFixableDiagnosticsForContext
32 | text = text.replace('t.has(e.code+"")', s => `(${s}||e.source==="tsslint")`);
33 |
34 | // Support "Fix all"
35 | for (const replaceText of [
36 | 'const i=new y(t,n,r);',
37 | // VSCode 1.93.1 (#36)
38 | 'const i=new v(t,n,r)',
39 | ]) {
40 | if (!text.includes(replaceText)) {
41 | continue;
42 | }
43 | text = text.replace(replaceText, s => s + `
44 | const vscode = require('vscode');
45 | vscode.languages.registerCodeActionsProvider(
46 | e.semantic,
47 | {
48 | async provideCodeActions(document, range, context, token) {
49 | if (!context.only || (context.only.value !== 'source' && context.only.value !== 'source.fixAll' && context.only.value !== 'source.fixAll.tsslint' && !context.only.value.startsWith('source.fixAll.tsslint.'))) {
50 | return;
51 | }
52 | let action;
53 | for (const diagnostic of context.diagnostics) {
54 | if (token.isCancellationRequested) {
55 | return;
56 | }
57 |
58 | if (diagnostic.source !== 'tsslint') {
59 | continue;
60 | }
61 |
62 | if (!action) {
63 | action = new vscode.CodeAction('Fix all TSSLint issues', vscode.CodeActionKind.SourceFixAll.append('tsslint'));
64 | action.edit = new vscode.WorkspaceEdit();
65 | }
66 |
67 | const args = {
68 | file: document.uri.fsPath,
69 | startLine: diagnostic.range.start.line + 1,
70 | startOffset: diagnostic.range.start.character + 1,
71 | endLine: diagnostic.range.end.line + 1,
72 | endOffset: diagnostic.range.end.character + 1,
73 | errorCodes: [diagnostic.code],
74 | };
75 |
76 | const response = await n.client.execute('getCodeFixes', args, token);
77 | if (response.type !== 'response') {
78 | continue;
79 | }
80 |
81 | const fix = response.body?.find(fix => fix.fixName === 'tsslint:' + diagnostic.code);
82 | if (fix) {
83 | withFileCodeEdits(action.edit, n.client, fix.changes);
84 | }
85 | }
86 | if (action) {
87 | return [action];
88 | }
89 |
90 | function withFileCodeEdits(
91 | workspaceEdit,
92 | client,
93 | edits
94 | ) {
95 | for (const edit of edits) {
96 | const resource = client.toResource(edit.fileName);
97 | for (const textChange of edit.textChanges) {
98 | workspaceEdit.replace(
99 | resource,
100 | new vscode.Range(
101 | Math.max(0, textChange.start.line - 1), Math.max(textChange.start.offset - 1, 0),
102 | Math.max(0, textChange.end.line - 1), Math.max(0, textChange.end.offset - 1)),
103 | textChange.newText
104 | );
105 | }
106 | }
107 | return workspaceEdit;
108 | }
109 | }
110 | },
111 | {
112 | providedCodeActionKinds: [vscode.CodeActionKind.SourceFixAll.append('tsslint')],
113 | }
114 | );`)
115 | }
116 |
117 | // Ensure tsslint is the first plugin to be loaded, which fixes compatibility with "astro-build.astro-vscode"
118 | const pluginName = require('./package.json').contributes.typescriptServerPlugins[0].name;
119 | text = text.replace('"--globalPlugins",i.plugins', `"--globalPlugins",i.plugins.sort((a,b)=>(b.name==="${pluginName}"?1:0)-(a.name==="${pluginName}"?1:0))`);
120 |
121 | return text;
122 | }
123 | return readFileSync(...args);
124 | };
125 |
126 | const loadedModule = require.cache[extensionJsPath];
127 | if (loadedModule) {
128 | delete require.cache[extensionJsPath];
129 | const patchedModule = require(extensionJsPath);
130 | Object.assign(loadedModule.exports, patchedModule);
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # TSSLint
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | > A lightweight inspection tool that seamlessly integrates with TypeScript Language Server
10 |
11 | TSSLint is not your typical linter. Its main purpose is to expose the TypeScript Language Server diagnostic interface, allowing you to add your own diagnostic rules without additional overhead to creating a TypeChecker.
12 |
13 | Discord Server: https://discord.gg/NpdmPEUNjE
14 |
15 | Special thanks to @basarat for transferring the `tsl` package name.
16 |
17 | ## Packages
18 |
19 | This repository is a monorepo that we manage using [Lerna-Lite](https://github.com/lerna-lite/lerna-lite). That means that we actually publish several packages to npm from the same codebase, including:
20 |
21 | - [`cli`](packages/cli): This package provides the command line interface for TSSLint.
22 | - [`config`](packages/config): This package allows you to define and build configuration files for TSSLint.
23 | - [`core`](packages/core): This is the core package for TSSLint, which provides the main functionality of the tool.
24 | - [`typescript-plugin`](packages/typescript-plugin): This package integrates TSSLint with the TypeScript language server.
25 | - [`vscode`](packages/vscode): This package is a Visual Studio Code extension that integrates TSSLint into the editor.
26 |
27 | ## Why TSSLint?
28 |
29 | The performance of TypeScript in code editors has always been a crucial concern. Most TypeScript tools integrate TypeScript libraries to enable type checking and query code types through the LanguageService or TypeChecker API.
30 |
31 | However, for complex types or large codebases, the tsserver process can consume significant memory and CPU resources. When linter tools integrate with TypeScript and create their own LanguageService instances, memory and CPU usage can continue to increase. In some cases, this has caused projects to experience long save times when codeActionOnSave is enabled in VSCode.
32 |
33 | TSSLint aims to seamlessly integrate with tsserver to minimize unnecessary overhead and provide linting capabilities on top of it.
34 |
35 | ## Features
36 |
37 | - Integration with tsserver to minimize semantic linting overhead in IDEs.
38 | - Writing config in typescript.
39 | - Direct support for meta framework files based on TS Plugin without a parser. (e.g., Vue, MDX)
40 | - Pure ESM.
41 | - Designed to allow simple, direct access to rule source code without an intermediary layer.
42 |
43 | ## Usage
44 |
45 | To enable TSSLint in VSCode, follow these steps:
46 |
47 | 1. Install the [TSSLint VSCode Extension](https://marketplace.visualstudio.com/items?itemName=johnsoncodehk.vscode-tsslint)
48 | 2. Add the `@tsslint/config` dependency to your project.
49 | ```json
50 | {
51 | "devDependencies": {
52 | "@tsslint/config": "latest"
53 | }
54 | }
55 | ```
56 | 3. Create the `tsslint.config.ts` config file:
57 | ```js
58 | import { defineConfig } from '@tsslint/config';
59 |
60 | export default defineConfig({
61 | rules: {
62 | // ... your rules
63 | },
64 | });
65 | ```
66 |
67 | ### Create a Rule
68 |
69 | To create a rule, you need to define a function that receives the context of the current diagnostic task. Within this function, you can call `report()` to report an error.
70 |
71 | As an example, let's create a `no-console` rule under `[project root]/rules/`.
72 |
73 | Here's the code for `[project root]/rules/noConsoleRule.ts`:
74 |
75 | ```js
76 | import { defineRule } from '@tsslint/config';
77 |
78 | export function create() {
79 | return defineRule(({ typescript: ts, file, report }) => {
80 | ts.forEachChild(file, function cb(node) {
81 | if (
82 | ts.isPropertyAccessExpression(node) &&
83 | ts.isIdentifier(node.expression) &&
84 | node.expression.text === 'console'
85 | ) {
86 | report(
87 | `Calls to 'console.x' are not allowed.`,
88 | node.parent.getStart(file),
89 | node.parent.getEnd()
90 | ).withFix(
91 | 'Remove this console expression',
92 | () => [{
93 | fileName: file.fileName,
94 | textChanges: [{
95 | newText: '/* deleted */',
96 | span: {
97 | start: node.parent.getStart(file),
98 | length: node.parent.getWidth(file),
99 | },
100 | }],
101 | }]
102 | );
103 | }
104 | ts.forEachChild(node, cb);
105 | });
106 | });
107 | }
108 | ```
109 |
110 | Then add it to the `tsslint.config.ts` config file.
111 |
112 | ```diff
113 | import { defineConfig } from '@tsslint/config';
114 |
115 | export default defineConfig({
116 | rules: {
117 | + 'no-console': (await import('./rules/noConsoleRule.ts')).create(),
118 | },
119 | });
120 | ```
121 |
122 | After saving the config file, you will notice that `console.log` is now reporting errors in the editor. The error message will also display the specific line of code where the error occurred. Clicking on the error message will take you to line 11 in `noConsoleRule.ts`, where the `report()` code is located.
123 |
124 | > Full example: https://github.com/johnsoncodehk/tsslint/tree/master/fixtures/define-a-rule
125 |
126 | ### Modify the Error
127 |
128 | While you cannot directly configure the severity of a rule, you can modify the reported errors through the `resolveDiagnostics()` API in the config file. This allows you to customize the severity of specific rules and even add additional errors.
129 |
130 | Here's an example of changing the severity of the `no-console` rule from Warning to Error in the `tsslint.config.ts` file:
131 |
132 | ```js
133 | import { defineConfig } from '@tsslint/config';
134 | import noConsoleRule from './rules/noConsoleRule.ts';
135 |
136 | export default defineConfig({
137 | rules: {
138 | 'no-console': noConsoleRule
139 | },
140 | plugins: [
141 | ({ typescript: ts }) => ({
142 | resolveDiagnostics(file, diagnostics) {
143 | for (const diagnostic of diagnostics) {
144 | if (diagnostic.code === 'no-console') {
145 | diagnostic.category = ts.DiagnosticCategory.Error;
146 | }
147 | }
148 | return diagnostics;
149 | },
150 | }),
151 | ],
152 | });
153 | ```
154 |
155 | ## CLI Usage
156 |
157 | The `@tsslint/cli` package provides a command-line interface for running the TSSLint tool across your TypeScript projects. It can be used by running the `tsslint` command in your terminal.
158 |
159 | Here is a basic example of how to use it:
160 |
161 | ```sh
162 | npx tsslint --project path/to/your/tsconfig.json
163 | ```
164 |
165 | This command will run the linter on the TypeScript project defined by the provided `tsconfig.json` file. Any linting errors will be output to the console.
166 |
167 | If you want to automatically fix any fixable linting errors, you can use the `--fix` option:
168 |
169 | ```sh
170 | npx tsslint --project path/to/your/tsconfig.json --fix
171 | ```
172 |
173 | This will run the linter and automatically apply any fixes that are available.
174 |
175 | You can also lint multiple projects at once:
176 |
177 | ```sh
178 | npx tsslint --project packages/*/tsconfig.json
179 | npx tsslint --project {packages/pkg-a/tsconfig.json,packages/pkg-b/tsconfig.json}
180 | ```
181 |
182 | This command will run the linter on all TypeScript projects located in the subdirectories of the `packages` directory. Each subdirectory should contain a `tsconfig.json` file defining a TypeScript project. Any linting errors will be output to the console.
183 |
184 | ### Linting Different Project Types
185 |
186 | TSSLint also supports linting different types of projects, such as Vue, Vue Vine, MDX, and Astro. You can specify the project type using the relevant flags:
187 |
188 | - **Vue projects**:
189 | ```sh
190 | npx tsslint --vue-project path/to/vue/tsconfig.json
191 | ```
192 | - **Vue Vine projects**:
193 | ```sh
194 | npx tsslint --vue-vine-project path/to/vue-vine/tsconfig.json
195 | ```
196 | - **MDX projects**:
197 | ```sh
198 | npx tsslint --mdx-project path/to/mdx/tsconfig.json
199 | ```
200 | - **Astro projects**:
201 | ```sh
202 | npx tsslint --astro-project path/to/astro/tsconfig.json
203 | ```
204 | - **TS Macro projects**:
205 | ```sh
206 | npx tsslint --ts-macro-project path/to/ts-macro/tsconfig.json
207 | ```
208 |
209 | This allows flexibility in linting different project structures while maintaining the same CLI workflow.
210 |
--------------------------------------------------------------------------------
/packages/config/lib/plugins/ignore.ts:
--------------------------------------------------------------------------------
1 | import type { Plugin } from '@tsslint/types';
2 | import { forEachComment } from 'ts-api-utils';
3 | import type * as ts from 'typescript';
4 |
5 | interface CommentState {
6 | used?: boolean;
7 | commentRange: [number, number];
8 | startLine: number;
9 | endLine?: number;
10 | }
11 |
12 | export function create(
13 | cmdOption: string | [string, string],
14 | reportsUnusedComments: boolean
15 | ): Plugin {
16 | const mode = typeof cmdOption === 'string' ? 'singleLine' : 'multiLine';
17 | const [cmd, endCmd] = Array.isArray(cmdOption) ? cmdOption : [cmdOption, undefined];
18 | const cmdText = cmd.replace(/\?/g, '');
19 | const withRuleId = '[ \\t]*\\b(?\\w\\S*)?';
20 | const header = '^\\s*';
21 | const ending = '([ \\t]+[^\\r\\n]*)?$';
22 | const reg = new RegExp(`${header}${cmd}${withRuleId}${ending}`);
23 | const endReg = endCmd ? new RegExp(`${header}${endCmd}${withRuleId}${ending}`) : undefined;
24 | const completeReg1 = /^\s*\/\/(\s*)([\S]*)?$/;
25 | const completeReg2 = new RegExp(`//\\s*${cmd}(\\S*)?$`);
26 |
27 | return ({ typescript: ts, languageService }) => {
28 | const reportedRulesOfFile = new Map();
29 | const { getCompletionsAtPosition } = languageService;
30 |
31 | languageService.getCompletionsAtPosition = (fileName, position, ...rest) => {
32 | let result = getCompletionsAtPosition(fileName, position, ...rest);
33 |
34 | const sourceFile = languageService.getProgram()?.getSourceFile(fileName);
35 | if (!sourceFile) {
36 | return result;
37 | }
38 |
39 | const reportedRules = reportedRulesOfFile.get(fileName);
40 | const line = sourceFile.getLineAndCharacterOfPosition(position).line;
41 | const lineStart = sourceFile.getPositionOfLineAndCharacter(line, 0);
42 | const prefix = sourceFile.text.slice(lineStart, position);
43 | const matchCmd = completeReg1
44 | ? prefix.match(completeReg1)
45 | : undefined;
46 |
47 | if (matchCmd) {
48 | const nextLineRules = reportedRules?.filter(([, reportedLine]) => reportedLine === line + 1) ?? [];
49 | const item: ts.CompletionEntry = {
50 | name: cmdText,
51 | insertText: matchCmd[1].length ? cmdText : ` ${cmdText}`,
52 | kind: ts.ScriptElementKind.keyword,
53 | sortText: 'a',
54 | replacementSpan: matchCmd[2]
55 | ? {
56 | start: position - matchCmd[2].length,
57 | length: matchCmd[2].length,
58 | }
59 | : undefined,
60 | labelDetails: {
61 | description: nextLineRules.length >= 2
62 | ? `Ignore ${nextLineRules.length} issues in next line`
63 | : nextLineRules.length
64 | ? 'Ignore 1 issue in next line'
65 | : undefined,
66 | }
67 | };
68 | if (result) {
69 | result.entries.push(item);
70 | } else {
71 | result = {
72 | isGlobalCompletion: false,
73 | isMemberCompletion: false,
74 | isNewIdentifierLocation: false,
75 | entries: [item],
76 | };
77 | }
78 | } else if (reportedRules?.length) {
79 | const matchRule = completeReg2
80 | ? prefix.match(completeReg2)
81 | : undefined;
82 | if (matchRule) {
83 | const visited = new Set();
84 | for (const [ruleId] of reportedRules) {
85 | if (visited.has(ruleId)) {
86 | continue;
87 | }
88 | visited.add(ruleId);
89 |
90 | const reportedLines = reportedRules
91 | .filter(([r]) => r === ruleId)
92 | .map(([, l]) => l + 1);
93 | const item: ts.CompletionEntry = {
94 | name: ruleId,
95 | kind: ts.ScriptElementKind.keyword,
96 | sortText: ruleId,
97 | replacementSpan: matchRule[1]
98 | ? {
99 | start: position - matchRule[1].length,
100 | length: matchRule[1].length,
101 | }
102 | : undefined,
103 | labelDetails: {
104 | description: `Reported in line${reportedLines.length >= 2 ? 's' : ''} ${reportedLines.join(', ')}`,
105 | },
106 | };
107 | if (result) {
108 | result.entries.push(item);
109 | } else {
110 | result = {
111 | isGlobalCompletion: false,
112 | isMemberCompletion: false,
113 | isNewIdentifierLocation: false,
114 | entries: [item],
115 | };
116 | }
117 | }
118 | }
119 | }
120 |
121 | return result;
122 | };
123 |
124 | return {
125 | resolveDiagnostics(file, results) {
126 | if (
127 | !reportsUnusedComments &&
128 | !results.some(error => error.source === 'tsslint')
129 | ) {
130 | return results;
131 | }
132 | const comments = new Map();
133 | const logs: string[] = [];
134 |
135 | forEachComment(file, (fullText, { pos, end }) => {
136 | pos += 2; // Trim the // or /* characters
137 | const commentText = fullText.substring(pos, end);
138 | logs.push(commentText);
139 | const startComment = commentText.match(reg);
140 |
141 | if (startComment?.index !== undefined) {
142 | const index = startComment.index + pos;
143 | const ruleId = startComment.groups?.ruleId;
144 |
145 | if (!comments.has(ruleId)) {
146 | comments.set(ruleId, []);
147 | }
148 | const disabledLines = comments.get(ruleId)!;
149 | const line = file.getLineAndCharacterOfPosition(index).line;
150 |
151 | let startLine = line;
152 |
153 | if (mode === 'singleLine') {
154 | const startWithComment = file.text.slice(
155 | file.getPositionOfLineAndCharacter(line, 0),
156 | index - 2
157 | ).trim() === '';
158 | if (startWithComment) {
159 | startLine = line + 1; // If the comment is at the start of the line, the error is in the next line
160 | }
161 | }
162 |
163 | disabledLines.push({
164 | commentRange: [
165 | index - 2,
166 | index + startComment[0].length,
167 | ],
168 | startLine,
169 | });
170 | }
171 | else if (endReg) {
172 | const endComment = commentText.match(endReg);
173 |
174 | if (endComment?.index !== undefined) {
175 | const index = endComment.index + pos;
176 | const prevLine = file.getLineAndCharacterOfPosition(index).line;
177 | const ruleId = endComment.groups?.ruleId;
178 |
179 | const disabledLines = comments.get(ruleId);
180 | if (disabledLines) {
181 | disabledLines[disabledLines.length - 1].endLine = prevLine;
182 | }
183 | }
184 | }
185 | });
186 |
187 | let reportedRules = reportedRulesOfFile.get(file.fileName);
188 | if (!reportedRules) {
189 | reportedRules = [];
190 | reportedRulesOfFile.set(file.fileName, reportedRules);
191 | }
192 | reportedRules.length = 0;
193 |
194 | results = results.filter(error => {
195 | if (error.source !== 'tsslint') {
196 | return true;
197 | }
198 | const line = file.getLineAndCharacterOfPosition(error.start).line;
199 |
200 | reportedRules.push([error.code as any, line]);
201 |
202 | for (const code of [undefined, error.code]) {
203 | const states = comments.get(code as any);
204 | if (!states) {
205 | continue;
206 | }
207 | if (mode === 'singleLine') {
208 | for (const state of states) {
209 | if (state.startLine === line) {
210 | state.used = true;
211 | return false;
212 | }
213 | }
214 | } else {
215 | for (const state of states) {
216 | if (line >= state.startLine && line <= (state.endLine ?? Number.MAX_VALUE)) {
217 | state.used = true;
218 | return false;
219 | }
220 | }
221 | }
222 | }
223 | return true;
224 | });
225 | if (reportsUnusedComments) {
226 | for (const comment of comments.values()) {
227 | for (const state of comment.values()) {
228 | if (!state.used) {
229 | results.push({
230 | file: file,
231 | start: state.commentRange[0],
232 | length: state.commentRange[1] - state.commentRange[0],
233 | code: 'tsslint:unused-ignore-comment' as any,
234 | messageText: `Unused comment.`,
235 | source: 'tsslint',
236 | category: 1,
237 | });
238 | }
239 | }
240 | }
241 | }
242 | return results;
243 | },
244 | resolveCodeFixes(file, diagnostic, codeFixes) {
245 | if (diagnostic.source !== 'tsslint' || diagnostic.start === undefined) {
246 | return codeFixes;
247 | }
248 | const line = file.getLineAndCharacterOfPosition(diagnostic.start).line;
249 | codeFixes.push({
250 | fixName: cmd,
251 | description: `Ignore with ${cmdText}`,
252 | changes: [
253 | {
254 | fileName: file.fileName,
255 | textChanges: [{
256 | newText: reg.test(`${cmdText}${diagnostic.code}`)
257 | ? `// ${cmdText}${diagnostic.code}\n`
258 | : `// ${cmdText} ${diagnostic.code}\n`,
259 | span: {
260 | start: file.getPositionOfLineAndCharacter(line, 0),
261 | length: 0,
262 | },
263 | }],
264 | },
265 | ],
266 | });
267 | return codeFixes;
268 | },
269 | };
270 | };
271 | }
272 |
--------------------------------------------------------------------------------
/packages/eslint/lib/dtsGenerate.ts:
--------------------------------------------------------------------------------
1 | import * as fs from 'fs';
2 | import * as path from 'path';
3 |
4 | const variableNameRegex = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
5 |
6 | export async function generate(
7 | nodeModulesDirs: string[],
8 | loader = async (mod: string) => {
9 | try {
10 | return require(mod);
11 | } catch {
12 | return await import(mod);
13 | }
14 | }
15 | ) {
16 | let indentLevel = 0;
17 | let dts = '';
18 | let defId = 0;
19 |
20 | line(`export interface ESLintRulesConfig {`);
21 | indentLevel++;
22 |
23 | const visited = new Set();
24 | const defs = new Map();
25 |
26 | for (const nodeModulesDir of nodeModulesDirs) {
27 | const pkgs = readdirDirSync(nodeModulesDir);
28 |
29 | for (const pkg of pkgs) {
30 | if (pkg.startsWith('@')) {
31 | const subPkgs = readdirDirSync(path.join(nodeModulesDir, pkg));
32 | for (const subPkg of subPkgs) {
33 | if (subPkg === 'eslint-plugin' || subPkg.startsWith('eslint-plugin-')) {
34 | const pluginName = `${pkg}/${subPkg}`;
35 | let plugin = await loader(pluginName);
36 | if ('default' in plugin) {
37 | plugin = plugin.default;
38 | }
39 | if (plugin.rules) {
40 | for (const ruleName in plugin.rules) {
41 | const rule = plugin.rules[ruleName];
42 | if (subPkg === 'eslint-plugin') {
43 | addRule(pkg, ruleName, rule);
44 | } else {
45 | addRule(pkg, `${subPkg.slice('eslint-plugin-'.length)}/${ruleName}`, rule);
46 | }
47 | }
48 | }
49 | }
50 | }
51 | }
52 | else if (pkg.startsWith('eslint-plugin-')) {
53 | let plugin = await loader(pkg);
54 | if ('default' in plugin) {
55 | plugin = plugin.default;
56 | }
57 | if (plugin.rules) {
58 | const scope = pkg.replace('eslint-plugin-', '');
59 | for (const ruleName in plugin.rules) {
60 | const rule = plugin.rules[ruleName];
61 | addRule(scope, ruleName, rule);
62 | }
63 | }
64 | }
65 | else if (pkg === 'eslint') {
66 | const rulesDir = path.join(nodeModulesDir, pkg, 'lib', 'rules');
67 | const ruleFiles = fs.readdirSync(rulesDir);
68 | for (const ruleFile of ruleFiles) {
69 | if (ruleFile.endsWith('.js')) {
70 | const ruleName = ruleFile.replace('.js', '');
71 | const rule = await loader(path.join(rulesDir, ruleFile));
72 | addRule(undefined, ruleName, rule);
73 | }
74 | }
75 | }
76 | }
77 | }
78 |
79 | indentLevel--;
80 | line(`}`);
81 | line(``);
82 |
83 | for (const [typeName, typeString] of defs.values()) {
84 | line(`type ${typeName} = ${typeString};`);
85 | }
86 |
87 | return dts;
88 |
89 | function addRule(scope: string | undefined, ruleName: string, rule: any) {
90 | let ruleKey: string;
91 | if (scope) {
92 | ruleKey = `${scope}/${ruleName}`;
93 | } else {
94 | ruleKey = `${ruleName}`;
95 | }
96 |
97 | if (visited.has(ruleKey)) {
98 | return;
99 | }
100 | visited.add(ruleKey);
101 |
102 | const meta = rule.meta ?? {};
103 | const { description, url } = meta.docs ?? {};
104 | const { schema } = meta;
105 |
106 | if (description || url) {
107 | line(`/**`);
108 | if (description) {
109 | line(` * ${description.replace(/\*\//g, '* /')}`);
110 | }
111 | if (url) {
112 | line(` * @see ${url}`);
113 | }
114 | line(` */`);
115 | }
116 |
117 | let optionsType: string | undefined;
118 |
119 | if (schema) {
120 | if (Array.isArray(schema)) {
121 | const optionsTypes: string[] = [];
122 | for (const item of schema) {
123 | const itemType = parseSchema(schema, item, indentLevel);
124 | optionsTypes.push(itemType);
125 | }
126 | optionsType = `[`;
127 | optionsType += optionsTypes
128 | .map(type => `(${type})?`)
129 | .join(', ');
130 | optionsType += `]`;
131 | } else {
132 | optionsType = parseSchema(schema, schema, indentLevel);
133 | }
134 | }
135 |
136 | if (optionsType) {
137 | line(`'${ruleKey}'?: ${optionsType},`);
138 | } else {
139 | line(`'${ruleKey}'?: any[],`);
140 | }
141 | }
142 |
143 | function line(line: string) {
144 | dts += indent(indentLevel) + line + '\n';
145 | }
146 |
147 | function parseSchema(schema: any, item: any, indentLevel: number): string {
148 | if (typeof item === 'object') {
149 | if (item.$ref) {
150 | const paths = item.$ref
151 | .replace('#/items/', '#/')
152 | .split('/').slice(1);
153 | let current = schema;
154 | for (const path of paths) {
155 | try {
156 | current = current[path];
157 | } catch {
158 | current = undefined;
159 | break;
160 | }
161 | }
162 | if (current) {
163 | let resolved = defs.get(current);
164 | if (!resolved) {
165 | resolved = [`Def${defId++}_${paths[paths.length - 1]}`, parseSchema(schema, current, 0)];
166 | defs.set(current, resolved);
167 | }
168 | return resolved[0];
169 | } else {
170 | console.error(`Failed to resolve schema path: ${item.$ref}`);
171 | return 'unknown';
172 | }
173 | }
174 | else if (Array.isArray(item)) {
175 | return item.map(item => parseSchema(schema, item, indentLevel)).join(' | ');
176 | }
177 | else if (Array.isArray(item.type)) {
178 | return item.type.map((type: any) => parseSchema(schema, type, indentLevel)).join(' | ');
179 | }
180 | else if (item.properties) {
181 | let res = `{\n`;
182 | indentLevel++;
183 | const properties = item.properties;
184 | const requiredArr = item.required ?? [];
185 | for (const key in properties) {
186 | const property = properties[key];
187 | if (property.description) {
188 | res += indent(indentLevel) + `/**\n`;
189 | res += indent(indentLevel) + ` * ${property.description.replace(/\*\//g, '* /')}\n`;
190 | res += indent(indentLevel) + ` */\n`;
191 | }
192 | const propertyType = parseSchema(schema, property, indentLevel);
193 | const isRequired = requiredArr.includes(key);
194 | if (!variableNameRegex.test(key)) {
195 | res += indent(indentLevel) + `'${key}'${isRequired ? '' : '?'}: ${propertyType},\n`;
196 | } else {
197 | res += indent(indentLevel) + `${key}${isRequired ? '' : '?'}: ${propertyType},\n`;
198 | }
199 | }
200 | indentLevel--;
201 | res += indent(indentLevel) + `}`;
202 | if (item.additionalProperties) {
203 | res += ` & `;
204 | res += parseAdditionalProperties(schema, item.additionalProperties, indentLevel);
205 | }
206 | return res;
207 | }
208 | else if (Array.isArray(item.required)) {
209 | let res = `{ `;
210 | const propertiesType: string[] = [];
211 | for (const key of item.required) {
212 | const propertyType = `any`;
213 | if (!variableNameRegex.test(key)) {
214 | propertiesType.push(`'${key}': ${propertyType}`);
215 | } else {
216 | propertiesType.push(`${key}: ${propertyType}`);
217 | }
218 | }
219 | res += propertiesType.join(', ');
220 | res += ` }`;
221 | return res;
222 | }
223 | else if (item.const) {
224 | return JSON.stringify(item.const);
225 | }
226 | else if (item.type === 'array') {
227 | if (Array.isArray(item.items)) {
228 | return `[${item.items.map((item: any) => parseSchema(schema, item, indentLevel)).join(', ')}]`;
229 | }
230 | if (item.items) {
231 | return `(${parseSchema(schema, item.items, indentLevel)})[]`;
232 | }
233 | return `any[]`;
234 | }
235 | else if (item.enum) {
236 | return item.enum.map((v: any) => JSON.stringify(v)).join(' | ');
237 | }
238 | else if (item.type) {
239 | return parseSchema(schema, item.type, indentLevel);
240 | }
241 | else if (item.anyOf) {
242 | return item.anyOf.map((item: any) => parseSchema(schema, item, indentLevel)).join(' | ');
243 | }
244 | else if (item.oneOf) {
245 | return item.oneOf.map((item: any) => parseSchema(schema, item, indentLevel)).join(' | ');
246 | }
247 | }
248 | else if (item === 'string' || item === 'boolean' || item === 'null' || item === 'number') {
249 | return item;
250 | }
251 | else if (item === 'object') {
252 | if (item.additionalProperties) {
253 | return parseAdditionalProperties(schema, item.additionalProperties, indentLevel);
254 | } else {
255 | return `{ [key: string]: unknown }`;
256 | }
257 | }
258 | else if (item === 'integer') {
259 | return 'number';
260 | }
261 | else if (item === 'array') {
262 | return 'any[]';
263 | }
264 | return 'unknown';
265 | }
266 |
267 | function indent(indentLevel: number) {
268 | return '\t'.repeat(indentLevel);
269 | }
270 |
271 | function parseAdditionalProperties(schema: any, item: any, indentLevel: number) {
272 | if (item === true) {
273 | return `{ [key: string]: unknown }`;
274 | } else {
275 | return `{ [key: string]: ${parseSchema(schema, item, indentLevel)} }`;
276 | }
277 | }
278 |
279 | function readdirDirSync(_path: string): string[] {
280 | return fs.readdirSync(_path, { withFileTypes: true })
281 | .filter(dirent => {
282 | if (dirent.isDirectory()) {
283 | return true;
284 | }
285 | if (dirent.isSymbolicLink()) {
286 | const fullPath = path.join(_path, dirent.name);
287 | try {
288 | return fs.statSync(fullPath).isDirectory();
289 | } catch {
290 | return false;
291 | }
292 | }
293 | return false;
294 | })
295 | .map(dirent => dirent.name);
296 | }
297 | }
298 |
--------------------------------------------------------------------------------
/packages/cli/lib/worker.ts:
--------------------------------------------------------------------------------
1 | import ts = require('typescript');
2 | import type config = require('@tsslint/config');
3 | import core = require('@tsslint/core');
4 | import url = require('url');
5 | import fs = require('fs');
6 | import path = require('path');
7 | import worker_threads = require('worker_threads');
8 | import languagePlugins = require('./languagePlugins.js');
9 |
10 | import { createLanguage, FileMap, isCodeActionsEnabled, type Language } from '@volar/language-core';
11 | import { createProxyLanguageService, decorateLanguageServiceHost, resolveFileLanguageId } from '@volar/typescript';
12 | import { transformDiagnostic, transformFileTextChanges } from '@volar/typescript/lib/node/transform';
13 |
14 | let projectVersion = 0;
15 | let typeRootsVersion = 0;
16 | let options: ts.CompilerOptions = {};
17 | let fileNames: string[] = [];
18 | let language: Language | undefined;
19 | let linter: core.Linter;
20 | let linterLanguageService!: ts.LanguageService;
21 | let linterSyntaxOnlyLanguageService!: ts.LanguageService;
22 |
23 | const snapshots = new Map();
24 | const versions = new Map();
25 | const originalHost: ts.LanguageServiceHost = {
26 | ...ts.sys,
27 | useCaseSensitiveFileNames() {
28 | return ts.sys.useCaseSensitiveFileNames;
29 | },
30 | getProjectVersion() {
31 | return projectVersion.toString();
32 | },
33 | getTypeRootsVersion() {
34 | return typeRootsVersion;
35 | },
36 | getCompilationSettings() {
37 | return options;
38 | },
39 | getScriptFileNames() {
40 | return fileNames;
41 | },
42 | getScriptVersion(fileName) {
43 | return versions.get(fileName)?.toString() ?? '0';
44 | },
45 | getScriptSnapshot(fileName) {
46 | if (!snapshots.has(fileName)) {
47 | snapshots.set(fileName, ts.ScriptSnapshot.fromString(ts.sys.readFile(fileName)!));
48 | }
49 | return snapshots.get(fileName);
50 | },
51 | getScriptKind(fileName) {
52 | const languageId = resolveFileLanguageId(fileName);
53 | switch (languageId) {
54 | case 'javascript':
55 | return ts.ScriptKind.JS;
56 | case 'javascriptreact':
57 | return ts.ScriptKind.JSX;
58 | case 'typescript':
59 | return ts.ScriptKind.TS;
60 | case 'typescriptreact':
61 | return ts.ScriptKind.TSX;
62 | case 'json':
63 | return ts.ScriptKind.JSON;
64 | }
65 | return ts.ScriptKind.Unknown;
66 | },
67 | getDefaultLibFileName(options) {
68 | return ts.getDefaultLibFilePath(options);
69 | },
70 | };
71 | const linterHost: ts.LanguageServiceHost = { ...originalHost };
72 | const originalService = ts.createLanguageService(linterHost);
73 | const originalSyntaxOnlyService = ts.createLanguageService(linterHost, undefined, true);
74 |
75 | export function createLocal() {
76 | return {
77 | setup(...args: Parameters) {
78 | return setup(...args);
79 | },
80 | lint(...args: Parameters) {
81 | return lint(...args)[0];
82 | },
83 | hasCodeFixes(...args: Parameters) {
84 | return hasCodeFixes(...args);
85 | },
86 | hasRules(...args: Parameters) {
87 | return hasRules(...args)[0];
88 | },
89 | };
90 | }
91 |
92 | export function create() {
93 | const worker = new worker_threads.Worker(__filename);
94 | return {
95 | setup(...args: Parameters) {
96 | return sendRequest(setup, ...args);
97 | },
98 | async lint(...[fileName, fix, cache]: Parameters) {
99 | const [res, newCache] = await sendRequest(lint, fileName, fix, cache);
100 | Object.assign(cache, newCache); // Sync the cache
101 | return res;
102 | },
103 | hasCodeFixes(...args: Parameters) {
104 | return sendRequest(hasCodeFixes, ...args);
105 | },
106 | async hasRules(...[fileName, cache]: Parameters) {
107 | const [res, newCache] = await sendRequest(hasRules, fileName, cache);
108 | Object.assign(cache, newCache); // Sync the cache
109 | return res;
110 | },
111 | };
112 |
113 | function sendRequest void>(t: T, ...args: any[]) {
114 | return new Promise>>(resolve => {
115 | worker.once('message', json => {
116 | resolve(JSON.parse(json));
117 | });
118 | worker.postMessage(JSON.stringify([t.name, ...args]));
119 | });
120 | }
121 | }
122 |
123 | worker_threads.parentPort?.on('message', async json => {
124 | const data: [cmd: keyof typeof handlers, ...args: any[]] = JSON.parse(json);
125 | const result = await (handlers[data[0]] as any)(...data.slice(1));
126 | worker_threads.parentPort!.postMessage(JSON.stringify(result));
127 | });
128 |
129 | const handlers = {
130 | setup,
131 | lint,
132 | hasCodeFixes,
133 | hasRules,
134 | };
135 |
136 | async function setup(
137 | tsconfig: string,
138 | languages: string[],
139 | configFile: string,
140 | builtConfig: string,
141 | _fileNames: string[],
142 | _options: ts.CompilerOptions
143 | ) {
144 | const clack = await import('@clack/prompts');
145 |
146 | let config: config.Config | config.Config[];
147 | try {
148 | config = (await import(url.pathToFileURL(builtConfig).toString())).default;
149 | } catch (err) {
150 | if (err instanceof Error) {
151 | clack.log.error(err.stack ?? err.message);
152 | } else {
153 | clack.log.error(String(err));
154 | }
155 | return false;
156 | }
157 |
158 | for (let key in linterHost) {
159 | if (!(key in originalHost)) {
160 | // @ts-ignore
161 | delete linterHost[key];
162 | } else {
163 | // @ts-ignore
164 | linterHost[key] = originalHost[key];
165 | }
166 | }
167 | linterLanguageService = originalService;
168 | linterSyntaxOnlyLanguageService = originalSyntaxOnlyService;
169 | language = undefined;
170 |
171 | const plugins = await languagePlugins.load(tsconfig, languages);
172 | if (plugins.length) {
173 | const { getScriptSnapshot } = originalHost;
174 | language = createLanguage(
175 | [
176 | ...plugins,
177 | { getLanguageId: fileName => resolveFileLanguageId(fileName) },
178 | ],
179 | new FileMap(ts.sys.useCaseSensitiveFileNames),
180 | fileName => {
181 | const snapshot = getScriptSnapshot(fileName);
182 | if (snapshot) {
183 | language!.scripts.set(fileName, snapshot);
184 | }
185 | }
186 | );
187 | decorateLanguageServiceHost(ts, language, linterHost);
188 |
189 | const proxy = createProxyLanguageService(linterLanguageService);
190 | proxy.initialize(language);
191 | linterLanguageService = proxy.proxy;
192 |
193 | const syntaxOnly = createProxyLanguageService(linterSyntaxOnlyLanguageService);
194 | syntaxOnly.initialize(language);
195 | linterSyntaxOnlyLanguageService = syntaxOnly.proxy;
196 | }
197 |
198 | projectVersion++;
199 | typeRootsVersion++;
200 | fileNames = _fileNames;
201 | options = plugins.some(plugin => plugin.typescript?.extraFileExtensions.length)
202 | ? {
203 | ..._options,
204 | allowNonTsExtensions: true,
205 | }
206 | : _options;
207 | linter = core.createLinter(
208 | {
209 | languageService: linterLanguageService,
210 | languageServiceHost: linterHost,
211 | typescript: ts,
212 | },
213 | path.dirname(configFile),
214 | config,
215 | () => { },
216 | linterSyntaxOnlyLanguageService
217 | );
218 |
219 | return true;
220 | }
221 |
222 | function lint(fileName: string, fix: boolean, fileCache: core.FileLintCache) {
223 | let newSnapshot: ts.IScriptSnapshot | undefined;
224 | let diagnostics!: ts.DiagnosticWithLocation[];
225 | let shouldCheck = true;
226 |
227 | if (fix) {
228 | if (Object.values(fileCache[1]).some(([hasFix]) => hasFix)) {
229 | // Reset the cache if there are any fixes applied.
230 | fileCache[1] = {};
231 | fileCache[2] = {};
232 | }
233 | diagnostics = linter.lint(fileName, fileCache);
234 | shouldCheck = false;
235 |
236 | let fixes = linter
237 | .getCodeFixes(fileName, 0, Number.MAX_VALUE, diagnostics, fileCache[2])
238 | .filter(fix => fix.fixId === 'tsslint');
239 |
240 | if (language) {
241 | fixes = fixes.map(fix => {
242 | fix.changes = transformFileTextChanges(language!, fix.changes, false, isCodeActionsEnabled);
243 | return fix;
244 | });
245 | }
246 |
247 | const textChanges = core.combineCodeFixes(fileName, fixes);
248 | if (textChanges.length) {
249 | const oldSnapshot = snapshots.get(fileName)!;
250 | newSnapshot = core.applyTextChanges(oldSnapshot, textChanges);
251 | snapshots.set(fileName, newSnapshot);
252 | versions.set(fileName, (versions.get(fileName) ?? 0) + 1);
253 | projectVersion++;
254 | }
255 | }
256 |
257 | if (newSnapshot) {
258 | const newText = newSnapshot.getText(0, newSnapshot.getLength());
259 | const oldText = ts.sys.readFile(fileName);
260 | if (newText !== oldText) {
261 | ts.sys.writeFile(fileName, newSnapshot.getText(0, newSnapshot.getLength()));
262 | fileCache[0] = fs.statSync(fileName).mtimeMs;
263 | fileCache[1] = {};
264 | fileCache[2] = {};
265 | shouldCheck = true;
266 | }
267 | }
268 |
269 | if (shouldCheck) {
270 | diagnostics = linter.lint(fileName, fileCache);
271 | }
272 |
273 | if (language) {
274 | diagnostics = diagnostics
275 | .map(d => transformDiagnostic(language!, d, (originalService as any).getCurrentProgram(), false))
276 | .filter(d => !!d);
277 |
278 | diagnostics = diagnostics.map(diagnostic => ({
279 | ...diagnostic,
280 | file: {
281 | fileName: diagnostic.file.fileName,
282 | text: getFileText(diagnostic.file.fileName),
283 | } as any,
284 | relatedInformation: diagnostic.relatedInformation?.map(info => ({
285 | ...info,
286 | file: info.file ? {
287 | fileName: info.file.fileName,
288 | text: getFileText(info.file.fileName),
289 | } as any : undefined,
290 | })),
291 | }));
292 | } else {
293 | diagnostics = diagnostics.map(diagnostic => ({
294 | ...diagnostic,
295 | file: {
296 | fileName: diagnostic.file.fileName,
297 | text: diagnostic.file.text,
298 | } as any,
299 | relatedInformation: diagnostic.relatedInformation?.map(info => ({
300 | ...info,
301 | file: info.file ? {
302 | fileName: info.file.fileName,
303 | text: info.file.text,
304 | } as any : undefined,
305 | })),
306 | }));
307 | }
308 |
309 | return [diagnostics, fileCache] as const;
310 | }
311 |
312 | function getFileText(fileName: string) {
313 | return originalHost.getScriptSnapshot(fileName)!.getText(0, Number.MAX_VALUE);
314 | }
315 |
316 | function hasCodeFixes(fileName: string) {
317 | return linter.hasCodeFixes(fileName);
318 | }
319 |
320 | function hasRules(fileName: string, minimatchCache: core.FileLintCache[2]) {
321 | return [Object.keys(linter.getRules(fileName, minimatchCache)).length > 0, minimatchCache] as const;
322 | }
323 |
--------------------------------------------------------------------------------
/packages/typescript-plugin/index.ts:
--------------------------------------------------------------------------------
1 | import type { Config, LinterContext } from '@tsslint/config';
2 | import type * as ts from 'typescript';
3 |
4 | import core = require('@tsslint/core');
5 | import path = require('path');
6 | import url = require('url');
7 | import fs = require('fs');
8 | import ErrorStackParser = require('error-stack-parser');
9 |
10 | const languageServiceDecorators = new WeakMap>();
11 | const plugin: ts.server.PluginModuleFactory = modules => {
12 | const { typescript: ts } = modules;
13 | const pluginModule: ts.server.PluginModule = {
14 | create(info) {
15 | let decorator = languageServiceDecorators.get(info.project);
16 | if (!decorator) {
17 | if (info.project.projectKind === ts.server.ProjectKind.Configured) {
18 | const tsconfig = info.project.getProjectName();
19 | decorator = decorateLanguageService(ts, path.dirname(tsconfig), info);
20 | } else {
21 | decorator = decorateLanguageService(ts, info.project.getCurrentDirectory(), info);
22 | }
23 | languageServiceDecorators.set(info.project, decorator);
24 | }
25 | decorator.update();
26 | return info.languageService;
27 | },
28 | };
29 | return pluginModule;
30 | };
31 | const fsFiles = new Map();
32 |
33 | export = plugin;
34 |
35 | function decorateLanguageService(
36 | ts: typeof import('typescript'),
37 | projectRoot: string,
38 | info: ts.server.PluginCreateInfo
39 | ) {
40 | const {
41 | getSemanticDiagnostics,
42 | getCodeFixesAtPosition,
43 | getCombinedCodeFix,
44 | getApplicableRefactors,
45 | getEditsForRefactor,
46 | } = info.languageService;
47 | const projectFileNameKeys = new Set();
48 |
49 | let configFile: string | undefined;
50 | let configFileBuildContext: Awaited> | undefined;
51 | let configFileDiagnostics: Omit[] = [];
52 | let config: Config | Config[] | undefined;
53 | let linter: core.Linter | undefined;
54 |
55 | info.languageService.getSemanticDiagnostics = fileName => {
56 | let result = getSemanticDiagnostics(fileName);
57 | if (!isProjectFileName(fileName)) {
58 | return result;
59 | }
60 | if (configFileDiagnostics.length) {
61 | const file = info.languageService.getProgram()?.getSourceFile(fileName);
62 | if (file) {
63 | result = result.concat(configFileDiagnostics.map(diagnostic => ({
64 | ...diagnostic,
65 | source: 'tsslint',
66 | file,
67 | start: 0,
68 | length: 0,
69 | })));
70 | }
71 | }
72 | if (linter) {
73 | result = result.concat(linter.lint(fileName));
74 | }
75 | return result;
76 | };
77 | info.languageService.getCodeFixesAtPosition = (fileName, start, end, errorCodes, formatOptions, preferences) => {
78 | return [
79 | ...getCodeFixesAtPosition(fileName, start, end, errorCodes, formatOptions, preferences),
80 | ...linter?.getCodeFixes(fileName, start, end) ?? [],
81 | ];
82 | };
83 | info.languageService.getCombinedCodeFix = (scope, fixId, formatOptions, preferences) => {
84 | if (fixId === 'tsslint' && linter) {
85 | const fixes = linter.getCodeFixes(scope.fileName, 0, Number.MAX_VALUE).filter(fix => fix.fixId === 'tsslint');
86 | const changes = core.combineCodeFixes(scope.fileName, fixes);
87 | return {
88 | changes: [{
89 | fileName: scope.fileName,
90 | textChanges: changes,
91 | }],
92 | };
93 | }
94 | return getCombinedCodeFix(scope, fixId, formatOptions, preferences);
95 | };
96 | info.languageService.getApplicableRefactors = (fileName, positionOrRange, preferences, triggerReason, kind, includeInteractiveActions) => {
97 | const start = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.pos;
98 | const end = typeof positionOrRange === 'number' ? positionOrRange : positionOrRange.end;
99 | const refactors = linter?.getRefactors(fileName, start, end) ?? [];
100 | return [
101 | ...getApplicableRefactors(fileName, positionOrRange, preferences, triggerReason, kind, includeInteractiveActions),
102 | {
103 | actions: refactors,
104 | name: 'TSSLint',
105 | description: 'TSSLint refactor actions',
106 | },
107 | ];
108 | };
109 | info.languageService.getEditsForRefactor = (fileName, formatOptions, positionOrRange, refactorName, actionName, preferences, interactiveRefactorArguments) => {
110 | const tsslintEdits = linter?.getRefactorEdits(fileName, actionName);
111 | if (tsslintEdits) {
112 | return { edits: tsslintEdits };
113 | }
114 | return getEditsForRefactor(fileName, formatOptions, positionOrRange, refactorName, actionName, preferences, interactiveRefactorArguments);
115 | };
116 |
117 | return { update };
118 |
119 | function isProjectFileName(fileName: string) {
120 | fileName = fileName.replace(/\\/g, '/');
121 | const projectFileNames = info.languageServiceHost.getScriptFileNames();
122 | if (projectFileNames.length !== projectFileNameKeys.size) {
123 | projectFileNameKeys.clear();
124 | for (const fileName of projectFileNames) {
125 | projectFileNameKeys.add(getFileKey(fileName));
126 | }
127 | }
128 | return projectFileNameKeys.has(getFileKey(fileName));
129 | }
130 |
131 | function getFileKey(fileName: string) {
132 | return info.languageServiceHost.useCaseSensitiveFileNames?.() ? fileName : fileName.toLowerCase();
133 | }
134 |
135 | async function update() {
136 |
137 | const newConfigFile = ts.findConfigFile(projectRoot, ts.sys.fileExists, 'tsslint.config.ts');
138 |
139 | if (newConfigFile !== configFile) {
140 | configFile = newConfigFile;
141 | config = undefined;
142 | linter = undefined;
143 | configFileBuildContext?.dispose();
144 | configFileDiagnostics = [];
145 |
146 | if (!configFile) {
147 | return;
148 | }
149 |
150 | const projectContext: LinterContext = {
151 | languageServiceHost: info.languageServiceHost,
152 | languageService: info.languageService,
153 | typescript: ts,
154 | };
155 |
156 | try {
157 | configFileBuildContext = await core.watchConfig(
158 | configFile,
159 | async (builtConfig, { errors, warnings }) => {
160 | configFileDiagnostics = [...errors, ...warnings].map(error => {
161 | const diag: typeof configFileDiagnostics[number] = {
162 | category: ts.DiagnosticCategory.Message,
163 | code: error.id as any,
164 | messageText: error.text,
165 | };
166 | if (error.location) {
167 | const fileName = path.resolve(error.location.file).replace('http-url:', '');
168 | let relatedFile = (info.languageService as any).getCurrentProgram()?.getSourceFile(fileName);
169 | if (!relatedFile) {
170 | const fileText = ts.sys.readFile(error.location.file);
171 | if (fileText !== undefined) {
172 | relatedFile = ts.createSourceFile(fileName, fileText, ts.ScriptTarget.Latest, true);
173 | }
174 | }
175 | if (relatedFile) {
176 | diag.messageText = `Error building config file.`;
177 | diag.relatedInformation = [{
178 | category: ts.DiagnosticCategory.Message,
179 | code: error.id as any,
180 | messageText: error.text,
181 | file: relatedFile,
182 | start: relatedFile.getPositionOfLineAndCharacter(error.location.line - 1, error.location.column),
183 | length: error.location.lineText.length,
184 | }];
185 | }
186 | }
187 | return diag;
188 | });
189 | if (builtConfig) {
190 | try {
191 | initSourceMapSupport();
192 | const mtime = ts.sys.getModifiedTime?.(builtConfig)?.getTime() ?? Date.now();
193 | config = (await import(url.pathToFileURL(builtConfig).toString() + '?tsslint_time=' + mtime)).default;
194 | linter = core.createLinter(projectContext, path.dirname(configFile!), config!, (diag, err, stackOffset) => {
195 | const relatedInfo = createRelatedInformation(ts, err, stackOffset);
196 | if (relatedInfo) {
197 | diag.relatedInformation!.push(relatedInfo);
198 | }
199 | });
200 | } catch (err) {
201 | config = undefined;
202 | linter = undefined;
203 | const prevLength = configFileDiagnostics.length;
204 | if (err instanceof Error) {
205 | const relatedInfo = createRelatedInformation(ts, err, 0);
206 | if (relatedInfo) {
207 | configFileDiagnostics.push({
208 | category: ts.DiagnosticCategory.Message,
209 | code: 0,
210 | messageText: err.message,
211 | relatedInformation: [relatedInfo],
212 | });
213 | }
214 | }
215 | if (prevLength === configFileDiagnostics.length) {
216 | configFileDiagnostics.push({
217 | category: ts.DiagnosticCategory.Message,
218 | code: 0,
219 | messageText: String(err),
220 | });
221 | }
222 | }
223 | }
224 | info.project.refreshDiagnostics();
225 | },
226 | true,
227 | ts.sys.createHash
228 | );
229 | } catch (err) {
230 | configFileDiagnostics.push({
231 | category: ts.DiagnosticCategory.Message,
232 | code: 'config-build-error' as any,
233 | messageText: String(err),
234 | });
235 | }
236 | }
237 | }
238 | }
239 |
240 | function initSourceMapSupport() {
241 | delete require.cache[require.resolve('source-map-support')];
242 |
243 | require('source-map-support').install({
244 | retrieveFile(pathOrUrl: string) {
245 | if (pathOrUrl.includes('?tsslint_time=')) {
246 | pathOrUrl = pathOrUrl.replace(/\?tsslint_time=\d*/, '');
247 | if (pathOrUrl.includes('://')) {
248 | pathOrUrl = url.fileURLToPath(pathOrUrl);
249 | }
250 | return fs.readFileSync(pathOrUrl, 'utf8');
251 | }
252 | },
253 | });
254 | require('source-map-support').install({
255 | retrieveFile(pathOrUrl: string) {
256 | if (pathOrUrl.endsWith('.map')) {
257 | try {
258 | if (pathOrUrl.includes('://')) {
259 | pathOrUrl = url.fileURLToPath(pathOrUrl);
260 | }
261 | const contents = fs.readFileSync(pathOrUrl, 'utf8');
262 | const map = JSON.parse(contents);
263 | for (let source of map.sources) {
264 | if (!source.startsWith('./') && !source.startsWith('../')) {
265 | source = './' + source;
266 | }
267 | source = path.resolve(path.dirname(pathOrUrl), source);
268 | if (!fs.existsSync(source)) {
269 | // Fixes https://github.com/typescript-eslint/typescript-eslint/issues/9352
270 | return JSON.stringify({
271 | version: 3,
272 | sources: [],
273 | sourcesContent: [],
274 | mappings: '',
275 | names: [],
276 | });
277 | }
278 | }
279 | return contents;
280 | } catch { }
281 | }
282 | },
283 | });
284 | }
285 |
286 | function createRelatedInformation(ts: typeof import('typescript'), err: Error, stackOffset: number): ts.DiagnosticRelatedInformation | undefined {
287 | const stacks = ErrorStackParser.parse(err);
288 | if (stacks.length <= stackOffset) {
289 | return;
290 | }
291 | const stack = stacks[stackOffset];
292 | if (stack.fileName && stack.lineNumber !== undefined && stack.columnNumber !== undefined) {
293 | let fileName = stack.fileName.replace(/\\/g, '/');
294 | if (fileName.startsWith('file://')) {
295 | fileName = fileName.substring('file://'.length);
296 | }
297 | if (fileName.includes('http-url:')) {
298 | fileName = fileName.split('http-url:')[1];
299 | }
300 | const mtime = ts.sys.getModifiedTime?.(fileName)?.getTime() ?? 0;
301 | const lastMtime = fsFiles.get(fileName)?.[1];
302 | if (mtime !== lastMtime) {
303 | const text = ts.sys.readFile(fileName);
304 | fsFiles.set(
305 | fileName,
306 | [
307 | text !== undefined,
308 | mtime,
309 | ts.createSourceFile(fileName, text ?? '', ts.ScriptTarget.Latest, true)
310 | ]
311 | );
312 | }
313 | const [exist, _mtime, relatedFile] = fsFiles.get(fileName)!;
314 | let pos = 0;
315 | if (exist) {
316 | try {
317 | pos = relatedFile.getPositionOfLineAndCharacter(stack.lineNumber - 1, stack.columnNumber - 1) ?? 0;
318 | } catch { }
319 | }
320 | return {
321 | category: ts.DiagnosticCategory.Message,
322 | code: 0,
323 | file: relatedFile,
324 | start: pos,
325 | length: 0,
326 | messageText: 'at ' + (stack.functionName ?? ''),
327 | };
328 | }
329 | }
330 |
--------------------------------------------------------------------------------
/packages/core/index.ts:
--------------------------------------------------------------------------------
1 | export * from './lib/build';
2 | export * from './lib/watch';
3 |
4 | import type {
5 | Config,
6 | LinterContext,
7 | Reporter,
8 | Rule,
9 | RuleContext,
10 | Rules,
11 | } from '@tsslint/types';
12 | import type * as ts from 'typescript';
13 |
14 | import path = require('path');
15 | import minimatch = require('minimatch');
16 |
17 | export type FileLintCache = [
18 | mtime: number,
19 | lintResult: Record<
20 | /* ruleId */ string,
21 | [hasFix: boolean, diagnostics: ts.DiagnosticWithLocation[]]
22 | >,
23 | minimatchResult: Record,
24 | ];
25 |
26 | export type Linter = ReturnType;
27 |
28 | export function createLinter(
29 | ctx: LinterContext,
30 | rootDir: string,
31 | config: Config | Config[],
32 | handleError: (diag: ts.DiagnosticWithLocation, err: Error, stackOffset: number) => void,
33 | syntaxOnlyLanguageService?: ts.LanguageService & {
34 | getNonBoundSourceFile?(fileName: string): ts.SourceFile;
35 | }
36 | ) {
37 | const ts = ctx.typescript;
38 | const fileRules = new Map>();
39 | const fileConfigs = new Map();
40 | const lintResults = new Map<
41 | /* fileName */ string,
42 | [
43 | file: ts.SourceFile,
44 | diagnostic2Fixes: Map ts.FileTextChanges[];
47 | }[]>,
48 | refactors: {
49 | title: string;
50 | diagnostic: ts.DiagnosticWithLocation;
51 | getEdits: () => ts.FileTextChanges[];
52 | }[],
53 | ]
54 | >();
55 | const configs = (Array.isArray(config) ? config : [config])
56 | .map(config => ({
57 | include: config.include,
58 | exclude: config.exclude,
59 | rules: config.rules ?? {},
60 | plugins: (config.plugins ?? []).map(plugin => plugin(ctx)),
61 | }));
62 | const normalizedPath = new Map();
63 | const rule2Mode = new Map();
64 | const getNonBoundSourceFile = syntaxOnlyLanguageService?.getNonBoundSourceFile;
65 |
66 | let shouldEnableTypeAware = false;
67 |
68 | return {
69 | lint(fileName: string, cache?: FileLintCache): ts.DiagnosticWithLocation[] {
70 | let currentRuleId: string;
71 | let shouldRetry = false;
72 | let rulesContext: RuleContext;
73 |
74 | const rules = getRulesForFile(fileName, cache?.[2]);
75 | const typeAwareMode = !getNonBoundSourceFile
76 | || shouldEnableTypeAware && !Object.keys(rules).some(ruleId => !rule2Mode.has(ruleId));
77 | const token = ctx.languageServiceHost.getCancellationToken?.();
78 | const configs = getConfigsForFile(fileName, cache?.[2]);
79 |
80 | if (typeAwareMode) {
81 | const program = ctx.languageService.getProgram()!;
82 | const file = ctx.languageService.getProgram()!.getSourceFile(fileName)!;
83 | rulesContext = {
84 | ...ctx,
85 | file,
86 | sourceFile: file,
87 | program,
88 | report,
89 | reportError: report,
90 | reportWarning: report,
91 | reportSuggestion: report,
92 | };
93 | } else {
94 | const file = getNonBoundSourceFile(fileName);
95 | rulesContext = {
96 | ...ctx,
97 | languageService: syntaxOnlyLanguageService,
98 | get program(): ts.Program {
99 | throw new Error('Not supported');
100 | },
101 | file,
102 | sourceFile: file,
103 | report,
104 | reportError: report,
105 | reportWarning: report,
106 | reportSuggestion: report,
107 | };
108 | }
109 |
110 | lintResults.set(fileName, [rulesContext.file, new Map(), []]);
111 |
112 | const lintResult = lintResults.get(fileName)!;
113 |
114 | for (const [ruleId, rule] of Object.entries(rules)) {
115 | if (token?.isCancellationRequested()) {
116 | break;
117 | }
118 |
119 | currentRuleId = ruleId;
120 |
121 | const ruleCache = cache?.[1][currentRuleId];
122 | if (ruleCache) {
123 | let lintResult = lintResults.get(fileName);
124 | if (!lintResult) {
125 | lintResults.set(fileName, lintResult = [rulesContext.file, new Map(), []]);
126 | }
127 | for (const cacheDiagnostic of ruleCache[1]) {
128 | lintResult[1].set({
129 | ...cacheDiagnostic,
130 | file: rulesContext.file,
131 | relatedInformation: cacheDiagnostic.relatedInformation?.map(info => ({
132 | ...info,
133 | file: info.file ? getNonBoundSourceFile?.(info.file.fileName) : undefined,
134 | })),
135 | }, []);
136 | }
137 | if (!typeAwareMode) {
138 | rule2Mode.set(currentRuleId, false);
139 | }
140 | continue;
141 | }
142 |
143 | try {
144 | rule(rulesContext);
145 | if (!typeAwareMode) {
146 | rule2Mode.set(currentRuleId, false);
147 | }
148 | } catch (err) {
149 | if (!typeAwareMode) {
150 | // console.log(`Rule "${currentRuleId}" is type aware.`);
151 | rule2Mode.set(currentRuleId, true);
152 | shouldRetry = true;
153 | } else if (err instanceof Error) {
154 | report(err.stack ?? err.message, 0, 0, ts.DiagnosticCategory.Message, 0, err);
155 | } else {
156 | report(String(err), 0, 0, ts.DiagnosticCategory.Message, Number.MAX_VALUE);
157 | }
158 | }
159 |
160 | if (cache && !rule2Mode.get(currentRuleId)) {
161 | cache[1][currentRuleId] ??= [false, []];
162 |
163 | for (const [_, fixes] of lintResult[1]) {
164 | if (fixes.length) {
165 | cache[1][currentRuleId][0] = true;
166 | break;
167 | }
168 | }
169 | }
170 | }
171 |
172 | if (shouldRetry) {
173 | // Retry
174 | shouldEnableTypeAware = true;
175 | if (cache && Object.values(cache[1]).some(([hasFix]) => hasFix)) {
176 | cache[1] = {};
177 | cache[2] = {};
178 | }
179 | return this.lint(fileName, cache);
180 | }
181 |
182 | let diagnostics = [...lintResult[1].keys()];
183 |
184 | try {
185 | for (const { plugins } of configs) {
186 | for (const { resolveDiagnostics } of plugins) {
187 | if (resolveDiagnostics) {
188 | diagnostics = resolveDiagnostics(rulesContext.file, diagnostics);
189 | }
190 | }
191 | }
192 | } catch (error) {
193 | if (!typeAwareMode) {
194 | // Retry
195 | shouldEnableTypeAware = true;
196 | if (cache && Object.values(cache[1]).some(([hasFix]) => hasFix)) {
197 | cache[1] = {};
198 | cache[2] = {};
199 | }
200 | return this.lint(fileName, cache);
201 | }
202 | throw error;
203 | }
204 |
205 | // Remove fixes and refactors that removed by resolveDiagnostics
206 | const diagnosticSet = new Set(diagnostics);
207 | for (const diagnostic of [...lintResult[1].keys()]) {
208 | if (!diagnosticSet.has(diagnostic)) {
209 | lintResult[1].delete(diagnostic);
210 | }
211 | }
212 | lintResult[2] = lintResult[2].filter(refactor => diagnosticSet.has(refactor.diagnostic));
213 |
214 | return diagnostics;
215 |
216 | function report(message: string, start: number, end: number, category: ts.DiagnosticCategory = ts.DiagnosticCategory.Message, stackOffset: number = 1, err?: Error): Reporter {
217 | const error: ts.DiagnosticWithLocation = {
218 | category,
219 | code: currentRuleId as any,
220 | messageText: message,
221 | file: rulesContext.file,
222 | start,
223 | length: end - start,
224 | source: 'tsslint',
225 | relatedInformation: [],
226 | };
227 |
228 | if (cache && !rule2Mode.get(currentRuleId)) {
229 | cache[1][currentRuleId] ??= [false, []];
230 | cache[1][currentRuleId][1].push({
231 | ...error,
232 | file: undefined as any,
233 | relatedInformation: error.relatedInformation?.map(info => ({
234 | ...info,
235 | file: info.file ? { fileName: info.file.fileName } as any : undefined,
236 | })),
237 | });
238 | }
239 |
240 | handleError(error, err ?? new Error(), stackOffset);
241 |
242 | let lintResult = lintResults.get(fileName);
243 | if (!lintResult) {
244 | lintResults.set(fileName, lintResult = [rulesContext.file, new Map(), []]);
245 | }
246 | const diagnostic2Fixes = lintResult[1];
247 | const refactors = lintResult[2];
248 | diagnostic2Fixes.set(error, []);
249 | const fixes = diagnostic2Fixes.get(error)!;
250 |
251 | return {
252 | withDeprecated() {
253 | error.reportsDeprecated = true;
254 | return this;
255 | },
256 | withUnnecessary() {
257 | error.reportsUnnecessary = true;
258 | return this;
259 | },
260 | withFix(title, getEdits) {
261 | fixes.push(({ title, getEdits }));
262 | return this;
263 | },
264 | withRefactor(title, getEdits) {
265 | refactors.push(({
266 | diagnostic: error,
267 | title,
268 | getEdits,
269 | }));
270 | return this;
271 | },
272 | };
273 | }
274 | },
275 | hasCodeFixes(fileName: string) {
276 | const lintResult = lintResults.get(fileName);
277 | if (!lintResult) {
278 | return false;
279 | }
280 | for (const [_, fixes] of lintResult[1]) {
281 | if (fixes.length) {
282 | return true;
283 | }
284 | }
285 | return false;
286 | },
287 | getCodeFixes(
288 | fileName: string,
289 | start: number,
290 | end: number,
291 | diagnostics?: ts.Diagnostic[],
292 | minimatchCache?: FileLintCache[2]
293 | ) {
294 | const lintResult = lintResults.get(fileName);
295 | if (!lintResult) {
296 | return [];
297 | }
298 |
299 | const file = lintResult[0];
300 | const configs = getConfigsForFile(fileName, minimatchCache);
301 | const result: ts.CodeFixAction[] = [];
302 |
303 | for (const [diagnostic, actions] of lintResult[1]) {
304 | if (diagnostics?.length && !diagnostics.includes(diagnostic)) {
305 | continue;
306 | }
307 | const diagStart = diagnostic.start;
308 | const diagEnd = diagStart + diagnostic.length;
309 | if (
310 | (diagStart >= start && diagStart <= end) ||
311 | (diagEnd >= start && diagEnd <= end) ||
312 | (start >= diagStart && start <= diagEnd) ||
313 | (end >= diagStart && end <= diagEnd)
314 | ) {
315 | let codeFixes: ts.CodeFixAction[] = [];
316 | for (const action of actions) {
317 | codeFixes.push({
318 | fixName: `tsslint:${diagnostic.code}`,
319 | description: action.title,
320 | changes: action.getEdits(),
321 | fixId: 'tsslint',
322 | fixAllDescription: 'Fix all TSSLint errors'
323 | });
324 | }
325 | for (const { plugins } of configs) {
326 | for (const { resolveCodeFixes } of plugins) {
327 | if (resolveCodeFixes) {
328 | codeFixes = resolveCodeFixes(file, diagnostic, codeFixes);
329 | }
330 | }
331 | }
332 | result.push(...codeFixes);
333 | }
334 | }
335 |
336 | return result;
337 | },
338 | getRefactors(fileName: string, start: number, end: number) {
339 | const lintResult = lintResults.get(fileName);
340 | if (!lintResult) {
341 | return [];
342 | }
343 |
344 | const result: ts.RefactorActionInfo[] = [];
345 |
346 | for (let i = 0; i < lintResult[2].length; i++) {
347 | const refactor = lintResult[2][i];
348 | const diagStart = refactor.diagnostic.start;
349 | const diagEnd = diagStart + refactor.diagnostic.length;
350 | if (
351 | (diagStart >= start && diagStart <= end) ||
352 | (diagEnd >= start && diagEnd <= end) ||
353 | (start >= diagStart && start <= diagEnd) ||
354 | (end >= diagStart && end <= diagEnd)
355 | ) {
356 | result.push({
357 | name: `tsslint:${i}`,
358 | description: refactor.title,
359 | kind: 'quickfix',
360 | });
361 | }
362 | }
363 |
364 | return result;
365 | },
366 | getRefactorEdits(fileName: string, actionName: string) {
367 | if (actionName.startsWith('tsslint:')) {
368 | const lintResult = lintResults.get(fileName);
369 | if (!lintResult) {
370 | return [];
371 | }
372 | const index = actionName.slice('tsslint:'.length);
373 | const refactor = lintResult[2][Number(index)];
374 | if (refactor) {
375 | return refactor.getEdits();
376 | }
377 | }
378 | },
379 | getRules: getRulesForFile,
380 | getConfigs: getConfigsForFile,
381 | };
382 |
383 | function getRulesForFile(fileName: string, minimatchCache: undefined | FileLintCache[2]) {
384 | let rules = fileRules.get(fileName);
385 | if (!rules) {
386 | rules = {};
387 | const configs = getConfigsForFile(fileName, minimatchCache);
388 | for (const config of configs) {
389 | collectRules(rules, config.rules, []);
390 | }
391 | for (const { plugins } of configs) {
392 | for (const { resolveRules } of plugins) {
393 | if (resolveRules) {
394 | rules = resolveRules(fileName, rules);
395 | }
396 | }
397 | }
398 | fileRules.set(fileName, rules);
399 | }
400 | return rules;
401 | }
402 |
403 | function getConfigsForFile(fileName: string, minimatchCache: undefined | FileLintCache[2]) {
404 | let result = fileConfigs.get(fileName);
405 | if (!result) {
406 | result = configs.filter(({ include, exclude }) => {
407 | if (exclude?.some(_minimatch)) {
408 | return false;
409 | }
410 | if (include && !include.some(_minimatch)) {
411 | return false;
412 | }
413 | return true;
414 | });
415 | fileConfigs.set(fileName, result);
416 | }
417 | return result;
418 |
419 | function _minimatch(pattern: string) {
420 | if (minimatchCache) {
421 | if (pattern in minimatchCache) {
422 | return minimatchCache[pattern];
423 | }
424 | }
425 | let normalized = normalizedPath.get(pattern);
426 | if (!normalized) {
427 | normalized = ts.server.toNormalizedPath(path.resolve(rootDir, pattern));
428 | normalizedPath.set(pattern, normalized);
429 | }
430 | const res = minimatch.minimatch(fileName, normalized, { dot: true });
431 | if (minimatchCache) {
432 | minimatchCache[pattern] = res;
433 | }
434 | return res;
435 | }
436 | }
437 |
438 | function collectRules(record: Record, rules: Rules, paths: string[]) {
439 | for (const [path, rule] of Object.entries(rules)) {
440 | if (typeof rule === 'object') {
441 | collectRules(record, rule, [...paths, path]);
442 | continue;
443 | }
444 | record[[...paths, path].join('/')] = rule;
445 | }
446 | }
447 | }
448 |
449 | export function combineCodeFixes(fileName: string, fixes: ts.CodeFixAction[]) {
450 |
451 | const changes = fixes
452 | .map(fix => fix.changes)
453 | .flat()
454 | .filter(change => change.fileName === fileName && change.textChanges.length)
455 | .sort((a, b) => b.textChanges[0].span.start - a.textChanges[0].span.start);
456 |
457 | let lastChangeAt = Number.MAX_VALUE;
458 | let finalTextChanges: ts.TextChange[] = [];
459 |
460 | for (const change of changes) {
461 | const textChanges = [...change.textChanges].sort((a, b) => a.span.start - b.span.start);
462 | const firstChange = textChanges[0];
463 | const lastChange = textChanges[textChanges.length - 1];
464 | if (lastChangeAt >= lastChange.span.start + lastChange.span.length) {
465 | lastChangeAt = firstChange.span.start;
466 | finalTextChanges = finalTextChanges.concat(textChanges);
467 | }
468 | }
469 |
470 | return finalTextChanges;
471 | }
472 |
473 | export function applyTextChanges(baseSnapshot: ts.IScriptSnapshot, textChanges: ts.TextChange[]): ts.IScriptSnapshot {
474 | textChanges = [...textChanges].sort((a, b) => b.span.start - a.span.start);
475 | let text = baseSnapshot.getText(0, baseSnapshot.getLength());
476 | for (const change of textChanges) {
477 | text = text.slice(0, change.span.start) + change.newText + text.slice(change.span.start + change.span.length);
478 | }
479 | return {
480 | getText(start, end) {
481 | return text.substring(start, end);
482 | },
483 | getLength() {
484 | return text.length;
485 | },
486 | getChangeRange(oldSnapshot) {
487 | if (oldSnapshot === baseSnapshot) {
488 | // TODO
489 | }
490 | return undefined;
491 | },
492 | };
493 | }
494 |
--------------------------------------------------------------------------------
/packages/eslint/index.ts:
--------------------------------------------------------------------------------
1 | import type * as TSSLint from '@tsslint/types';
2 | import type * as ESLint from 'eslint';
3 | import type * as ts from 'typescript';
4 | import type { ESLintRulesConfig } from './lib/types.js';
5 |
6 | export { create as createDisableNextLinePlugin } from './lib/plugins/disableNextLine.js';
7 | export { create as createShowDocsActionPlugin } from './lib/plugins/showDocsAction.js';
8 |
9 | const estrees = new WeakMap();
14 | const noop = () => { };
15 | const plugins: Record;
17 | } | undefined>> = {};
18 | const loader = async (moduleName: string) => {
19 | let mod: {} | undefined;
20 | try {
21 | mod ??= require(moduleName);
22 | } catch { }
23 | try {
24 | mod ??= await import(moduleName);
25 | } catch { }
26 | if (mod && 'default' in mod) {
27 | return mod.default;
28 | }
29 | return mod as any;
30 | };
31 |
32 | type S = 'off' | 'error' | 'warn' | 'suggestion' | 'message' | 0 | 1 | 2 | 3 | 4;
33 | type O = S | [S, ...options: T];
34 |
35 | /**
36 | * @deprecated Use `defineRules` instead
37 | */
38 | export async function convertRules(
39 | rulesConfig: { [K in keyof ESLintRulesConfig]: O },
40 | context: Partial = {}
41 | ) {
42 | const rules: TSSLint.Rules = {};
43 | for (const [rule, severityOrOptions] of Object.entries(rulesConfig)) {
44 | let severity: S;
45 | let options: any[];
46 | if (Array.isArray(severityOrOptions)) {
47 | [severity, ...options] = severityOrOptions;
48 | }
49 | else {
50 | severity = severityOrOptions;
51 | options = [];
52 | }
53 | if (severity === 'off' || severity === 0) {
54 | rules[rule] = noop;
55 | continue;
56 | }
57 | const ruleModule = await loadRuleByKey(rule);
58 | if (!ruleModule) {
59 | throw new Error(`Failed to resolve rule "${rule}".`);
60 | }
61 | rules[rule] = convertRule(
62 | ruleModule,
63 | options,
64 | { id: rule, ...context },
65 | severity === 'warn' || severity === 1
66 | ? 0
67 | : severity === 'error' || severity === 2
68 | ? 1
69 | : severity === 'suggestion' || severity === 3
70 | ? 2
71 | : 3
72 | );
73 | }
74 | return rules;
75 | }
76 |
77 | /**
78 | * Converts an ESLint rules configuration to TSSLint rules.
79 | *
80 | * The type definitions are generated when `@tsslint/eslint` is installed.
81 | * If the type definitions become outdated, please run
82 | * `node node_modules/@tsslint/eslint/scripts/generateDts.js` to update them.
83 | */
84 | export async function defineRules(
85 | config: { [K in keyof ESLintRulesConfig]: boolean | ESLintRulesConfig[K] },
86 | context: Partial = {},
87 | category: ts.DiagnosticCategory = 3 satisfies ts.DiagnosticCategory.Message
88 | ) {
89 | const rules: TSSLint.Rules = {};
90 | for (const [rule, severityOrOptions] of Object.entries(config)) {
91 | let severity: boolean;
92 | let options: any[];
93 | if (Array.isArray(severityOrOptions)) {
94 | severity = true;
95 | options = severityOrOptions;
96 | }
97 | else {
98 | severity = severityOrOptions;
99 | options = [];
100 | }
101 | if (!severity) {
102 | rules[rule] = noop;
103 | continue;
104 | }
105 | const ruleModule = await loadRuleByKey(rule);
106 | if (!ruleModule) {
107 | throw new Error(`Failed to resolve rule "${rule}".`);
108 | }
109 | rules[rule] = convertRule(
110 | ruleModule,
111 | options,
112 | { id: rule, ...context },
113 | category,
114 | );
115 | }
116 | return rules;
117 | }
118 |
119 | function* resolveRuleKey(rule: string): Generator<[
120 | pluginName: string | undefined,
121 | ruleName: string,
122 | ]> {
123 | const slashIndex = rule.indexOf('/');
124 | if (slashIndex !== -1) {
125 | let pluginName = rule.startsWith('@')
126 | ? `${rule.slice(0, slashIndex)}/eslint-plugin`
127 | : `eslint-plugin-${rule.slice(0, slashIndex)}`;
128 | let ruleName = rule.slice(slashIndex + 1);
129 |
130 | yield [pluginName, ruleName];
131 |
132 | if (ruleName.indexOf('/') >= 0) {
133 | pluginName += `-${ruleName.slice(0, ruleName.indexOf('/'))}`;
134 | ruleName = ruleName.slice(ruleName.indexOf('/') + 1);
135 | yield [pluginName, ruleName];
136 | }
137 | }
138 | else {
139 | yield [undefined, rule];
140 | }
141 | }
142 |
143 | async function loadRuleByKey(rule: string): Promise {
144 | for (const resolved of resolveRuleKey(rule)) {
145 | const ruleModule = await loadRule(...resolved);
146 | if (ruleModule) {
147 | return ruleModule;
148 | }
149 | }
150 | }
151 |
152 | async function loadRule(pluginName: string | undefined, ruleName: string): Promise {
153 | if (pluginName) {
154 | plugins[pluginName] ??= loader(pluginName);
155 | const plugin = await plugins[pluginName];
156 | return plugin?.rules[ruleName];
157 | }
158 | try {
159 | return require(`../../eslint/lib/rules/${ruleName}.js`);
160 | } catch {
161 | return require(`./node_modules/eslint/lib/rules/${ruleName}.js`);
162 | }
163 | }
164 |
165 | export function convertRule(
166 | eslintRule: ESLint.Rule.RuleModule,
167 | options: any[] = [],
168 | context: Partial = {},
169 | category: ts.DiagnosticCategory = 3 satisfies ts.DiagnosticCategory.Message
170 | ): TSSLint.Rule {
171 | // ESLint internal scripts
172 | let createEmitter;
173 | let NodeEventGenerator;
174 | let Traverser;
175 | try {
176 | createEmitter = require('../../eslint/lib/linter/safe-emitter.js');
177 | NodeEventGenerator = require('../../eslint/lib/linter/node-event-generator.js');
178 | Traverser = require('../../eslint/lib/shared/traverser.js');
179 | } catch {
180 | createEmitter = require(require.resolve('./node_modules/eslint/lib/linter/safe-emitter.js'));
181 | NodeEventGenerator = require(require.resolve('./node_modules/eslint/lib/linter/node-event-generator.js'));
182 | Traverser = require(require.resolve('./node_modules/eslint/lib/shared/traverser.js'));
183 | }
184 |
185 | const tsslintRule: TSSLint.Rule = ({ file, languageService, languageServiceHost, report }) => {
186 | const { sourceCode, eventQueue } = getEstree(
187 | file,
188 | languageService,
189 | languageServiceHost.getCompilationSettings()
190 | );
191 | const emitter = createEmitter();
192 |
193 | if (eslintRule.meta?.defaultOptions) {
194 | for (let i = 0; i < eslintRule.meta.defaultOptions.length; i++) {
195 | options[i] ??= eslintRule.meta.defaultOptions[i];
196 | }
197 | }
198 |
199 | let currentNode: any;
200 |
201 | const cwd = languageServiceHost.getCurrentDirectory();
202 | const ruleListeners = eslintRule.create({
203 | cwd,
204 | getCwd() {
205 | return cwd;
206 | },
207 | filename: file.fileName,
208 | getFilename() {
209 | return file.fileName;
210 | },
211 | physicalFilename: file.fileName,
212 | getPhysicalFilename() {
213 | return file.fileName;
214 | },
215 | sourceCode,
216 | getSourceCode() {
217 | return sourceCode;
218 | },
219 | settings: {},
220 | parserOptions: {},
221 | languageOptions: {},
222 | parserPath: undefined,
223 | id: 'unknown',
224 | options,
225 | report(descriptor) {
226 | let message = 'message' in descriptor
227 | ? descriptor.message
228 | : getMessage(descriptor.messageId);
229 | message = message.replace(/\{\{\s*(\w+)\s*\}\}/gu, key => {
230 | return descriptor.data?.[key.slice(2, -2).trim()] ?? key;
231 | });
232 | let start = 0;
233 | let end = 0;
234 | try {
235 | if ('loc' in descriptor) {
236 | if ('line' in descriptor.loc) {
237 | start = file.getPositionOfLineAndCharacter(descriptor.loc.line - 1, descriptor.loc.column);
238 | end = start;
239 | }
240 | else {
241 | start = file.getPositionOfLineAndCharacter(descriptor.loc.start.line - 1, descriptor.loc.start.column);
242 | end = file.getPositionOfLineAndCharacter(descriptor.loc.end.line - 1, descriptor.loc.end.column);
243 | }
244 | }
245 | else if ('node' in descriptor) {
246 | if (descriptor.node.range) {
247 | start = descriptor.node.range[0];
248 | end = descriptor.node.range[1];
249 | }
250 | else if (descriptor.node.loc) {
251 | start = file.getPositionOfLineAndCharacter(descriptor.node.loc.start.line - 1, descriptor.node.loc.start.column);
252 | end = file.getPositionOfLineAndCharacter(descriptor.node.loc.end.line - 1, descriptor.node.loc.end.column);
253 | }
254 | }
255 | } catch { }
256 | const reporter = report(message, start, end, category, 2);
257 | if (descriptor.fix) {
258 | // @ts-expect-error
259 | const textChanges = getTextChanges(descriptor.fix);
260 | reporter.withFix(
261 | getTextChangeMessage(textChanges),
262 | () => [{
263 | fileName: file.fileName,
264 | textChanges,
265 | }]
266 | );
267 | }
268 | for (const suggest of descriptor.suggest ?? []) {
269 | if ('messageId' in suggest) {
270 | let message = getMessage(suggest.messageId);
271 | message = message.replace(/\{\{\s*(\w+)\s*\}\}/gu, key => {
272 | return suggest.data?.[key.slice(2, -2).trim()] ?? key;
273 | });
274 | reporter.withRefactor(
275 | message,
276 | () => [{
277 | fileName: file.fileName,
278 | // @ts-expect-error
279 | textChanges: getTextChanges(suggest.fix),
280 | }]
281 | );
282 | }
283 | else {
284 | // @ts-expect-error
285 | const textChanges = getTextChanges(suggest.fix);
286 | reporter.withRefactor(
287 | getTextChangeMessage(textChanges),
288 | () => [{
289 | fileName: file.fileName,
290 | textChanges,
291 | }]
292 | );
293 | }
294 | }
295 | },
296 | getAncestors() {
297 | return sourceCode.getAncestors(currentNode);
298 | },
299 | getDeclaredVariables(node) {
300 | return sourceCode.getDeclaredVariables(node);
301 | },
302 | getScope() {
303 | return sourceCode.getScope(currentNode);
304 | },
305 | markVariableAsUsed(name) {
306 | return sourceCode.markVariableAsUsed(name, currentNode);
307 | },
308 | ...context,
309 | });
310 |
311 | for (const selector in ruleListeners) {
312 | emitter.on(selector, ruleListeners[selector]);
313 | }
314 |
315 | const eventGenerator = new NodeEventGenerator(emitter, { visitorKeys: sourceCode.visitorKeys, fallback: Traverser.getKeys });
316 |
317 | for (const step of eventQueue) {
318 | switch (step.kind) {
319 | case 1: {
320 | try {
321 | if (step.phase === 1) {
322 | currentNode = step.target;
323 | eventGenerator.enterNode(step.target);
324 | } else {
325 | eventGenerator.leaveNode(step.target);
326 | }
327 | } catch (err) {
328 | throw err;
329 | }
330 | break;
331 | }
332 |
333 | case 2: {
334 | emitter.emit(step.target, ...step.args);
335 | break;
336 | }
337 |
338 | default:
339 | throw new Error(`Invalid traversal step found: "${step.type}".`);
340 | }
341 | }
342 |
343 | function getTextChangeMessage(textChanges: ts.TextChange[]) {
344 | if (textChanges.length === 1) {
345 | const change = textChanges[0];
346 | const originalText = file.text.substring(change.span.start, change.span.start + change.span.length);
347 | if (change.newText.length === 0) {
348 | return `Remove \`${originalText}\`.`;
349 | }
350 | else if (change.span.length === 0) {
351 | const line = file.getLineAndCharacterOfPosition(change.span.start).line;
352 | const lineStart = file.getPositionOfLineAndCharacter(line, 0);
353 | const lineText = file.text.substring(lineStart, change.span.start).trimStart();
354 | return `Insert \`${change.newText}\` after \`${lineText}\`.`;
355 | }
356 | }
357 | const changes = [...textChanges].sort((a, b) => a.span.start - b.span.start);
358 | let text = '';
359 | let newText = '';
360 | for (let i = 0; i < changes.length; i++) {
361 | const change = changes[i];
362 | text += file.text.substring(change.span.start, change.span.start + change.span.length);
363 | newText += change.newText;
364 | if (i !== changes.length - 1) {
365 | text += '…';
366 | newText += '…';
367 | }
368 | }
369 | if (text.length + newText.length <= 50) {
370 | return `Replace \`${text}\` with \`${newText}\`.`;
371 | }
372 | let removeLeft = 0;
373 | let removeRight = 0;
374 | let removedLeft = false;
375 | let removedRight = false;
376 | for (let i = 0; i < text.length && i < newText.length; i++) {
377 | if (text[i] !== newText[i]) {
378 | break;
379 | }
380 | removeLeft++;
381 | }
382 | for (let i = 0; i < text.length && i < newText.length; i++) {
383 | if (text[text.length - 1 - i] !== newText[newText.length - 1 - i]) {
384 | break;
385 | }
386 | removeRight++;
387 | }
388 | if (removeLeft > removeRight) {
389 | removedLeft = true;
390 | text = text.slice(removeLeft);
391 | newText = newText.slice(removeLeft);
392 | if (text.length + newText.length > 50) {
393 | removedRight = true;
394 | text = text.slice(0, -removeRight);
395 | newText = newText.slice(0, -removeRight);
396 | }
397 | }
398 | else {
399 | removedRight = true;
400 | text = text.slice(0, -removeRight);
401 | newText = newText.slice(0, -removeRight);
402 | if (text.length + newText.length > 50) {
403 | removedLeft = true;
404 | text = text.slice(removeLeft);
405 | newText = newText.slice(removeLeft);
406 | }
407 | }
408 | if (removedLeft) {
409 | text = '…' + text;
410 | newText = '…' + newText;
411 | }
412 | if (removedRight) {
413 | text += '…';
414 | newText += '…';
415 | }
416 | return `Replace \`${text}\` with \`${newText}\`.`;
417 | }
418 |
419 | function getTextChanges(fix: ESLint.Rule.ReportFixer | null | undefined) {
420 | const fixes = fix?.({
421 | insertTextAfter(nodeOrToken, text) {
422 | if (!nodeOrToken.loc?.end) {
423 | throw new Error('Cannot insert text after a node without a location.');
424 | }
425 | const start = file.getPositionOfLineAndCharacter(nodeOrToken.loc.end.line - 1, nodeOrToken.loc.end.column);
426 | return this.insertTextAfterRange([start, start], text);
427 | },
428 | insertTextAfterRange(range, text) {
429 | return {
430 | text,
431 | range: [range[1], range[1]],
432 | };
433 | },
434 | insertTextBefore(nodeOrToken, text) {
435 | if (!nodeOrToken.loc?.start) {
436 | throw new Error('Cannot insert text before a node without a location.');
437 | }
438 | const start = file.getPositionOfLineAndCharacter(nodeOrToken.loc.start.line - 1, nodeOrToken.loc.start.column);
439 | return this.insertTextBeforeRange([start, start], text);
440 | },
441 | insertTextBeforeRange(range, text) {
442 | return {
443 | text,
444 | range: [range[0], range[0]],
445 | };
446 | },
447 | remove(nodeOrToken) {
448 | if (!nodeOrToken.loc) {
449 | throw new Error('Cannot remove a node without a location.');
450 | }
451 | const start = file.getPositionOfLineAndCharacter(nodeOrToken.loc.start.line - 1, nodeOrToken.loc.start.column);
452 | const end = file.getPositionOfLineAndCharacter(nodeOrToken.loc.end.line - 1, nodeOrToken.loc.end.column);
453 | return this.removeRange([start, end]);
454 | },
455 | removeRange(range) {
456 | return {
457 | text: '',
458 | range,
459 | };
460 | },
461 | replaceText(nodeOrToken, text) {
462 | if (!nodeOrToken.loc) {
463 | throw new Error('Cannot replace text of a node without a location.');
464 | }
465 | const start = file.getPositionOfLineAndCharacter(nodeOrToken.loc.start.line - 1, nodeOrToken.loc.start.column);
466 | const end = file.getPositionOfLineAndCharacter(nodeOrToken.loc.end.line - 1, nodeOrToken.loc.end.column);
467 | return this.replaceTextRange([start, end], text);
468 | },
469 | replaceTextRange(range, text) {
470 | return {
471 | text,
472 | range,
473 | };
474 | },
475 | });
476 | const textChanges: ts.TextChange[] = [];
477 | if (fixes && 'text' in fixes) {
478 | textChanges.push({
479 | newText: fixes.text,
480 | span: {
481 | start: fixes.range[0],
482 | length: fixes.range[1] - fixes.range[0],
483 | },
484 | });
485 | }
486 | else if (fixes) {
487 | for (const fix of fixes) {
488 | textChanges.push({
489 | newText: fix.text,
490 | span: {
491 | start: fix.range[0],
492 | length: fix.range[1] - fix.range[0],
493 | },
494 | });
495 | }
496 | }
497 | return textChanges;
498 | }
499 |
500 | function getMessage(messageId: string) {
501 | return eslintRule.meta?.messages?.[messageId] ?? '';
502 | }
503 | };
504 | (tsslintRule as any).meta = eslintRule.meta;
505 | return tsslintRule;
506 | }
507 |
508 | function getEstree(
509 | file: ts.SourceFile,
510 | languageService: ts.LanguageService,
511 | compilationSettings: ts.CompilerOptions
512 | ) {
513 | if (!estrees.has(file)) {
514 | let program: ts.Program | undefined;
515 | let SourceCode;
516 |
517 | const Parser = require('@typescript-eslint/parser');
518 | try {
519 | SourceCode = require('../../eslint/lib/languages/js/source-code/source-code.js');
520 | } catch {
521 | SourceCode = require(require.resolve('./node_modules/eslint/lib/languages/js/source-code/source-code.js'));
522 | }
523 |
524 | const programProxy = new Proxy({} as ts.Program, {
525 | get(_target, p, receiver) {
526 | program ??= languageService.getProgram()!;
527 | return Reflect.get(program, p, receiver);
528 | },
529 | });
530 | const { ast, scopeManager, visitorKeys, services } = Parser.parseForESLint(file, {
531 | tokens: true,
532 | comment: true,
533 | loc: true,
534 | range: true,
535 | preserveNodeMaps: true,
536 | filePath: file.fileName,
537 | emitDecoratorMetadata: compilationSettings.emitDecoratorMetadata ?? false,
538 | experimentalDecorators: compilationSettings.experimentalDecorators ?? false,
539 | });
540 | const sourceCode = new SourceCode({
541 | text: file.text,
542 | ast,
543 | scopeManager,
544 | visitorKeys,
545 | parserServices: {
546 | ...services,
547 | program: programProxy,
548 | getSymbolAtLocation: (node: any) => programProxy.getTypeChecker().getSymbolAtLocation(services.esTreeNodeToTSNodeMap.get(node)),
549 | getTypeAtLocation: (node: any) => programProxy.getTypeChecker().getTypeAtLocation(services.esTreeNodeToTSNodeMap.get(node)),
550 | },
551 | });
552 | const eventQueue = sourceCode.traverse();
553 | estrees.set(file, { estree: ast, sourceCode, eventQueue });
554 | }
555 | return estrees.get(file)!;
556 | }
557 |
--------------------------------------------------------------------------------
/packages/cli/index.ts:
--------------------------------------------------------------------------------
1 | import ts = require('typescript');
2 | import path = require('path');
3 | import core = require('@tsslint/core');
4 | import cache = require('./lib/cache.js');
5 | import worker = require('./lib/worker.js');
6 | import fs = require('fs');
7 | import os = require('os');
8 | import minimatch = require('minimatch');
9 | import languagePlugins = require('./lib/languagePlugins.js');
10 |
11 | process.env.TSSLINT_CLI = '1';
12 |
13 | const _reset = '\x1b[0m';
14 | const gray = (s: string) => '\x1b[90m' + s + _reset;
15 | const red = (s: string) => '\x1b[91m' + s + _reset;
16 | const green = (s: string) => '\x1b[92m' + s + _reset;
17 | const yellow = (s: string) => '\x1b[93m' + s + _reset;
18 | const blue = (s: string) => '\x1b[94m' + s + _reset;
19 | const purple = (s: string) => '\x1b[95m' + s + _reset;
20 | const cyan = (s: string) => '\x1b[96m' + s + _reset;
21 |
22 | // https://talyian.github.io/ansicolors/
23 | const tsColor = (s: string) => '\x1b[34m' + s + _reset;
24 | const tsMacroColor = (s: string) => '\x1b[38;5;135m' + s + _reset;
25 | const vueColor = (s: string) => '\x1b[32m' + s + _reset;
26 | const vueVineColor = (s: string) => '\x1b[38;5;48m' + s + _reset;
27 | const mdxColor = (s: string) => '\x1b[33m' + s + _reset;
28 | const astroColor = (s: string) => '\x1b[38;5;209m' + s + _reset;
29 |
30 | let threads = 1;
31 |
32 | if (process.argv.includes('--threads')) {
33 | const threadsIndex = process.argv.indexOf('--threads');
34 | const threadsArg = process.argv[threadsIndex + 1];
35 | if (!threadsArg || threadsArg.startsWith('-')) {
36 | console.error(red(`Missing argument for --threads.`));
37 | process.exit(1);
38 | }
39 | threads = Math.min(os.availableParallelism(), Number(threadsArg));
40 | }
41 |
42 | class Project {
43 | worker: ReturnType | undefined;
44 | /**
45 | * file names for passing to ts language service host (getScriptFileNames)
46 | */
47 | rawFileNames: string[] = [];
48 | /**
49 | * file names after filter, for linting process
50 | */
51 | fileNames: string[] = [];
52 | options: ts.CompilerOptions = {};
53 | configFile: string | undefined;
54 | currentFileIndex = 0;
55 | builtConfig: string | undefined;
56 | cache: cache.CacheData = {};
57 |
58 | constructor(
59 | public tsconfig: string,
60 | public languages: string[]
61 | ) { }
62 |
63 | async init(
64 | // @ts-expect-error
65 | clack: typeof import('@clack/prompts'),
66 | filesFilter: string[]
67 | ) {
68 | this.configFile = ts.findConfigFile(path.dirname(this.tsconfig), ts.sys.fileExists, 'tsslint.config.ts');
69 |
70 | const labels: string[] = [];
71 |
72 | if (this.languages.length === 0) {
73 | labels.push(tsColor('TS'));
74 | } else {
75 | if (this.languages.includes('ts-macro')) {
76 | labels.push(tsMacroColor('TS Macro'));
77 | }
78 | if (this.languages.includes('vue')) {
79 | labels.push(vueColor('Vue'));
80 | }
81 | if (this.languages.includes('vue-vine')) {
82 | labels.push(vueVineColor('Vue Vine'));
83 | }
84 | if (this.languages.includes('mdx')) {
85 | labels.push(mdxColor('MDX'));
86 | }
87 | if (this.languages.includes('astro')) {
88 | labels.push(astroColor('Astro'));
89 | }
90 | }
91 |
92 | const label = labels.join(gray(' | '));
93 |
94 | if (!this.configFile) {
95 | clack.log.error(`${label} ${path.relative(process.cwd(), this.tsconfig)} ${gray('(No tsslint.config.ts found)')}`);
96 | return this;
97 | }
98 |
99 | const commonLine = await parseCommonLine(this.tsconfig, this.languages);
100 |
101 | this.rawFileNames = commonLine.fileNames;
102 | this.options = commonLine.options;
103 |
104 | if (!this.rawFileNames.length) {
105 | clack.log.message(`${label} ${gray(path.relative(process.cwd(), this.tsconfig))} ${gray('(0)')}`);
106 | return this;
107 | }
108 |
109 | if (filesFilter.length) {
110 | this.fileNames = this.rawFileNames.filter(
111 | fileName => filesFilter.some(
112 | filter => minimatch.minimatch(fileName, filter, { dot: true })
113 | )
114 | );
115 | if (!this.fileNames.length) {
116 | clack.log.message(`${label} ${gray(path.relative(process.cwd(), this.tsconfig))} ${gray('(No files left after filter)')}`);
117 | return this;
118 | }
119 | } else {
120 | this.fileNames = this.rawFileNames;
121 | }
122 |
123 | const filteredLengthDiff = this.rawFileNames.length - this.fileNames.length;
124 | clack.log.info(`${label} ${path.relative(process.cwd(), this.tsconfig)} ${gray(`(${this.fileNames.length}${filteredLengthDiff ? `, skipped ${filteredLengthDiff}` : ''})`)}`);
125 |
126 | if (!process.argv.includes('--force')) {
127 | this.cache = cache.loadCache(this.tsconfig, this.configFile, ts.sys.createHash);
128 | }
129 |
130 | return this;
131 | }
132 | }
133 |
134 | (async () => {
135 | const builtConfigs = new Map>();
136 | const clack = await import('@clack/prompts');
137 | const processFiles = new Set();
138 | const tsconfigAndLanguages = new Map();
139 | const isTTY = process.stdout.isTTY;
140 |
141 | let projects: Project[] = [];
142 | let spinner = isTTY ? clack.spinner() : undefined;
143 | let spinnerStopingWarn = false;
144 | let hasFix = false;
145 | let allFilesNum = 0;
146 | let processed = 0;
147 | let excluded = 0;
148 | let passed = 0;
149 | let errors = 0;
150 | let warnings = 0;
151 | let messages = 0;
152 | let suggestions = 0;
153 | let cached = 0;
154 |
155 | if (isTTY) {
156 | const write = process.stdout.write.bind(process.stdout);
157 | process.stdout.write = (...args) => {
158 | if (spinnerStopingWarn && typeof args[0] === 'string') {
159 | args[0] = args[0].replace('▲', yellow('▲'));
160 | }
161 | // @ts-ignore
162 | return write(...args);
163 | };
164 | }
165 |
166 | if (
167 | ![
168 | '--project',
169 | '--projects',
170 | '--vue-project',
171 | '--vue-projects',
172 | '--vue-vine-project',
173 | '--vue-vine-projects',
174 | '--mdx-project',
175 | '--mdx-projects',
176 | '--astro-project',
177 | '--astro-projects',
178 | '--ts-macro-project',
179 | '--ts-macro-projects',
180 | ].some(flag => process.argv.includes(flag))
181 | ) {
182 | const language = await clack.select({
183 | message: 'Select framework',
184 | initialValue: undefined,
185 | options: [{
186 | label: 'Vanilla JS/TS',
187 | value: undefined,
188 | }, {
189 | label: 'TS Macro',
190 | value: 'ts-macro',
191 | }, {
192 | label: 'Vue',
193 | value: 'vue',
194 | }, {
195 | label: 'Vue Vine',
196 | value: 'vue-vine',
197 | }, {
198 | label: 'MDX',
199 | value: 'mdx',
200 | }, {
201 | label: 'Astro',
202 | value: 'astro',
203 | }],
204 | });
205 |
206 | if (clack.isCancel(language)) {
207 | process.exit(1);
208 | }
209 |
210 | const tsconfigOptions = fs.globSync('**/{tsconfig.json,tsconfig.*.json,jsconfig.json}');
211 |
212 | let options = await Promise.all(
213 | tsconfigOptions.map(async tsconfigOption => {
214 | const tsconfig = require.resolve(
215 | tsconfigOption.startsWith('.') ? tsconfigOption : `./${tsconfigOption}`,
216 | { paths: [process.cwd()] }
217 | );
218 | try {
219 | const commonLine = await parseCommonLine(tsconfig, language ? [language] : []);
220 | return {
221 | label: path.relative(process.cwd(), tsconfig) + ` (${commonLine.fileNames.length})`,
222 | value: tsconfigOption,
223 | };
224 | } catch {
225 | return undefined;
226 | }
227 | })
228 | );
229 |
230 | options = options.filter(option => !!option);
231 |
232 | if (options.some(option => !option!.label.endsWith('(0)'))) {
233 | options = options.filter(option => !option!.label.endsWith('(0)'));
234 | }
235 |
236 | if (!options.length) {
237 | clack.log.error(red('No projects found.'));
238 | process.exit(1);
239 | }
240 |
241 | const selectedTsconfigs = await clack.multiselect({
242 | message: 'Select one or multiple projects',
243 | initialValues: [options[0]!.value],
244 | // @ts-expect-error
245 | options,
246 | });
247 |
248 | if (clack.isCancel(selectedTsconfigs)) {
249 | process.exit(1);
250 | }
251 |
252 | let command = 'tsslint';
253 |
254 | if (!language) {
255 | command += ' --project ' + selectedTsconfigs.join(' ');
256 | } else {
257 | command += ` --${language}-project ` + selectedTsconfigs.join(' ');
258 | }
259 |
260 | clack.log.info(`${gray('Command:')} ${purple(command)}`);
261 |
262 | for (let tsconfig of selectedTsconfigs) {
263 | tsconfig = resolvePath(tsconfig);
264 | tsconfigAndLanguages.set(tsconfig, language ? [language] : []);
265 | }
266 | } else {
267 | const options = [
268 | {
269 | projectFlags: ['--project', '--projects'],
270 | language: undefined,
271 | },
272 | {
273 | projectFlags: ['--vue-project', '--vue-projects'],
274 | language: 'vue',
275 | },
276 | {
277 | projectFlags: ['--vue-vine-project', '--vue-vine-projects'],
278 | language: 'vue-vine',
279 | },
280 | {
281 | projectFlags: ['--mdx-project', '--mdx-projects'],
282 | projectsFlag: '--mdx-projects',
283 | language: 'mdx',
284 | },
285 | {
286 | projectFlags: ['--astro-project', '--astro-projects'],
287 | language: 'astro',
288 | },
289 | {
290 | projectFlags: ['--ts-macro-project', '--ts-macro-projects'],
291 | language: 'ts-macro',
292 | },
293 | ];
294 | for (const { projectFlags, language } of options) {
295 | const projectFlag = projectFlags.find(flag => process.argv.includes(flag));
296 | if (!projectFlag) {
297 | continue;
298 | }
299 | let foundArg = false;
300 | const projectsIndex = process.argv.indexOf(projectFlag);
301 | for (let i = projectsIndex + 1; i < process.argv.length; i++) {
302 | if (process.argv[i].startsWith('-')) {
303 | break;
304 | }
305 | foundArg = true;
306 | const searchGlob = process.argv[i];
307 | const tsconfigs = fs.globSync(searchGlob);
308 | if (!tsconfigs.length) {
309 | clack.log.error(red(`No projects found for ${projectFlag} ${searchGlob}.`));
310 | process.exit(1);
311 | }
312 | for (let tsconfig of tsconfigs) {
313 | tsconfig = resolvePath(tsconfig);
314 | if (!tsconfigAndLanguages.has(tsconfig)) {
315 | tsconfigAndLanguages.set(tsconfig, []);
316 | }
317 | if (language) {
318 | tsconfigAndLanguages.get(tsconfig)!.push(language);
319 | }
320 | }
321 | }
322 | if (!foundArg) {
323 | clack.log.error(red(`Missing argument for ${projectFlag}.`));
324 | process.exit(1);
325 | }
326 | }
327 | }
328 |
329 | function normalizeFilterGlobPath(filterGlob: string, expandDirToGlob = true) {
330 | let filterPath = path.resolve(process.cwd(), filterGlob);
331 | if (expandDirToGlob && fs.existsSync(filterPath) && fs.statSync(filterPath).isDirectory()) {
332 | filterPath = path.join(filterPath, '**/*');
333 | }
334 | return ts.server.toNormalizedPath(filterPath);
335 | }
336 |
337 | const filters: string[] = [];
338 | const filterArgIndex = process.argv.indexOf('--filter');
339 | if (filterArgIndex !== -1) {
340 | const filterGlob = process.argv[filterArgIndex + 1];
341 | if (!filterGlob || filterGlob.startsWith('-')) {
342 | clack.log.error(red(`Missing argument for --filter.`));
343 | process.exit(1);
344 | }
345 | filters.push(normalizeFilterGlobPath(filterGlob));
346 | for (let i = filterArgIndex + 2; i < process.argv.length; i++) {
347 | const filterGlob = process.argv[i];
348 | if (filterGlob.startsWith('-')) {
349 | break;
350 | }
351 | filters.push(normalizeFilterGlobPath(filterGlob));
352 | }
353 | }
354 |
355 | for (const [tsconfig, languages] of tsconfigAndLanguages) {
356 | projects.push(await new Project(tsconfig, languages).init(clack, filters));
357 | }
358 |
359 | spinner?.start();
360 |
361 | projects = projects.filter(project => !!project.configFile);
362 | projects = projects.filter(project => !!project.fileNames.length);
363 | for (const project of projects) {
364 | project.builtConfig = await getBuiltConfig(project.configFile!);
365 | }
366 | projects = projects.filter(project => !!project.builtConfig);
367 | for (const project of projects) {
368 | allFilesNum += project.fileNames.length;
369 | }
370 |
371 | if (allFilesNum === 0) {
372 | (spinner?.stop ?? clack.log.message)(yellow('No input files.'));
373 | process.exit(1);
374 | }
375 |
376 | if (isTTY || threads >= 2) {
377 | await Promise.all(new Array(threads).fill(0).map(() => {
378 | return startWorker(worker.create());
379 | }));
380 | } else {
381 | await startWorker(worker.createLocal() as any);
382 | }
383 |
384 | (spinner?.stop ?? clack.log.message)(
385 | cached
386 | ? gray(`Processed ${processed} files with cache. (Use `) + cyan(`--force`) + gray(` to ignore cache.)`)
387 | : gray(`Processed ${processed} files.`)
388 | );
389 |
390 | const projectsFlag = process.argv.find(arg => arg.endsWith('-projects'));
391 | if (projectsFlag) {
392 | clack.log.warn(
393 | gray(`Please use `)
394 | + cyan(`${projectsFlag.slice(0, -1)}`)
395 | + gray(` instead of `)
396 | + cyan(`${projectsFlag}`)
397 | + gray(` starting from version 1.5.0.`)
398 | );
399 | }
400 |
401 | const data = [
402 | [passed, 'passed', green] as const,
403 | [errors, 'errors', red] as const,
404 | [warnings, 'warnings', yellow] as const,
405 | [messages, 'messages', blue] as const,
406 | [suggestions, 'suggestions', gray] as const,
407 | [excluded, 'excluded', gray] as const,
408 | ];
409 |
410 | let summary = data
411 | .filter(([count]) => count)
412 | .map(([count, label, color]) => color(`${count} ${label}`))
413 | .join(gray(' | '));
414 |
415 | if (hasFix) {
416 | summary += gray(` (Use `) + cyan(`--fix`) + gray(` to apply automatic fixes.)`);
417 | } else if (errors || warnings || messages) {
418 | summary += gray(` (No fixes available.)`);
419 | }
420 |
421 | clack.outro(summary);
422 | process.exit((errors || messages) ? 1 : 0);
423 |
424 | async function startWorker(linterWorker: ReturnType) {
425 | const unfinishedProjects = projects.filter(project => project.currentFileIndex < project.fileNames.length);
426 | if (!unfinishedProjects.length) {
427 | return;
428 | }
429 | // Select a project that does not have a worker yet
430 | const project = unfinishedProjects.find(project => !project.worker);
431 | if (!project) {
432 | return;
433 | }
434 | project.worker = linterWorker;
435 |
436 | const setupSuccess = await linterWorker.setup(
437 | project.tsconfig,
438 | project.languages,
439 | project.configFile!,
440 | project.builtConfig!,
441 | project.rawFileNames,
442 | project.options,
443 | );
444 | if (!setupSuccess) {
445 | projects = projects.filter(p => p !== project);
446 | startWorker(linterWorker);
447 | return;
448 | }
449 |
450 | while (project.currentFileIndex < project.fileNames.length) {
451 | const fileName = project.fileNames[project.currentFileIndex++];
452 | addProcessFile(fileName);
453 |
454 | const fileStat = fs.statSync(fileName, { throwIfNoEntry: false });
455 | if (!fileStat) {
456 | continue;
457 | }
458 |
459 | let fileCache = project.cache[fileName];
460 | if (fileCache) {
461 | if (fileCache[0] !== fileStat.mtimeMs) {
462 | fileCache[0] = fileStat.mtimeMs;
463 | fileCache[1] = {};
464 | fileCache[2] = {};
465 | }
466 | else {
467 | cached++;
468 | }
469 | }
470 | else {
471 | project.cache[fileName] = fileCache = [fileStat.mtimeMs, {}, {}];
472 | }
473 |
474 | const diagnostics = await linterWorker.lint(
475 | fileName,
476 | process.argv.includes('--fix'),
477 | fileCache
478 | );
479 | const formatHost: ts.FormatDiagnosticsHost = {
480 | getCurrentDirectory: ts.sys.getCurrentDirectory,
481 | getCanonicalFileName: ts.sys.useCaseSensitiveFileNames ? x => x : x => x.toLowerCase(),
482 | getNewLine: () => ts.sys.newLine,
483 | };
484 |
485 | if (diagnostics.length) {
486 | hasFix ||= await linterWorker.hasCodeFixes(fileName);
487 |
488 | for (const diagnostic of diagnostics) {
489 | hasFix ||= !!fileCache[1][diagnostic.code]?.[0];
490 |
491 | let output: string;
492 |
493 | if (diagnostic.category === ts.DiagnosticCategory.Suggestion) {
494 | output = ts.formatDiagnosticsWithColorAndContext([{
495 | ...diagnostic,
496 | category: ts.DiagnosticCategory.Message,
497 | }], formatHost);
498 | output = output.replace(/\[94mmessage/, '[90msuggestion');
499 | output = output.replace(/\[94m/g, '[90m');
500 | } else {
501 | output = ts.formatDiagnosticsWithColorAndContext([diagnostic], formatHost);
502 | }
503 |
504 | output = output.trimEnd();
505 |
506 | if (typeof diagnostic.code === 'string') {
507 | output = output.replace(`TS${diagnostic.code}`, diagnostic.code);
508 | }
509 |
510 | if (diagnostic.category === ts.DiagnosticCategory.Error) {
511 | errors++;
512 | log(output, 1);
513 | }
514 | else if (diagnostic.category === ts.DiagnosticCategory.Warning) {
515 | warnings++;
516 | log(output, 2);
517 | }
518 | else if (diagnostic.category === ts.DiagnosticCategory.Message) {
519 | messages++;
520 | log(output);
521 | }
522 | else {
523 | suggestions++;
524 | log(output);
525 | }
526 | }
527 | } else if (!(await linterWorker.hasRules(fileName, fileCache[2]))) {
528 | excluded++;
529 | } else {
530 | passed++;
531 | }
532 | processed++;
533 |
534 | removeProcessFile(
535 | fileName,
536 | project.currentFileIndex < project.fileNames.length
537 | ? project.fileNames[project.currentFileIndex]
538 | : undefined
539 | );
540 | }
541 |
542 | cache.saveCache(project.tsconfig, project.configFile!, project.cache, ts.sys.createHash);
543 |
544 | await startWorker(linterWorker);
545 | }
546 |
547 | async function getBuiltConfig(configFile: string) {
548 | if (!builtConfigs.has(configFile)) {
549 | builtConfigs.set(configFile, core.buildConfig(configFile, ts.sys.createHash, msg => spinner?.message(msg), (s, code) => log(gray(s), code)));
550 | }
551 | return await builtConfigs.get(configFile);
552 | }
553 |
554 | function addProcessFile(fileName: string) {
555 | processFiles.add(fileName);
556 | updateSpinner();
557 | }
558 |
559 | function removeProcessFile(fileName: string, nextFileName?: string) {
560 | processFiles.delete(fileName);
561 | updateSpinner(nextFileName);
562 | }
563 |
564 | function updateSpinner(nextFileName?: string) {
565 | let msg: string | undefined;
566 | if (processFiles.size === 0) {
567 | if (nextFileName) {
568 | msg = gray(`[${processed + processFiles.size}/${allFilesNum}] ${path.relative(process.cwd(), nextFileName)}`);
569 | }
570 | }
571 | else if (processFiles.size === 1) {
572 | msg = gray(`[${processed + processFiles.size}/${allFilesNum}] ${path.relative(process.cwd(), [...processFiles][0])}`);
573 | } else {
574 | msg = gray(`[${processed + processFiles.size}/${allFilesNum}] Processing ${processFiles.size} files`);
575 | }
576 | if (!spinner && isTTY) {
577 | spinner = clack.spinner();
578 | spinner.start(msg);
579 | } else {
580 | spinner?.message(msg);
581 | }
582 | }
583 |
584 | function log(msg: string, code?: number) {
585 | if (spinner) {
586 | spinnerStopingWarn = code === 2;
587 | spinner.stop(msg, code);
588 | spinnerStopingWarn = false;
589 | spinner = undefined;
590 | } else {
591 | if (code === 1) {
592 | clack.log.error(msg);
593 | } else if (code === 2) {
594 | clack.log.warn(msg);
595 | } else if (code === 3) {
596 | clack.log.message(msg);
597 | } else {
598 | clack.log.step(msg);
599 | }
600 | }
601 | }
602 |
603 | function resolvePath(p: string) {
604 | if (
605 | !path.isAbsolute(p)
606 | && !p.startsWith('./')
607 | && !p.startsWith('../')
608 | ) {
609 | p = `./${p}`;
610 | }
611 | try {
612 | return require.resolve(p, { paths: [process.cwd()] });
613 | } catch {
614 | clack.log.error(red(`No such file: ${p}`));
615 | process.exit(1);
616 | }
617 | }
618 | })();
619 |
620 | async function parseCommonLine(tsconfig: string, languages: string[]) {
621 | const jsonConfigFile = ts.readJsonConfigFile(tsconfig, ts.sys.readFile);
622 | const plugins = await languagePlugins.load(tsconfig, languages);
623 | const extraFileExtensions = plugins.flatMap(plugin => plugin.typescript?.extraFileExtensions ?? []).flat();
624 | return ts.parseJsonSourceFileConfigFileContent(jsonConfigFile, ts.sys, path.dirname(tsconfig), {}, tsconfig, undefined, extraFileExtensions);
625 | }
626 |
--------------------------------------------------------------------------------