diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8cf1b03..dcfd2994 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,24 @@ jobs: env: CI: true + test_types: + name: Test Types (core) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "lts/*" + + - name: npm install and test types + working-directory: packages/core + run: | + npm install + npm run build + npm run test:types + jsr_test: name: Verify JSR Publish runs-on: ubuntu-latest diff --git a/packages/core/package.json b/packages/core/package.json index 797e3a65..650220e2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -19,7 +19,8 @@ "scripts": { "build:cts": "node -e \"fs.cpSync('dist/esm/types.d.ts', 'dist/cjs/types.d.cts')\"", "build": "tsc && npm run build:cts", - "test:jsr": "npx jsr@latest publish --dry-run" + "test:jsr": "npx jsr@latest publish --dry-run", + "test:types": "tsc -p tests/types/tsconfig.json" }, "repository": { "type": "git", @@ -35,6 +36,9 @@ "url": "https://github.com/eslint/rewrite/issues" }, "homepage": "https://github.com/eslint/rewrite#readme", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, "devDependencies": { "json-schema": "^0.4.0", "typescript": "^5.4.5" diff --git a/packages/core/tests/types/tsconfig.json b/packages/core/tests/types/tsconfig.json new file mode 100644 index 00000000..7bbf5d88 --- /dev/null +++ b/packages/core/tests/types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "rootDir": "../..", + "strict": true + }, + "files": ["../../dist/esm/types.d.ts", "types.test.ts"] +} diff --git a/packages/core/tests/types/types.test.ts b/packages/core/tests/types/types.test.ts new file mode 100644 index 00000000..5ecc7a39 --- /dev/null +++ b/packages/core/tests/types/types.test.ts @@ -0,0 +1,241 @@ +/** + * @fileoverview Type tests for ESLint Core. + * @author Francesco Trotta + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import type { + File, + FileProblem, + Language, + LanguageContext, + LanguageOptions, + OkParseResult, + ParseResult, + RuleContext, + RuleDefinition, + RulesConfig, + RulesMeta, + RuleTextEdit, + RuleTextEditor, + RuleVisitor, + SourceLocation, + SourceRange, + TextSourceCode, + TraversalStep, +} from "@eslint/core"; + +//----------------------------------------------------------------------------- +// Helper types +//----------------------------------------------------------------------------- + +interface TestNode { + type: string; + start: number; + lenght: number; +} + +interface TestRootNode { + type: "root"; + start: number; + length: number; +} + +//----------------------------------------------------------------------------- +// Tests for shared types +//----------------------------------------------------------------------------- + +interface TestLanguageOptions extends LanguageOptions { + howMuch?: "yes" | "no" | boolean; +} + +class TestSourceCode + implements + TextSourceCode<{ + LangOptions: TestLanguageOptions; + RootNode: TestRootNode; + SyntaxElementWithLoc: unknown; + ConfigNode: unknown; + }> +{ + text: string; + ast: TestRootNode; + notMuch: "no" | false; + visitorKeys?: Record | undefined; + + constructor(text: string, ast: TestRootNode) { + this.text = text; + this.ast = ast; + this.notMuch = false; + } + + /* eslint-disable class-methods-use-this -- not all methods need `this` */ + + getLoc(syntaxElement: { start: number; length: number }): SourceLocation { + return { + start: { line: 1, column: syntaxElement.start + 1 }, + end: { + line: 1, + column: syntaxElement.start + 1 + syntaxElement.length, + }, + }; + } + + getRange(syntaxElement: { start: number; length: number }): SourceRange { + return [ + syntaxElement.start, + syntaxElement.start + syntaxElement.length, + ]; + } + + *traverse(): Iterable { + // To be implemented. + } + + applyLanguageOptions(languageOptions: TestLanguageOptions): void { + if (languageOptions.howMuch === "yes") { + this.notMuch = "no"; + } + } + + applyInlineConfig(): { + configs: { loc: SourceLocation; config: { rules: RulesConfig } }[]; + problems: FileProblem[]; + } { + throw new Error("Method not implemented."); + } + + /* eslint-enable class-methods-use-this -- not all methods need `this` */ +} + +//----------------------------------------------------------------------------- +// Tests for language-related types +//----------------------------------------------------------------------------- + +interface TestNormalizedLanguageOptions extends TestLanguageOptions { + howMuch: boolean; // option is required and must be a boolean +} + +const testLanguage: Language = { + fileType: "text", + lineStart: 1, + columnStart: 1, + nodeTypeKey: "type", + + validateLanguageOptions(languageOptions: TestLanguageOptions): void { + if ( + !["yes", "no", true, false, undefined].includes( + languageOptions.howMuch, + ) + ) { + throw Error("Invalid options."); + } + }, + + normalizeLanguageOptions( + languageOptions: TestLanguageOptions, + ): TestNormalizedLanguageOptions { + const { howMuch } = languageOptions; + return { howMuch: howMuch === "yes" || howMuch === true }; + }, + + parse( + file: File, + context: { languageOptions: TestNormalizedLanguageOptions }, + ): ParseResult { + context.languageOptions.howMuch satisfies boolean; + return { + ok: true, + ast: { + type: "root", + start: 0, + length: file.body.length, + }, + }; + }, + + createSourceCode( + file: File, + input: OkParseResult, + context: LanguageContext, + ): TestSourceCode { + context.languageOptions.howMuch satisfies boolean; + return new TestSourceCode(String(file.body), input.ast); + }, +}; + +testLanguage.defaultLanguageOptions satisfies LanguageOptions | undefined; + +//----------------------------------------------------------------------------- +// Tests for rule-related types +//----------------------------------------------------------------------------- + +interface TestRuleVisitor extends RuleVisitor { + Node?: (node: TestNode) => void; +} + +type TestRuleContext = RuleContext<{ + LangOptions: TestLanguageOptions; + Code: TestSourceCode; + RuleOptions: [{ foo: string; bar: number }]; + Node: TestNode; +}>; + +const testRule: RuleDefinition<{ + LangOptions: TestLanguageOptions; + Code: TestSourceCode; + RuleOptions: [{ foo: string; bar: number }]; + Visitor: TestRuleVisitor; + Node: TestNode; + MessageIds: "badFoo" | "wrongBar"; + ExtRuleDocs: never; +}> = { + meta: { + type: "problem", + fixable: "code", + messages: { + badFoo: "change this foo", + wrongBar: "fix this bar", + }, + }, + + create(context: TestRuleContext): TestRuleVisitor { + return { + Foo(node: TestNode) { + // node.type === "Foo" + context.report({ + messageId: "badFoo", + loc: { + start: { line: node.start, column: 1 }, + end: { line: node.start + 1, column: Infinity }, + }, + fix(fixer: RuleTextEditor): RuleTextEdit { + return fixer.replaceText( + node, + context.languageOptions.howMuch === "yes" + ? "👍" + : "👎", + ); + }, + }); + }, + Bar(node: TestNode) { + // node.type === "Bar" + context.report({ + message: "This bar is foobar", + node, + suggest: [ + { + messageId: "Bar", + }, + ], + }); + }, + }; + }, +}; + +testRule.meta satisfies RulesMeta | undefined;