Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 57 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,12 @@ TSSLint aims to seamlessly integrate with tsserver to minimize unnecessary overh

## Features

- Integration with tsserver to minimize semantic linting overhead in IDEs
- Writing config in typescript
- Direct support for meta framework files based on TS Plugin without a parser (e.g., Vue)
- Integration with tsserver to minimize semantic linting overhead in IDEs.
- Writing config in typescript.
- Direct support for meta framework files based on TS Plugin without a parser. (e.g., Vue)
- Pure ESM.
- Supports HTTP URL import, no need to add dependencies in package.json.
- Designed to allow simple, direct access to rule source code without an intermediary layer.

## Usage

Expand Down Expand Up @@ -65,53 +68,72 @@ As an example, let's create a `no-console` rule under `[project root]/rules/`.
Here's the code for `[project root]/rules/noConsoleRule.ts`:

```js
import { defineRule } from '@tsslint/config';

export default defineRule(({ typescript: ts, sourceFile, reportWarning }) => {
ts.forEachChild(sourceFile, function walk(node) {
if (
ts.isPropertyAccessExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'console'
) {
reportWarning(
`Calls to 'console.x' are not allowed.`,
node.parent.getStart(sourceFile),
node.parent.getEnd()
).withFix(
'Remove this console expression',
() => [{
fileName: sourceFile.fileName,
textChanges: [{
newText: '/* deleted */',
span: {
start: node.parent.getStart(sourceFile),
length: node.parent.getEnd() - node.parent.getStart(sourceFile),
},
}],
}]
);
}
ts.forEachChild(node, walk);
});
});
import type { Rule } from '@tsslint/config';

export function create(): Rule {
return ({ typescript: ts, sourceFile, reportWarning }) => {
ts.forEachChild(sourceFile, function visit(node) {
if (
ts.isPropertyAccessExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'console'
) {
reportWarning(
`Calls to 'console.x' are not allowed.`,
node.parent.getStart(sourceFile),
node.parent.getEnd()
).withFix(
'Remove this console expression',
() => [{
fileName: sourceFile.fileName,
textChanges: [{
newText: '/* deleted */',
span: {
start: node.parent.getStart(sourceFile),
length: node.parent.getEnd() - node.parent.getStart(sourceFile),
},
}],
}]
);
}
ts.forEachChild(node, visit);
});
};
}
```

Then add it to the `tsslint.config.ts` config file.

```diff
import { defineConfig } from '@tsslint/config';
+ import noConsoleRule from './rules/noConsoleRule.ts';

export default defineConfig({
rules: {
+ 'no-console': noConsoleRule
+ 'no-console': (await import('./rules/noConsoleRule.ts')).create(),
},
});
```

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 `reportWarning()` code is located.

### Import Rules from HTTP URL

You can directly import rules from other repositories using HTTP URLs. This allows you to easily share and reuse rules across different projects.

Here's an example of how to import a rule from a HTTP URL:

```diff
import { defineConfig } from '@tsslint/config';

export default defineConfig({
rules: {
'no-console': (await import('./rules/noConsoleRule.ts')).create(),
+ 'no-alert': (await import('https://gist.githubusercontent.com/johnsoncodehk/55a4c45a5a35fc30b83de20507fb2bdc/raw/5f9c9a67ace76c0a77995fd71c3fb4fb504a40c8/TSSLint_noAlertRule.ts')).create(),
},
});

In this example, the `no-alert` rule is imported from a file hosted on GitHub. After saving the config file, you will notice that `alert()` calls are now reporting errors in the editor.

### Modify the Error

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.
Expand Down
4 changes: 2 additions & 2 deletions fixtures/define-a-plugin/tsslint.config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { defineConfig } from '@tsslint/config';
import noConsoleRule from '../noConsoleRule';
import { create as createNoConsoleRule } from '../noConsoleRule';

export default defineConfig({
plugins: [
() => ({
resolveRules(rules) {
rules['no-console'] = noConsoleRule;
rules['no-console'] = createNoConsoleRule();
return rules;
},
}),
Expand Down
3 changes: 1 addition & 2 deletions fixtures/define-a-rule/tsslint.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { defineConfig } from '@tsslint/config';
import noConsoleRule from '../noConsoleRule';

export default defineConfig({
debug: true,
rules: {
'no-console': noConsoleRule,
'no-console': (await import('../noConsoleRule.ts')).create(),
},
});
1 change: 1 addition & 0 deletions fixtures/http-import/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
alert();
3 changes: 3 additions & 0 deletions fixtures/http-import/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"include": [ "fixture.ts" ],
}
8 changes: 8 additions & 0 deletions fixtures/http-import/tsslint.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from '@tsslint/config';

export default defineConfig({
debug: true,
rules: {
'no-alert': (await import('https://gist.githubusercontent.com/johnsoncodehk/55a4c45a5a35fc30b83de20507fb2bdc/raw/5f9c9a67ace76c0a77995fd71c3fb4fb504a40c8/TSSLint_noAlertRule.ts')).create(),
},
});
60 changes: 31 additions & 29 deletions fixtures/noConsoleRule.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,32 @@
import { defineRule } from '@tsslint/config';
import type { Rule } from '@tsslint/config';

export default defineRule(({ typescript: ts, sourceFile, reportWarning }) => {
ts.forEachChild(sourceFile, function walk(node) {
if (
ts.isPropertyAccessExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'console'
) {
reportWarning(
`Calls to 'console.x' are not allowed.`,
node.parent.getStart(sourceFile),
node.parent.getEnd()
).withFix(
`Remove 'console.${node.name.text}'`,
() => [{
fileName: sourceFile.fileName,
textChanges: [{
newText: '/* deleted */',
span: {
start: node.parent.getStart(sourceFile),
length: node.parent.getEnd() - node.parent.getStart(sourceFile),
},
}],
}]
);
}
ts.forEachChild(node, walk);
});
});
export function create(): Rule {
return ({ typescript: ts, sourceFile, reportWarning }) => {
ts.forEachChild(sourceFile, function visit(node) {
if (
ts.isPropertyAccessExpression(node) &&
ts.isIdentifier(node.expression) &&
node.expression.text === 'console'
) {
reportWarning(
`Calls to 'console.x' are not allowed.`,
node.parent.getStart(sourceFile),
node.parent.getEnd()
).withFix(
`Remove 'console.${node.name.text}'`,
() => [{
fileName: sourceFile.fileName,
textChanges: [{
newText: '/* deleted */',
span: {
start: node.parent.getStart(sourceFile),
length: node.parent.getEnd() - node.parent.getStart(sourceFile),
},
}],
}]
);
}
ts.forEachChild(node, visit);
});
};
}
15 changes: 12 additions & 3 deletions packages/cli/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import ts = require('typescript');
import path = require('path');
import config = require('@tsslint/config');
import type config = require('@tsslint/config');
import build = require('@tsslint/config/lib/build');
import core = require('@tsslint/core');
import glob = require('glob');

Expand Down Expand Up @@ -87,9 +88,17 @@ import glob = require('glob');
}

if (!configs.has(configFile)) {
configs.set(configFile, await config.buildConfigFile(configFile, ts.sys.createHash));
try {
configs.set(configFile, await build.buildConfigFile(configFile, ts.sys.createHash));
} catch (err) {
configs.set(configFile, undefined);
console.error(err);
}
}
const tsslintConfig = configs.get(configFile);
if (!tsslintConfig) {
return;
}
const tsslintConfig = configs.get(configFile)!;

parsed = parseCommonLine(tsconfig);
if (!parsed.fileNames) {
Expand Down
8 changes: 1 addition & 7 deletions packages/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
export * from './lib/build';
export * from './lib/watch';
export * from './lib/types';

import type { Config, Rule } from './lib/types';
import type { Config } from './lib/types';

export function defineConfig(config: Config) {
return config;
}

export function defineRule(rule: Rule) {
return rule;
}
56 changes: 40 additions & 16 deletions packages/config/lib/watch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import esbuild = require('esbuild');
import path = require('path');
import _path = require('path');
import fs = require('fs');
import type { Config } from './types';

export async function watchConfigFile(
Expand All @@ -8,40 +9,43 @@ export async function watchConfigFile(
watch = true,
createHash: (path: string) => string = btoa,
) {
const outDir = path.resolve(
__dirname,
'..',
'..',
'.tsslint',
);
const outFileName = createHash(path.relative(outDir, configFilePath)) + '.cjs';
const outFile = path.join(outDir, outFileName);
const resultHandler = (result: esbuild.BuildResult) => {
const outDir = _path.resolve(configFilePath, '..', 'node_modules', '.tsslint');
const outFileName = createHash(_path.relative(outDir, configFilePath)) + '.mjs';
const outFile = _path.join(outDir, outFileName);
const resultHandler = async (result: esbuild.BuildResult) => {
let config: Config | undefined;
if (!result.errors.length) {
try {
config = require(outFile).default;
delete require.cache[outFile!];
config = (await import(outFile)).default;
delete require.cache[outFile];
} catch (e) {
debugger;
result.errors.push({ text: String(e) } as any);
}
}
onBuild(config, result);
};
const cacheDir = _path.resolve(outDir, 'http_resources');
const cachePathToOriginalPath = new Map<string, string>();
const ctx = await esbuild.context({
entryPoints: [configFilePath],
bundle: true,
sourcemap: true,
outfile: outFile,
format: 'cjs',
format: 'esm',
platform: 'node',
plugins: [{
name: 'tsslint',
setup(build) {
build.onResolve({ filter: /.*/ }, args => {
if (!args.path.endsWith('.ts')) {
build.onResolve({ filter: /^https?:\/\// }, ({ path }) => {
const cachePath = _path.join(cacheDir, createHash(path));
cachePathToOriginalPath.set(cachePath, path);
return { path: cachePath, namespace: 'http-url' };
});
build.onResolve({ filter: /.*/ }, ({ path, resolveDir }) => {
if (!path.endsWith('.ts')) {
try {
const jsPath = require.resolve(args.path, { paths: [args.resolveDir] });
const jsPath = require.resolve(path, { paths: [resolveDir] });
return {
path: jsPath,
external: true,
Expand All @@ -50,6 +54,26 @@ export async function watchConfigFile(
}
return {};
});
build.onLoad({ filter: /.*/, namespace: 'http-url' }, async ({ path: cachePath }) => {
const path = cachePathToOriginalPath.get(cachePath)!;
if (fs.existsSync(cachePath)) {
return {
contents: fs.readFileSync(cachePath, 'utf8'),
loader: 'ts',
};
}
const response = await fetch(path);
if (!response.ok) {
throw new Error(`Failed to load ${path}`);
}
const text = await response.text();
fs.mkdirSync(cacheDir, { recursive: true });
fs.writeFileSync(cachePath, text, 'utf8');
return {
contents: text,
loader: path.substring(path.lastIndexOf('.') + 1) as 'ts' | 'js',
};
});
if (watch) {
build.onEnd(resultHandler);
}
Expand Down
10 changes: 8 additions & 2 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,21 @@ export function createLinter(ctx: ProjectContext, config: Config, withStack: boo
if (fileName.startsWith('file://')) {
fileName = fileName.substring('file://'.length);
}
if (fileName.includes('http-url:')) {
fileName = fileName.split('http-url:')[1];
}
if (!sourceFiles.has(fileName)) {
const text = ctx.languageServiceHost.readFile(fileName) ?? '';
sourceFiles.set(
fileName,
ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true),
);
}
const stackFile = sourceFiles.get(fileName)!;
const pos = stackFile?.getPositionOfLineAndCharacter(stack.lineNumber - 1, stack.columnNumber - 1);
const stackFile = sourceFiles.get(fileName);
let pos = 0;
try {
pos = stackFile?.getPositionOfLineAndCharacter(stack.lineNumber - 1, stack.columnNumber - 1) ?? 0;
} catch { }
if (withStack) {
error.relatedInformation?.push({
category: ts.DiagnosticCategory.Message,
Expand Down
Loading