diff --git a/.eslintignore b/.eslintignore index d55c57aca..a46f06ecc 100644 --- a/.eslintignore +++ b/.eslintignore @@ -7,4 +7,5 @@ !/.vscode !/.github /prettier-playground -/tests/fixtures/rules/indent/invalid/ts \ No newline at end of file +/tests/fixtures/rules/indent/invalid/ts +/tests/fixtures/rules/valid-compile/valid/ts diff --git a/README.md b/README.md index c2a1c6753..2d07a1f9f 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,7 @@ These rules relate to possible syntax or logic errors in Svelte code: | [@ota-meshi/svelte/no-dupe-else-if-blocks](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-dupe-else-if-blocks.html) | disallow duplicate conditions in `{#if}` / `{:else if}` chains | :star: | | [@ota-meshi/svelte/no-not-function-handler](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-not-function-handler.html) | disallow use of not function in event handler | :star: | | [@ota-meshi/svelte/no-object-in-text-mustaches](https://ota-meshi.github.io/eslint-plugin-svelte/rules/no-object-in-text-mustaches.html) | disallow objects in text mustache interpolation | :star: | +| [@ota-meshi/svelte/valid-compile](https://ota-meshi.github.io/eslint-plugin-svelte/rules/valid-compile.html) | disallow warnings when compiling. | | ## Security Vulnerability diff --git a/docs/rules/README.md b/docs/rules/README.md index edd61c36b..c6f047160 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -18,6 +18,7 @@ These rules relate to possible syntax or logic errors in Svelte code: | [@ota-meshi/svelte/no-dupe-else-if-blocks](./no-dupe-else-if-blocks.md) | disallow duplicate conditions in `{#if}` / `{:else if}` chains | :star: | | [@ota-meshi/svelte/no-not-function-handler](./no-not-function-handler.md) | disallow use of not function in event handler | :star: | | [@ota-meshi/svelte/no-object-in-text-mustaches](./no-object-in-text-mustaches.md) | disallow objects in text mustache interpolation | :star: | +| [@ota-meshi/svelte/valid-compile](./valid-compile.md) | disallow warnings when compiling. | | ## Security Vulnerability diff --git a/docs/rules/valid-compile.md b/docs/rules/valid-compile.md new file mode 100644 index 000000000..8f592862b --- /dev/null +++ b/docs/rules/valid-compile.md @@ -0,0 +1,46 @@ +--- +pageClass: "rule-details" +sidebarDepth: 0 +title: "@ota-meshi/svelte/valid-compile" +description: "disallow warnings when compiling." +--- + +# @ota-meshi/svelte/valid-compile + +> disallow warnings when compiling. + +- :exclamation: **_This rule has not been released yet._** + +## :book: Rule Details + +This rule uses Svelte compiler to check the source code. + + + + + +```svelte + + + +Rick Astley dances. + + + +``` + + + +Note that we exclude reports for some checks, such as `missing-declaration`, which you can check with different ESLint rules. + +## :wrench: Options + +Nothing. + +## :mag: Implementation + +- [Rule source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/src/rules/valid-compile.ts) +- [Test source](https://github.com/ota-meshi/eslint-plugin-svelte/blob/main/tests/src/rules/valid-compile.ts) diff --git a/package.json b/package.json index 36e9fbb52..04c782329 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "dependencies": { "debug": "^4.3.1", "eslint-utils": "^3.0.0", + "sourcemap-codec": "^1.4.8", "svelte-eslint-parser": "^0.4.0" }, "peerDependencies": { @@ -82,6 +83,9 @@ "eslint-plugin-vue": "^7.9.0", "eslint-plugin-yml": "^0.9.0", "eslint4b": "^7.25.0", + "estree-walker": "^3.0.0", + "locate-character": "^2.0.5", + "magic-string": "^0.25.7", "mocha": "^9.0.0", "nyc": "^15.1.0", "pako": "^2.0.3", diff --git a/src/rules/valid-compile.ts b/src/rules/valid-compile.ts new file mode 100644 index 000000000..73abc5324 --- /dev/null +++ b/src/rules/valid-compile.ts @@ -0,0 +1,420 @@ +import Module from "module" +import path from "path" +import { VisitorKeys } from "svelte-eslint-parser" +import type { SourceCode } from "../types" +import { createRule } from "../utils" +import * as compiler from "svelte/compiler" +import type typescript from "typescript" +import type { SourceMapMappings } from "sourcemap-codec" +import { decode } from "sourcemap-codec" + +type TS = typeof typescript + +type Warning = { + code?: string + start?: { + line: number + column: number + } + end?: { + line: number + column: number + } + message: string +} + +class LinesAndColumns { + private readonly lineStartIndices: number[] + + public constructor(code: string) { + const len = code.length + const lineStartIndices = [0] + for (let index = 0; index < len; index++) { + const c = code[index] + if (c === "\r") { + const next = code[index + 1] || "" + if (next === "\n") { + index++ + } + lineStartIndices.push(index + 1) + } else if (c === "\n") { + lineStartIndices.push(index + 1) + } + } + this.lineStartIndices = lineStartIndices + } + + public getLocFromIndex(index: number): { line: number; column: number } { + const lineNumber = sortedLastIndex(this.lineStartIndices, index) + return { + line: lineNumber, + column: index - this.lineStartIndices[lineNumber - 1], + } + } + + public getIndexFromLoc(loc: { line: number; column: number }): number { + const lineStartIndex = this.lineStartIndices[loc.line - 1] + const positionIndex = lineStartIndex + loc.column + + return positionIndex + } +} + +export default createRule("valid-compile", { + meta: { + docs: { + description: "disallow warnings when compiling.", + category: "Possible Errors", + recommended: false, + }, + schema: [], + messages: {}, + type: "problem", + }, + create(context) { + const sourceCode = context.getSourceCode() + const text = sourceCode.text + + const ignores = ["missing-declaration"] + + /** + * report + */ + function report( + warnings: Warning[], + mapLocation?: (warn: Warning) => { + start?: { + line: number + column: number + } + end?: { + line: number + column: number + } + }, + ) { + for (const warn of warnings) { + if (warn.code && ignores.includes(warn.code)) { + continue + } + const loc = mapLocation?.(warn) ?? warn + context.report({ + loc: { + start: loc.start || loc.end || { line: 1, column: 0 }, + end: loc.end || loc.start || { line: 1, column: 0 }, + }, + message: `${warn.message}${warn.code ? `(${warn.code})` : ""}`, + }) + } + } + + const parserVisitorKeys = sourceCode.visitorKeys + if (isEqualKeys(parserVisitorKeys, VisitorKeys)) { + return { + "Program:exit"() { + report(getWarnings(text)) + }, + } + } + let ts: TS + try { + const createRequire: (filename: string) => (modName: string) => unknown = + // Added in v12.2.0 + Module.createRequire || + // Added in v10.12.0, but deprecated in v12.2.0. + Module.createRequireFromPath + + const cwd = context.getCwd?.() ?? process.cwd() + const relativeTo = path.join(cwd, "__placeholder__.js") + ts = createRequire(relativeTo)("typescript") as TS + } catch { + return {} + } + + class RemapContext { + private originalStart = 0 + + private code = "" + + private locs: LinesAndColumns | null = null + + private readonly mapIndexes: { + range: [number, number] + remap: (index: number) => number + }[] = [] + + public appendOriginal(endIndex: number) { + const codeStart = this.code.length + const start = this.originalStart + this.code += text.slice(start, endIndex) + this.originalStart = endIndex + const offset = start - codeStart + this.mapIndexes.push({ + range: [codeStart, this.code.length], + remap(index) { + return index + offset + }, + }) + } + + public postprocess(): string { + this.appendOriginal(text.length) + return this.code + } + + public appendTranspile(endIndex: number) { + const codeStart = this.code.length + const start = this.originalStart + const inputText = text.slice(start, endIndex) + + const output = ts.transpileModule(inputText, { + reportDiagnostics: false, + compilerOptions: { + target: ts.ScriptTarget.ESNext, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Preserve, + sourceMap: true, + }, + transformers: { + before: [createTsImportTransformer(ts)], + }, + }) + + const outputText = `${output.outputText}\n` + + this.code += outputText + this.originalStart = endIndex + + let outputLocs: LinesAndColumns | null = null + let inputLocs: LinesAndColumns | null = null + let decoded: SourceMapMappings | null = null + this.mapIndexes.push({ + range: [codeStart, this.code.length], + remap: (index) => { + outputLocs ??= new LinesAndColumns(outputText) + inputLocs ??= new LinesAndColumns(inputText) + const outputCodePos = outputLocs.getLocFromIndex(index - codeStart) + const inputCodePos = remapPosition(outputCodePos) + return inputLocs.getIndexFromLoc(inputCodePos) + start + }, + }) + + /** Remapping source position */ + function remapPosition(pos: { line: number; column: number }): { + line: number + column: number + } { + decoded ??= decode(JSON.parse(output.sourceMapText!).mappings) + + const lineMaps = decoded[pos.line] + + if (!lineMaps?.length) { + for (let line = pos.line - 1; line >= 0; line--) { + const prevLineMaps = decoded[line] + if (prevLineMaps?.length) { + const [, , remapLine, remapCol] = + prevLineMaps[prevLineMaps.length - 1] + return { + line: remapLine!, + column: remapCol!, + } + } + } + return { line: -1, column: -1 } + } + + for (let index = 0; index < lineMaps.length - 1; index++) { + const [col, , remapLine, remapCol] = lineMaps[index] + if (col <= pos.column && pos.column < lineMaps[index + 1][0]) { + return { + line: remapLine!, + column: remapCol!, + } + } + } + const [, , remapLine, remapCol] = lineMaps[lineMaps.length - 1] + return { + line: remapLine!, + column: remapCol!, + } + } + } + + public remapLocs(points: { + start?: { + line: number + column: number + } + end?: { + line: number + column: number + } + }): { + start?: { + line: number + column: number + } + end?: { + line: number + column: number + } + } { + const locs = (this.locs ??= new LinesAndColumns(this.code)) + let start: + | { + line: number + column: number + } + | undefined = undefined + let end: + | { + line: number + column: number + } + | undefined = undefined + if (points.start) { + const index = locs.getIndexFromLoc(points.start) + for (const mapIndex of this.mapIndexes) { + if (mapIndex.range[0] <= index && index < mapIndex.range[1]) { + start = sourceCode.getLocFromIndex(mapIndex.remap(index)) + break + } + } + } + if (points.end) { + const index = locs.getIndexFromLoc(points.end) + for (const mapIndex of this.mapIndexes) { + if (mapIndex.range[0] <= index && index <= mapIndex.range[1]) { + end = sourceCode.getLocFromIndex(mapIndex.remap(index)) + break + } + } + } + + return { start, end } + } + } + + const remapContext = new RemapContext() + + return { + SvelteScriptElement(node) { + if (node.endTag) { + remapContext.appendOriginal(node.startTag.range[1]) + remapContext.appendTranspile(node.endTag.range[0]) + } + }, + "Program:exit"() { + const code = remapContext.postprocess() + report(getWarnings(code), (warn) => remapContext.remapLocs(warn)) + }, + } + }, +}) + +/** + * Get compile warnings + */ +function getWarnings(code: string): Warning[] { + try { + const result = compiler.compile(code, { generate: false }) + + return result.warnings + } catch (e) { + // console.log(code) + return [ + { + message: e.message, + start: e.start, + end: e.end, + }, + ] + } +} + +/** + * Checks if the given visitorKeys are the equals. + */ +function isEqualKeys( + a: SourceCode["visitorKeys"], + b: SourceCode["visitorKeys"], +): boolean { + const keysA = new Set(Object.keys(a)) + const keysB = new Set(Object.keys(a)) + if (keysA.size !== keysB.size) { + return false + } + for (const key of keysA) { + if (!keysB.has(key)) { + return false + } + const vKeysA = new Set(a[key]) + const vKeysB = new Set(b[key]) + if (vKeysA.size !== vKeysB.size) { + return false + } + + for (const vKey of vKeysA) { + if (!vKeysB.has(vKey)) { + return false + } + } + } + return true +} + +/** + * @see https://github.com/sveltejs/eslint-plugin-svelte3/blob/259263ccaf69c59e473d9bfa39706b0955eccfbd/src/preprocess.js#L194 + * MIT License @ Conduitry + */ +function createTsImportTransformer( + ts: TS, +): typescript.TransformerFactory { + const factory = ts.factory + /** + * https://github.com/sveltejs/svelte-preprocess/blob/main/src/transformers/typescript.ts + * TypeScript transformer for preserving imports correctly when preprocessing TypeScript files + */ + return (context: typescript.TransformationContext) => { + /** visitor */ + function visit(node: typescript.Node): typescript.Node { + if (ts.isImportDeclaration(node)) { + if (node.importClause && node.importClause.isTypeOnly) { + return factory.createEmptyStatement() + } + + return factory.createImportDeclaration( + node.decorators, + node.modifiers, + node.importClause, + node.moduleSpecifier, + ) + } + + return ts.visitEachChild(node, (child) => visit(child), context) + } + + return (node: typescript.SourceFile) => ts.visitNode(node, visit) + } +} + +/** + * Uses a binary search to determine the highest index at which value should be inserted into array in order to maintain its sort order. + */ +function sortedLastIndex(array: number[], value: number): number { + let lower = 0 + let upper = array.length + + while (lower < upper) { + const mid = Math.floor(lower + (upper - lower) / 2) + const target = array[mid] + if (target < value) { + lower = mid + 1 + } else if (target > value) { + upper = mid + } else { + return mid + 1 + } + } + + return upper +} diff --git a/src/types.ts b/src/types.ts index 75fd389bc..ca0526f03 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,9 @@ export type RuleContext = { markVariableAsUsed(name: string): boolean report(descriptor: ReportDescriptor): void + + // eslint@6 does not have this method. + getCwd?: () => string } export type NodeOrToken = { diff --git a/src/utils/rules.ts b/src/utils/rules.ts index 5ef170ebe..e9d71effd 100644 --- a/src/utils/rules.ts +++ b/src/utils/rules.ts @@ -17,6 +17,7 @@ import preferClassDirective from "../rules/prefer-class-directive" import shorthandAttribute from "../rules/shorthand-attribute" import spacedHtmlComment from "../rules/spaced-html-comment" import system from "../rules/system" +import validCompile from "../rules/valid-compile" export const rules = [ buttonHasType, @@ -37,4 +38,5 @@ export const rules = [ shorthandAttribute, spacedHtmlComment, system, + validCompile, ] as RuleModule[] diff --git a/tests/fixtures/rules/valid-compile/invalid/a11y01-errors.json b/tests/fixtures/rules/valid-compile/invalid/a11y01-errors.json new file mode 100644 index 000000000..7865a3bf9 --- /dev/null +++ b/tests/fixtures/rules/valid-compile/invalid/a11y01-errors.json @@ -0,0 +1,7 @@ +[ + { + "message": "A11y: element should have an alt attribute(a11y-missing-attribute)", + "line": 5, + "column": 1 + } +] diff --git a/tests/fixtures/rules/valid-compile/invalid/a11y01-input.svelte b/tests/fixtures/rules/valid-compile/invalid/a11y01-input.svelte new file mode 100644 index 000000000..a6a917dd2 --- /dev/null +++ b/tests/fixtures/rules/valid-compile/invalid/a11y01-input.svelte @@ -0,0 +1,5 @@ + + + diff --git a/tests/fixtures/rules/valid-compile/valid/test01-input.svelte b/tests/fixtures/rules/valid-compile/valid/test01-input.svelte new file mode 100644 index 000000000..280c346fd --- /dev/null +++ b/tests/fixtures/rules/valid-compile/valid/test01-input.svelte @@ -0,0 +1,6 @@ + + +{name} dances. diff --git a/tests/fixtures/rules/valid-compile/valid/ts/class01-input.svelte b/tests/fixtures/rules/valid-compile/valid/ts/class01-input.svelte new file mode 100644 index 000000000..5877c8e57 --- /dev/null +++ b/tests/fixtures/rules/valid-compile/valid/ts/class01-input.svelte @@ -0,0 +1,13 @@ + diff --git a/tests/fixtures/rules/valid-compile/valid/ts/test01-input.svelte b/tests/fixtures/rules/valid-compile/valid/ts/test01-input.svelte new file mode 100644 index 000000000..59d83f68a --- /dev/null +++ b/tests/fixtures/rules/valid-compile/valid/ts/test01-input.svelte @@ -0,0 +1,6 @@ + + +{name} dances. diff --git a/tests/fixtures/rules/valid-compile/valid/undef01-input.svelte b/tests/fixtures/rules/valid-compile/valid/undef01-input.svelte new file mode 100644 index 000000000..61a5b660d --- /dev/null +++ b/tests/fixtures/rules/valid-compile/valid/undef01-input.svelte @@ -0,0 +1,5 @@ + + +{name} dances. diff --git a/tests/fixtures/rules/valid-compile/valid/unuse01-input.svelte b/tests/fixtures/rules/valid-compile/valid/unuse01-input.svelte new file mode 100644 index 000000000..f6503a44c --- /dev/null +++ b/tests/fixtures/rules/valid-compile/valid/unuse01-input.svelte @@ -0,0 +1,6 @@ + + + diff --git a/tests/src/rules/valid-compile.ts b/tests/src/rules/valid-compile.ts new file mode 100644 index 000000000..f19f0990d --- /dev/null +++ b/tests/src/rules/valid-compile.ts @@ -0,0 +1,16 @@ +import { RuleTester } from "eslint" +import rule from "../../../src/rules/valid-compile" +import { loadTestCases } from "../../utils/utils" + +const tester = new RuleTester({ + parserOptions: { + ecmaVersion: 2020, + sourceType: "module", + parser: { + ts: "@typescript-eslint/parser", + js: "espree", + }, + }, +}) + +tester.run("valid-compile", rule as any, loadTestCases("valid-compile"))