From 23dd01b444b432df81b819a51d443823e9ea89d2 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 9 Jun 2024 00:44:56 +0800 Subject: [PATCH 1/4] feat(config): supports importing rules from http url --- README.md | 65 ++++++++++++------------ fixtures/define-a-rule/tsslint.config.ts | 3 +- fixtures/noConsoleRule.ts | 60 +++++++++++----------- packages/cli/index.ts | 15 ++++-- packages/config/index.ts | 8 +-- packages/config/lib/watch.ts | 53 +++++++++++++------ packages/typescript-plugin/index.ts | 7 +-- 7 files changed, 119 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index f0bd427d..da17d9a4 100644 --- a/README.md +++ b/README.md @@ -65,47 +65,48 @@ 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 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); + }); + }; +} ``` 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(), }, }); ``` diff --git a/fixtures/define-a-rule/tsslint.config.ts b/fixtures/define-a-rule/tsslint.config.ts index 41d87dea..1656c89a 100644 --- a/fixtures/define-a-rule/tsslint.config.ts +++ b/fixtures/define-a-rule/tsslint.config.ts @@ -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(), }, }); diff --git a/fixtures/noConsoleRule.ts b/fixtures/noConsoleRule.ts index 38d13abf..3b3cebbf 100644 --- a/fixtures/noConsoleRule.ts +++ b/fixtures/noConsoleRule.ts @@ -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 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); + }); + }; +} diff --git a/packages/cli/index.ts b/packages/cli/index.ts index 79b708e3..09451848 100644 --- a/packages/cli/index.ts +++ b/packages/cli/index.ts @@ -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'); @@ -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) { diff --git a/packages/config/index.ts b/packages/config/index.ts index 2bdd5190..0ad44052 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -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; -} diff --git a/packages/config/lib/watch.ts b/packages/config/lib/watch.ts index c4c4e0b9..edc7151d 100644 --- a/packages/config/lib/watch.ts +++ b/packages/config/lib/watch.ts @@ -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( @@ -8,21 +9,17 @@ 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); } } @@ -33,15 +30,18 @@ export async function watchConfigFile( 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 }) => { + return { path, 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, @@ -50,6 +50,27 @@ export async function watchConfigFile( } return {}; }); + build.onLoad({ filter: /.*/, namespace: 'http-url' }, async ({ path }) => { + const cacheDir = _path.resolve(outDir, 'http_resources'); + const cachePath = _path.join(cacheDir, createHash(path)); + 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); } diff --git a/packages/typescript-plugin/index.ts b/packages/typescript-plugin/index.ts index 9bee31b5..17f97c15 100644 --- a/packages/typescript-plugin/index.ts +++ b/packages/typescript-plugin/index.ts @@ -1,4 +1,5 @@ -import type { Config, ProjectContext, watchConfigFile } from '@tsslint/config'; +import type { Config, ProjectContext } from '@tsslint/config'; +import type { watchConfigFile } from '@tsslint/config/lib/watch'; import { Linter, createLinter, combineCodeFixes } from '@tsslint/core'; import * as path from 'path'; import type * as ts from 'typescript'; @@ -157,7 +158,7 @@ function decorateLanguageService( let configImportPath: string | undefined; try { - configImportPath = require.resolve('@tsslint/config', { paths: [configFile] }); + configImportPath = require.resolve('@tsslint/config/lib/watch', { paths: [path.dirname(configFile)] }); } catch (err) { configFileDiagnostics = [{ category: ts.DiagnosticCategory.Error, @@ -170,7 +171,7 @@ function decorateLanguageService( return; } - const { watchConfigFile }: typeof import('@tsslint/config') = require(configImportPath); + const { watchConfigFile }: typeof import('@tsslint/config/lib/watch') = require(configImportPath); const projectContext: ProjectContext = { configFile, tsconfig, From 42619b9a5172010288e93bf3b642c438a3132aee Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 9 Jun 2024 00:52:07 +0800 Subject: [PATCH 2/4] Update tsslint.config.ts --- fixtures/define-a-plugin/tsslint.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/fixtures/define-a-plugin/tsslint.config.ts b/fixtures/define-a-plugin/tsslint.config.ts index d43f8cb4..49e175cf 100644 --- a/fixtures/define-a-plugin/tsslint.config.ts +++ b/fixtures/define-a-plugin/tsslint.config.ts @@ -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; }, }), From 58455aa40fbc04e568729826374db23b1f3dfd35 Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 9 Jun 2024 01:02:54 +0800 Subject: [PATCH 3/4] Update README.md --- README.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index da17d9a4..b94b2f51 100644 --- a/README.md +++ b/README.md @@ -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 From 19abda7f0fc88f0daa5f3b0b759062059eaf172f Mon Sep 17 00:00:00 2001 From: Johnson Chu Date: Sun, 9 Jun 2024 01:42:28 +0800 Subject: [PATCH 4/4] Add fixture --- README.md | 22 ++++++++++++++++++++-- fixtures/http-import/fixture.ts | 1 + fixtures/http-import/tsconfig.json | 3 +++ fixtures/http-import/tsslint.config.ts | 8 ++++++++ fixtures/noConsoleRule.ts | 4 ++-- packages/config/lib/watch.ts | 11 +++++++---- packages/core/index.ts | 10 ++++++++-- 7 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 fixtures/http-import/fixture.ts create mode 100644 fixtures/http-import/tsconfig.json create mode 100644 fixtures/http-import/tsslint.config.ts diff --git a/README.md b/README.md index b94b2f51..5edb7dc6 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ import type { Rule } from '@tsslint/config'; export function create(): Rule { return ({ typescript: ts, sourceFile, reportWarning }) => { - ts.forEachChild(sourceFile, function walk(node) { + ts.forEachChild(sourceFile, function visit(node) { if ( ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && @@ -96,7 +96,7 @@ export function create(): Rule { }] ); } - ts.forEachChild(node, walk); + ts.forEachChild(node, visit); }); }; } @@ -116,6 +116,24 @@ export default defineConfig({ 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. diff --git a/fixtures/http-import/fixture.ts b/fixtures/http-import/fixture.ts new file mode 100644 index 00000000..21275ae4 --- /dev/null +++ b/fixtures/http-import/fixture.ts @@ -0,0 +1 @@ +alert(); diff --git a/fixtures/http-import/tsconfig.json b/fixtures/http-import/tsconfig.json new file mode 100644 index 00000000..57aa947c --- /dev/null +++ b/fixtures/http-import/tsconfig.json @@ -0,0 +1,3 @@ +{ + "include": [ "fixture.ts" ], +} diff --git a/fixtures/http-import/tsslint.config.ts b/fixtures/http-import/tsslint.config.ts new file mode 100644 index 00000000..dc398e7e --- /dev/null +++ b/fixtures/http-import/tsslint.config.ts @@ -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(), + }, +}); diff --git a/fixtures/noConsoleRule.ts b/fixtures/noConsoleRule.ts index 3b3cebbf..997a5564 100644 --- a/fixtures/noConsoleRule.ts +++ b/fixtures/noConsoleRule.ts @@ -2,7 +2,7 @@ import type { Rule } from '@tsslint/config'; export function create(): Rule { return ({ typescript: ts, sourceFile, reportWarning }) => { - ts.forEachChild(sourceFile, function walk(node) { + ts.forEachChild(sourceFile, function visit(node) { if ( ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && @@ -26,7 +26,7 @@ export function create(): Rule { }] ); } - ts.forEachChild(node, walk); + ts.forEachChild(node, visit); }); }; } diff --git a/packages/config/lib/watch.ts b/packages/config/lib/watch.ts index edc7151d..4c4a9c2f 100644 --- a/packages/config/lib/watch.ts +++ b/packages/config/lib/watch.ts @@ -25,6 +25,8 @@ export async function watchConfigFile( } onBuild(config, result); }; + const cacheDir = _path.resolve(outDir, 'http_resources'); + const cachePathToOriginalPath = new Map(); const ctx = await esbuild.context({ entryPoints: [configFilePath], bundle: true, @@ -36,7 +38,9 @@ export async function watchConfigFile( name: 'tsslint', setup(build) { build.onResolve({ filter: /^https?:\/\// }, ({ path }) => { - return { path, namespace: 'http-url' }; + 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')) { @@ -50,9 +54,8 @@ export async function watchConfigFile( } return {}; }); - build.onLoad({ filter: /.*/, namespace: 'http-url' }, async ({ path }) => { - const cacheDir = _path.resolve(outDir, 'http_resources'); - const cachePath = _path.join(cacheDir, createHash(path)); + build.onLoad({ filter: /.*/, namespace: 'http-url' }, async ({ path: cachePath }) => { + const path = cachePathToOriginalPath.get(cachePath)!; if (fs.existsSync(cachePath)) { return { contents: fs.readFileSync(cachePath, 'utf8'), diff --git a/packages/core/index.ts b/packages/core/index.ts index e556abf7..c51d3b59 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -99,6 +99,9 @@ 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( @@ -106,8 +109,11 @@ export function createLinter(ctx: ProjectContext, config: Config, withStack: boo 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,