diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index bad4e96..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "root": true, - "parser": "@typescript-eslint/parser", - "parserOptions": { - "ecmaVersion": 6, - "sourceType": "module" - }, - "plugins": [ - "@typescript-eslint" - ], - "rules": { - "@typescript-eslint/naming-convention": [ - "warn", - { - "selector": "import", - "format": [ "camelCase", "PascalCase" ] - } - ], - "@typescript-eslint/semi": "warn", - "curly": "warn", - "eqeqeq": "warn", - "no-throw-literal": "warn", - "semi": "off" - }, - "ignorePatterns": [ - "dist", - "**/*.d.ts" - ] -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9d7fbff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +permissions: + contents: read + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v5 + with: + node-version: "22" + + - name: Install dependencies + run: npm ci + + - name: check + run: npm run check diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3ac9aeb..998fee7 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,5 @@ { - // See http://go.microsoft.com/fwlink/?LinkId=827846 - // for the documentation about the extensions.json format - "recommendations": [ - "dbaeumer.vscode-eslint" - ] + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": ["biomejs.biome"] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 670d6e6..1210201 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,32 +3,26 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 { - "version": "0.2.0", - "configurations": [ - { - "name": "Run Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}" - }, - { - "name": "Extension Tests", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" - ], - "outFiles": [ - "${workspaceFolder}/out/test/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}" - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + }, + { + "name": "Extension Tests", + "type": "extensionHost", + "request": "launch", + "args": [ + "--extensionDevelopmentPath=${workspaceFolder}", + "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" + ], + "outFiles": ["${workspaceFolder}/out/test/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 325ddd5..fc73259 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,31 +1,62 @@ -// Place your settings in this file to overwrite default and user settings. { - "files.exclude": { - "dist": false - }, - "search.exclude": { - "dist": true - }, - // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off", - "workbench.colorCustomizations": { - "activityBar.activeBackground": "#2a2a2a", - "activityBar.background": "#2a2a2a", - "activityBar.foreground": "#e7e7e7", - "activityBar.inactiveForeground": "#e7e7e799", - "activityBarBadge.background": "#606020", - "activityBarBadge.foreground": "#e7e7e7", - "commandCenter.border": "#e7e7e799", - "sash.hoverBorder": "#2a2a2a", - "statusBar.background": "#111111", - "statusBar.foreground": "#e7e7e7", - "statusBarItem.hoverBackground": "#2a2a2a", - "statusBarItem.remoteBackground": "#111111", - "statusBarItem.remoteForeground": "#e7e7e7", - "titleBar.activeBackground": "#111111", - "titleBar.activeForeground": "#e7e7e7", - "titleBar.inactiveBackground": "#11111199", - "titleBar.inactiveForeground": "#e7e7e799" - }, - "peacock.color": "#111" -} \ No newline at end of file + "files.exclude": { + "dist": false + }, + "search.exclude": { + "dist": true + }, + "typescript.tsc.autoDetect": "off", + "workbench.colorCustomizations": { + "activityBar.activeBackground": "#2a2a2a", + "activityBar.background": "#2a2a2a", + "activityBar.foreground": "#e7e7e7", + "activityBar.inactiveForeground": "#e7e7e799", + "activityBarBadge.background": "#606020", + "activityBarBadge.foreground": "#e7e7e7", + "commandCenter.border": "#e7e7e799", + "sash.hoverBorder": "#2a2a2a", + "statusBar.background": "#111111", + "statusBar.foreground": "#e7e7e7", + "statusBarItem.hoverBackground": "#2a2a2a", + "statusBarItem.remoteBackground": "#111111", + "statusBarItem.remoteForeground": "#e7e7e7", + "titleBar.activeBackground": "#111111", + "titleBar.activeForeground": "#e7e7e7", + "titleBar.inactiveBackground": "#11111199", + "titleBar.inactiveForeground": "#e7e7e799" + }, + "peacock.color": "#111", + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[css]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[graphql]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "typescript.tsdk": "node_modules/typescript/lib", + "editor.formatOnSave": true, + "editor.formatOnPaste": true, + "emmet.showExpandedAbbreviation": "never", + "editor.codeActionsOnSave": { + "source.fixAll.biome": "explicit", + "source.organizeImports.biome": "explicit" + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3b17e53..078ff7e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,20 +1,20 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "watch", - "problemMatcher": "$tsc-watch", - "isBackground": true, - "presentation": { - "reveal": "never" - }, - "group": { - "kind": "build", - "isDefault": true - } - } - ] + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] } diff --git a/AGENTS.md b/AGENTS.md index 79d8907..47731c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,3 +48,332 @@ - Extension (application/wiring): converts to VS Code Ranges, applies decorations, wires events. Uses `makeVsCodeResolveFn()` as the resolver. - Dependency direction: Core ← Analyzer ← Extension (no backflow) - Documentation: see `docs/ARCHITECTURE.md` (includes a Mermaid diagram) + + +# Project Context +Ultracite enforces strict type safety, accessibility standards, and consistent code quality for JavaScript/TypeScript projects using Biome's lightning-fast formatter and linter. + +## Key Principles +- Zero configuration required +- Subsecond performance +- Maximum type safety +- AI-friendly code generation + +## Before Writing Code +1. Analyze existing patterns in the codebase +2. Consider edge cases and error scenarios +3. Follow the rules below strictly +4. Validate accessibility requirements + +## Rules + +### Accessibility (a11y) +- Don't use `accessKey` attribute on any HTML element. +- Don't set `aria-hidden="true"` on focusable elements. +- Don't add ARIA roles, states, and properties to elements that don't support them. +- Don't use distracting elements like `` or ``. +- Only use the `scope` prop on `` elements. +- Don't assign non-interactive ARIA roles to interactive HTML elements. +- Make sure label elements have text content and are associated with an input. +- Don't assign interactive ARIA roles to non-interactive HTML elements. +- Don't assign `tabIndex` to non-interactive HTML elements. +- Don't use positive integers for `tabIndex` property. +- Don't include "image", "picture", or "photo" in img alt prop. +- Don't use explicit role property that's the same as the implicit/default role. +- Make static elements with click handlers use a valid role attribute. +- Always include a `title` element for SVG elements. +- Give all elements requiring alt text meaningful information for screen readers. +- Make sure anchors have content that's accessible to screen readers. +- Assign `tabIndex` to non-interactive HTML elements with `aria-activedescendant`. +- Include all required ARIA attributes for elements with ARIA roles. +- Make sure ARIA properties are valid for the element's supported roles. +- Always include a `type` attribute for button elements. +- Make elements with interactive roles and handlers focusable. +- Give heading elements content that's accessible to screen readers (not hidden with `aria-hidden`). +- Always include a `lang` attribute on the html element. +- Always include a `title` attribute for iframe elements. +- Accompany `onClick` with at least one of: `onKeyUp`, `onKeyDown`, or `onKeyPress`. +- Accompany `onMouseOver`/`onMouseOut` with `onFocus`/`onBlur`. +- Include caption tracks for audio and video elements. +- Use semantic elements instead of role attributes in JSX. +- Make sure all anchors are valid and navigable. +- Ensure all ARIA properties (`aria-*`) are valid. +- Use valid, non-abstract ARIA roles for elements with ARIA roles. +- Use valid ARIA state and property values. +- Use valid values for the `autocomplete` attribute on input elements. +- Use correct ISO language/country codes for the `lang` attribute. + +### Code Complexity and Quality +- Don't use consecutive spaces in regular expression literals. +- Don't use the `arguments` object. +- Don't use primitive type aliases or misleading types. +- Don't use the comma operator. +- Don't use empty type parameters in type aliases and interfaces. +- Don't write functions that exceed a given Cognitive Complexity score. +- Don't nest describe() blocks too deeply in test files. +- Don't use unnecessary boolean casts. +- Don't use unnecessary callbacks with flatMap. +- Use for...of statements instead of Array.forEach. +- Don't create classes that only have static members (like a static namespace). +- Don't use this and super in static contexts. +- Don't use unnecessary catch clauses. +- Don't use unnecessary constructors. +- Don't use unnecessary continue statements. +- Don't export empty modules that don't change anything. +- Don't use unnecessary escape sequences in regular expression literals. +- Don't use unnecessary fragments. +- Don't use unnecessary labels. +- Don't use unnecessary nested block statements. +- Don't rename imports, exports, and destructured assignments to the same name. +- Don't use unnecessary string or template literal concatenation. +- Don't use String.raw in template literals when there are no escape sequences. +- Don't use useless case statements in switch statements. +- Don't use ternary operators when simpler alternatives exist. +- Don't use useless `this` aliasing. +- Don't use any or unknown as type constraints. +- Don't initialize variables to undefined. +- Don't use the void operators (they're not familiar). +- Use arrow functions instead of function expressions. +- Use Date.now() to get milliseconds since the Unix Epoch. +- Use .flatMap() instead of map().flat() when possible. +- Use literal property access instead of computed property access. +- Don't use parseInt() or Number.parseInt() when binary, octal, or hexadecimal literals work. +- Use concise optional chaining instead of chained logical expressions. +- Use regular expression literals instead of the RegExp constructor when possible. +- Don't use number literal object member names that aren't base 10 or use underscore separators. +- Remove redundant terms from logical expressions. +- Use while loops instead of for loops when you don't need initializer and update expressions. +- Don't pass children as props. +- Don't reassign const variables. +- Don't use constant expressions in conditions. +- Don't use `Math.min` and `Math.max` to clamp values when the result is constant. +- Don't return a value from a constructor. +- Don't use empty character classes in regular expression literals. +- Don't use empty destructuring patterns. +- Don't call global object properties as functions. +- Don't declare functions and vars that are accessible outside their block. +- Make sure builtins are correctly instantiated. +- Don't use super() incorrectly inside classes. Also check that super() is called in classes that extend other constructors. +- Don't use variables and function parameters before they're declared. +- Don't use 8 and 9 escape sequences in string literals. +- Don't use literal numbers that lose precision. + +### React and JSX Best Practices +- Don't use the return value of React.render. +- Make sure all dependencies are correctly specified in React hooks. +- Make sure all React hooks are called from the top level of component functions. +- Don't forget key props in iterators and collection literals. +- Don't destructure props inside JSX components in Solid projects. +- Don't define React components inside other components. +- Don't use event handlers on non-interactive elements. +- Don't assign to React component props. +- Don't use both `children` and `dangerouslySetInnerHTML` props on the same element. +- Don't use dangerous JSX props. +- Don't use Array index in keys. +- Don't insert comments as text nodes. +- Don't assign JSX properties multiple times. +- Don't add extra closing tags for components without children. +- Use `<>...` instead of `...`. +- Watch out for possible "wrong" semicolons inside JSX elements. + +### Correctness and Safety +- Don't assign a value to itself. +- Don't return a value from a setter. +- Don't compare expressions that modify string case with non-compliant values. +- Don't use lexical declarations in switch clauses. +- Don't use variables that haven't been declared in the document. +- Don't write unreachable code. +- Make sure super() is called exactly once on every code path in a class constructor before this is accessed if the class has a superclass. +- Don't use control flow statements in finally blocks. +- Don't use optional chaining where undefined values aren't allowed. +- Don't have unused function parameters. +- Don't have unused imports. +- Don't have unused labels. +- Don't have unused private class members. +- Don't have unused variables. +- Make sure void (self-closing) elements don't have children. +- Don't return a value from a function with the return type 'void' +- Use isNaN() when checking for NaN. +- Make sure "for" loop update clauses move the counter in the right direction. +- Make sure typeof expressions are compared to valid values. +- Make sure generator functions contain yield. +- Don't use await inside loops. +- Don't use bitwise operators. +- Don't use expressions where the operation doesn't change the value. +- Make sure Promise-like statements are handled appropriately. +- Don't use __dirname and __filename in the global scope. +- Prevent import cycles. +- Don't use configured elements. +- Don't hardcode sensitive data like API keys and tokens. +- Don't let variable declarations shadow variables from outer scopes. +- Don't use the TypeScript directive @ts-ignore. +- Prevent duplicate polyfills from Polyfill.io. +- Don't use useless backreferences in regular expressions that always match empty strings. +- Don't use unnecessary escapes in string literals. +- Don't use useless undefined. +- Make sure getters and setters for the same property are next to each other in class and object definitions. +- Make sure object literals are declared consistently (defaults to explicit definitions). +- Use static Response methods instead of new Response() constructor when possible. +- Make sure switch-case statements are exhaustive. +- Make sure the `preconnect` attribute is used when using Google Fonts. +- Use `Array#{indexOf,lastIndexOf}()` instead of `Array#{findIndex,findLastIndex}()` when looking for the index of an item. +- Make sure iterable callbacks return consistent values. +- Use `with { type: "json" }` for JSON module imports. +- Use numeric separators in numeric literals. +- Use object spread instead of `Object.assign()` when constructing new objects. +- Always use the radix argument when using `parseInt()`. +- Make sure JSDoc comment lines start with a single asterisk, except for the first one. +- Include a description parameter for `Symbol()`. +- Don't use spread (`...`) syntax on accumulators. +- Don't use the `delete` operator. +- Don't access namespace imports dynamically. +- Don't use namespace imports. +- Declare regex literals at the top level. +- Don't use `target="_blank"` without `rel="noopener"`. + +### TypeScript Best Practices +- Don't use TypeScript enums. +- Don't export imported variables. +- Don't add type annotations to variables, parameters, and class properties that are initialized with literal expressions. +- Don't use TypeScript namespaces. +- Don't use non-null assertions with the `!` postfix operator. +- Don't use parameter properties in class constructors. +- Don't use user-defined types. +- Use `as const` instead of literal types and type annotations. +- Use either `T[]` or `Array` consistently. +- Initialize each enum member value explicitly. +- Use `export type` for types. +- Use `import type` for types. +- Make sure all enum members are literal values. +- Don't use TypeScript const enum. +- Don't declare empty interfaces. +- Don't let variables evolve into any type through reassignments. +- Don't use the any type. +- Don't misuse the non-null assertion operator (!) in TypeScript files. +- Don't use implicit any type on variable declarations. +- Don't merge interfaces and classes unsafely. +- Don't use overload signatures that aren't next to each other. +- Use the namespace keyword instead of the module keyword to declare TypeScript namespaces. + +### Style and Consistency +- Don't use global `eval()`. +- Don't use callbacks in asynchronous tests and hooks. +- Don't use negation in `if` statements that have `else` clauses. +- Don't use nested ternary expressions. +- Don't reassign function parameters. +- This rule lets you specify global variable names you don't want to use in your application. +- Don't use specified modules when loaded by import or require. +- Don't use constants whose value is the upper-case version of their name. +- Use `String.slice()` instead of `String.substr()` and `String.substring()`. +- Don't use template literals if you don't need interpolation or special-character handling. +- Don't use `else` blocks when the `if` block breaks early. +- Don't use yoda expressions. +- Don't use Array constructors. +- Use `at()` instead of integer index access. +- Follow curly brace conventions. +- Use `else if` instead of nested `if` statements in `else` clauses. +- Use single `if` statements instead of nested `if` clauses. +- Use `new` for all builtins except `String`, `Number`, and `Boolean`. +- Use consistent accessibility modifiers on class properties and methods. +- Use `const` declarations for variables that are only assigned once. +- Put default function parameters and optional function parameters last. +- Include a `default` clause in switch statements. +- Use the `**` operator instead of `Math.pow`. +- Use `for-of` loops when you need the index to extract an item from the iterated array. +- Use `node:assert/strict` over `node:assert`. +- Use the `node:` protocol for Node.js builtin modules. +- Use Number properties instead of global ones. +- Use assignment operator shorthand where possible. +- Use function types instead of object types with call signatures. +- Use template literals over string concatenation. +- Use `new` when throwing an error. +- Don't throw non-Error values. +- Use `String.trimStart()` and `String.trimEnd()` over `String.trimLeft()` and `String.trimRight()`. +- Use standard constants instead of approximated literals. +- Don't assign values in expressions. +- Don't use async functions as Promise executors. +- Don't reassign exceptions in catch clauses. +- Don't reassign class members. +- Don't compare against -0. +- Don't use labeled statements that aren't loops. +- Don't use void type outside of generic or return types. +- Don't use console. +- Don't use control characters and escape sequences that match control characters in regular expression literals. +- Don't use debugger. +- Don't assign directly to document.cookie. +- Use `===` and `!==`. +- Don't use duplicate case labels. +- Don't use duplicate class members. +- Don't use duplicate conditions in if-else-if chains. +- Don't use two keys with the same name inside objects. +- Don't use duplicate function parameter names. +- Don't have duplicate hooks in describe blocks. +- Don't use empty block statements and static blocks. +- Don't let switch clauses fall through. +- Don't reassign function declarations. +- Don't allow assignments to native objects and read-only global variables. +- Use Number.isFinite instead of global isFinite. +- Use Number.isNaN instead of global isNaN. +- Don't assign to imported bindings. +- Don't use irregular whitespace characters. +- Don't use labels that share a name with a variable. +- Don't use characters made with multiple code points in character class syntax. +- Make sure to use new and constructor properly. +- Don't use shorthand assign when the variable appears on both sides. +- Don't use octal escape sequences in string literals. +- Don't use Object.prototype builtins directly. +- Don't redeclare variables, functions, classes, and types in the same scope. +- Don't have redundant "use strict". +- Don't compare things where both sides are exactly the same. +- Don't let identifiers shadow restricted names. +- Don't use sparse arrays (arrays with holes). +- Don't use template literal placeholder syntax in regular strings. +- Don't use the then property. +- Don't use unsafe negation. +- Don't use var. +- Don't use with statements in non-strict contexts. +- Make sure async functions actually use await. +- Make sure default clauses in switch statements come last. +- Make sure to pass a message value when creating a built-in error. +- Make sure get methods always return a value. +- Use a recommended display strategy with Google Fonts. +- Make sure for-in loops include an if statement. +- Use Array.isArray() instead of instanceof Array. +- Make sure to use the digits argument with Number#toFixed(). +- Make sure to use the "use strict" directive in script files. + +### Next.js Specific Rules +- Don't use `` elements in Next.js projects. +- Don't use `` elements in Next.js projects. +- Don't import next/document outside of pages/_document.jsx in Next.js projects. +- Don't use the next/head module in pages/_document.js on Next.js projects. + +### Testing Best Practices +- Don't use export or module.exports in test files. +- Don't use focused tests. +- Make sure the assertion function, like expect, is placed inside an it() function call. +- Don't use disabled tests. + +## Common Tasks +- `npx ultracite init` - Initialize Ultracite in your project +- `npx ultracite fix` - Format and fix code automatically +- `npx ultracite check` - Check for issues without fixing + +## Example: Error Handling +```typescript +// ✅ Good: Comprehensive error handling +try { + const result = await fetchData(); + return { success: true, data: result }; +} catch (error) { + console.error('API call failed:', error); + return { success: false, error: error.message }; +} + +// ❌ Bad: Swallowing errors +try { + return await fetchData(); +} catch (e) { + console.log(e); +} +``` \ No newline at end of file diff --git a/biome.jsonc b/biome.jsonc new file mode 100644 index 0000000..4c359c0 --- /dev/null +++ b/biome.jsonc @@ -0,0 +1,4 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "extends": ["ultracite"] +} diff --git a/package-lock.json b/package-lock.json index 80f3e84..d984370 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,17 +12,205 @@ "typescript": "5.9.2" }, "devDependencies": { + "@biomejs/biome": "^2.2.5", "@types/node": "24", "@types/vscode": "1.104.0", "@typescript-eslint/eslint-plugin": "6.9.0", "@typescript-eslint/parser": "6.9.0", "eslint": "8.0.0", + "ultracite": "^5.6.2", "vitest": "3.2.4" }, "engines": { "vscode": "^1.104.0" } }, + "node_modules/@biomejs/biome": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.2.5.tgz", + "integrity": "sha512-zcIi+163Rc3HtyHbEO7CjeHq8DjQRs40HsGbW6vx2WI0tg8mYQOPouhvHSyEnCBAorfYNnKdR64/IxO7xQ5faw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "bin": { + "biome": "bin/biome" + }, + "engines": { + "node": ">=14.21.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/biome" + }, + "optionalDependencies": { + "@biomejs/cli-darwin-arm64": "2.2.5", + "@biomejs/cli-darwin-x64": "2.2.5", + "@biomejs/cli-linux-arm64": "2.2.5", + "@biomejs/cli-linux-arm64-musl": "2.2.5", + "@biomejs/cli-linux-x64": "2.2.5", + "@biomejs/cli-linux-x64-musl": "2.2.5", + "@biomejs/cli-win32-arm64": "2.2.5", + "@biomejs/cli-win32-x64": "2.2.5" + } + }, + "node_modules/@biomejs/cli-darwin-arm64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.2.5.tgz", + "integrity": "sha512-MYT+nZ38wEIWVcL5xLyOhYQQ7nlWD0b/4mgATW2c8dvq7R4OQjt/XGXFkXrmtWmQofaIM14L7V8qIz/M+bx5QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-darwin-x64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.2.5.tgz", + "integrity": "sha512-FLIEl73fv0R7dI10EnEiZLw+IMz3mWLnF95ASDI0kbx6DDLJjWxE5JxxBfmG+udz1hIDd3fr5wsuP7nwuTRdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.2.5.tgz", + "integrity": "sha512-5DjiiDfHqGgR2MS9D+AZ8kOfrzTGqLKywn8hoXpXXlJXIECGQ32t+gt/uiS2XyGBM2XQhR6ztUvbjZWeccFMoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-arm64-musl": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.2.5.tgz", + "integrity": "sha512-5Ov2wgAFwqDvQiESnu7b9ufD1faRa+40uwrohgBopeY84El2TnBDoMNXx6iuQdreoFGjwW8vH6k68G21EpNERw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.2.5.tgz", + "integrity": "sha512-fq9meKm1AEXeAWan3uCg6XSP5ObA6F/Ovm89TwaMiy1DNIwdgxPkNwxlXJX8iM6oRbFysYeGnT0OG8diCWb9ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-linux-x64-musl": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.2.5.tgz", + "integrity": "sha512-AVqLCDb/6K7aPNIcxHaTQj01sl1m989CJIQFQEaiQkGr2EQwyOpaATJ473h+nXDUuAcREhccfRpe/tu+0wu0eQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-arm64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.2.5.tgz", + "integrity": "sha512-xaOIad4wBambwJa6mdp1FigYSIF9i7PCqRbvBqtIi9y29QtPVQ13sDGtUnsRoe6SjL10auMzQ6YAe+B3RpZXVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@biomejs/cli-win32-x64": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.2.5.tgz", + "integrity": "sha512-F/jhuXCssPFAuciMhHKk00xnCAxJRS/pUzVfXYmOMUp//XW7mO6QeCjsjvnm8L4AO/dG2VOB0O+fJPiJ2uXtIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=14.21.3" + } + }, + "node_modules/@clack/core": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", + "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/@clack/prompts": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz", + "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "0.5.0", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, "node_modules/@esbuild/darwin-arm64": { "version": "0.25.9", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", @@ -171,6 +359,19 @@ "darwin" ] }, + "node_modules/@trpc/server": { + "version": "11.6.0", + "resolved": "https://registry.npmjs.org/@trpc/server/-/server-11.6.0.tgz", + "integrity": "sha512-skTso0AWbOZck40jwNeYv++AMZXNWLUWdyk+pB5iVaYmEKTuEeMoPrEudR12VafbEU6tZa8HK3QhBfTYYHDCdg==", + "dev": true, + "funding": [ + "https://trpc.io/sponsor" + ], + "license": "MIT", + "peerDependencies": { + "typescript": ">=5.7.2" + } + }, "node_modules/@types/chai": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.2.tgz", @@ -731,6 +932,16 @@ "node": ">= 16" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -749,6 +960,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/commander": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -756,6 +977,23 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -803,6 +1041,16 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -1108,6 +1356,13 @@ "node": ">=12.0.0" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -1459,6 +1714,13 @@ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -1573,6 +1835,33 @@ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, + "node_modules/nypm/node_modules/tinyexec": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "dev": true, + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1678,6 +1967,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -1910,6 +2211,13 @@ "dev": true, "license": "ISC" }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT" + }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -2104,6 +2412,50 @@ "node": ">=8.0" } }, + "node_modules/trpc-cli": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/trpc-cli/-/trpc-cli-0.11.0.tgz", + "integrity": "sha512-cFt5LVl1EzwmhZtWa6xPBWr6rgLXGgEOqmcTMIYcI6fLQE1REgu6tS55LmqUJs5kVSXrOd1z5/aufJS71xUUyA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "commander": "^14.0.0" + }, + "bin": { + "trpc-cli": "dist/bin.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@orpc/server": "^1.0.0", + "@trpc/server": "^10.45.2 || ^11.0.1", + "@valibot/to-json-schema": "^1.1.0", + "effect": "^3.14.2 || ^4.0.0", + "valibot": "^1.1.0", + "zod": "^3.24.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "@orpc/server": { + "optional": true + }, + "@trpc/server": { + "optional": true + }, + "@valibot/to-json-schema": { + "optional": true + }, + "effect": { + "optional": true + }, + "valibot": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, "node_modules/ts-api-utils": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", @@ -2154,6 +2506,25 @@ "node": ">=14.17" } }, + "node_modules/ultracite": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/ultracite/-/ultracite-5.6.2.tgz", + "integrity": "sha512-ku0OhXCE8P7HZ8eUghFxhWroNiDTGv+5sKOgtyeTuOIvdfyyhVie5ijG/qFcleDOq5nvENolj/y51SJ5ddXbfw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "^0.11.0", + "@trpc/server": "^11.6.0", + "deepmerge": "^4.3.1", + "jsonc-parser": "^3.3.1", + "nypm": "^0.6.2", + "trpc-cli": "^0.11.0", + "zod": "^4.1.12" + }, + "bin": { + "ultracite": "dist/index.js" + } + }, "node_modules/undici-types": { "version": "7.12.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz", @@ -2440,6 +2811,16 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "dev": true, "license": "ISC" + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 9342017..b2e6dd2 100644 --- a/package.json +++ b/package.json @@ -112,16 +112,16 @@ "scripts": { "vscode:prepublish": "npm run compile", "compile": "tsc -p ./", - "lint": "eslint src --ext ts", + "check": "ultracite check", + "format": "ultracite fix", "test": "vitest run", "test:unit:watch": "vitest" }, "devDependencies": { + "@biomejs/biome": "^2.2.5", "@types/node": "24", "@types/vscode": "1.104.0", - "@typescript-eslint/eslint-plugin": "6.9.0", - "@typescript-eslint/parser": "6.9.0", - "eslint": "8.0.0", + "ultracite": "^5.6.2", "vitest": "3.2.4" }, "dependencies": { diff --git a/src/analyzer/__tests__/compute.test.ts b/src/analyzer/__tests__/compute.test.ts index 2bce087..295c9a0 100644 --- a/src/analyzer/__tests__/compute.test.ts +++ b/src/analyzer/__tests__/compute.test.ts @@ -1,8 +1,11 @@ -import { describe, it, expect } from 'vitest'; -import { computeHighlights } from '../../analyzer/compute'; -import type { RuntimeControls } from '../../analyzer/types'; +import { describe, expect, it } from "vitest"; +import { computeHighlights } from "../../analyzer/compute"; +import type { RuntimeControls } from "../../analyzer/types"; -async function alwaysResolve(): Promise { return true; } +// biome-ignore lint/suspicious/useAwait: test utility +async function alwaysResolve(): Promise { + return true; +} type Case = { title: string; @@ -16,8 +19,8 @@ type Case = { const cases: Case[] = [ { - title: 'local server function definition + await call + form action', - file: 'page.tsx', + title: "local server function definition + await call + form action", + file: "page.tsx", code: ` export default async function Page() { async function onSubmit(formData: FormData) { @@ -34,8 +37,8 @@ const cases: Case[] = [ expectCall: 2, }, { - title: 'top-level helpers are ignored (useEffect/useOptimistic/useRouter)', - file: 'helpers.tsx', + title: "top-level helpers are ignored (useEffect/useOptimistic/useRouter)", + file: "helpers.tsx", code: ` import { useEffect } from 'react'; import { useOptimistic } from 'react'; @@ -55,8 +58,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'module prologue + exported async function', - file: 'mod-prologue.tsx', + title: "module prologue + exported async function", + file: "mod-prologue.tsx", code: ` 'use server'; export async function doIt() { return 1; } @@ -71,7 +74,7 @@ const cases: Case[] = [ }, { title: "local const async arrow with 'use server' + direct call", - file: 'local-const.tsx', + file: "local-const.tsx", code: ` export default function P(){ const run = async () => { 'use server'; return 1; }; @@ -84,8 +87,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'optional chaining property call (obj?.run?.()) is not highlighted', - file: 'opt-chain.tsx', + title: "optional chaining property call (obj?.run?.()) is not highlighted", + file: "opt-chain.tsx", code: ` export default function P(){ const obj: any = { run: () => {} }; @@ -98,8 +101,8 @@ const cases: Case[] = [ expectCall: 0, }, { - title: 'direct call through nested wrappers is detected', - file: 'wrap.ts', + title: "direct call through nested wrappers is detected", + file: "wrap.ts", code: ` export default function P(){ const id = () => {}; @@ -112,8 +115,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'type-only import is excluded from call highlight', - file: 'types-only.ts', + title: "type-only import is excluded from call highlight", + file: "types-only.ts", code: ` import type { foo } from './x'; export default function P(){ foo(); return null; } @@ -123,8 +126,8 @@ const cases: Case[] = [ expectCall: 0, }, { - title: 'startTransition detects property access callee', - file: 'st-prop.tsx', + title: "startTransition detects property access callee", + file: "st-prop.tsx", code: ` export default function P(){ const run = () => {}; @@ -139,7 +142,7 @@ const cases: Case[] = [ }, { title: "element access call is not supported (no highlight)", - file: 'elem.ts', + file: "elem.ts", code: ` const obj: any = { run: () => {} }; export default function P(){ obj['run'](); return null; } @@ -150,7 +153,7 @@ const cases: Case[] = [ }, { title: "namespace import call is highlighted (ns.fn())", - file: 'ns.ts', + file: "ns.ts", code: ` import * as actions from './a'; export default function P(){ actions.submit(); return null; } @@ -160,8 +163,9 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'namespace import multi-level property is not highlighted (ns.group.fn())', - file: 'ns2.ts', + title: + "namespace import multi-level property is not highlighted (ns.group.fn())", + file: "ns2.ts", code: ` import * as actions from './a'; export default function P(){ actions.group.submit(); return null; } @@ -172,7 +176,7 @@ const cases: Case[] = [ }, { title: "async export without 'use server' is not a definition", - file: 'no-server.ts', + file: "no-server.ts", code: ` export async function maybeAction() { return 1 } export default async function Page(){ @@ -186,8 +190,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'global builtins (alert/console) are excluded', - file: 'builtin.tsx', + title: "global builtins (alert/console) are excluded", + file: "builtin.tsx", code: ` export default function Comp(){ alert('hello'); @@ -201,7 +205,7 @@ const cases: Case[] = [ }, { title: "non-async function with 'use server' is not a definition", - file: 'not-action.ts', + file: "not-action.ts", code: ` export function notAction(){ 'use server'; return 1; } export default function Page(){ notAction(); return null; } @@ -214,8 +218,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'builder/factory definition + direct call + form action', - file: 'builder.tsx', + title: "builder/factory definition + direct call + form action", + file: "builder.tsx", code: ` 'use server'; import { actionClient } from './safe-action'; @@ -234,8 +238,8 @@ const cases: Case[] = [ expectCall: 2, }, { - title: 'JSX inline server function definition + jsx call', - file: 'inline.tsx', + title: "JSX inline server function definition + jsx call", + file: "inline.tsx", code: ` export default function Page(){ return ( @@ -250,8 +254,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'imported server function call (no local definitions)', - file: 'admin.tsx', + title: "imported server function call (no local definitions)", + file: "admin.tsx", code: ` import { adminAction } from './action'; export default async function AdminPage(){ @@ -264,8 +268,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'startTransition + useActionState patterns', - file: 'comp.tsx', + title: "startTransition + useActionState patterns", + file: "comp.tsx", code: ` import { someAction } from './actions'; export default function Comp(){ @@ -279,8 +283,8 @@ const cases: Case[] = [ expectCall: 2, }, { - title: 'useTransition startTransition callback highlights server functions', - file: 'use-transition.tsx', + title: "useTransition startTransition callback highlights server functions", + file: "use-transition.tsx", code: ` import { useTransition } from 'react'; import { doThing } from './actions'; @@ -295,8 +299,9 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'useTransition async startTransition callback highlights server functions', - file: 'use-transition-async.tsx', + title: + "useTransition async startTransition callback highlights server functions", + file: "use-transition-async.tsx", code: ` import { useTransition } from 'react'; import { doThing } from './actions'; @@ -313,8 +318,8 @@ const cases: Case[] = [ expectCall: 1, }, { - title: 'ignoreCallees excludes imported call', - file: 'ignore-import.tsx', + title: "ignoreCallees excludes imported call", + file: "ignore-import.tsx", code: ` import { doThing } from './actions'; export default function C(){ @@ -325,11 +330,11 @@ const cases: Case[] = [ expectBody: 0, expectIcon: 0, expectCall: 0, - controls: { ignoreCallees: ['doThing'] }, + controls: { ignoreCallees: ["doThing"] }, }, { - title: 'ignoreCallees excludes local callable call', - file: 'ignore-local.tsx', + title: "ignoreCallees excludes local callable call", + file: "ignore-local.tsx", code: ` export default function P(){ async function doThing(){ 'use server'; return 1; } @@ -340,15 +345,24 @@ const cases: Case[] = [ expectBody: 1, expectIcon: 1, expectCall: 0, - controls: { ignoreCallees: ['doThing'] }, + controls: { ignoreCallees: ["doThing"] }, }, ]; -describe('highlight/computeHighlights (parameterized)', () => { - it.each(cases)('$title', async ({ code, file, expectBody, expectIcon, expectCall, controls }) => { - const res = await computeHighlights(code, file, `file:///${file}`, alwaysResolve, controls); - expect(res.bodyRanges.length).toBe(expectBody); - expect(res.iconRanges.length).toBe(expectIcon); - expect(res.callRanges.length).toBe(expectCall); - }); +describe("highlight/computeHighlights (parameterized)", () => { + it.each(cases)( + "$title", + async ({ code, file, expectBody, expectIcon, expectCall, controls }) => { + const res = await computeHighlights( + code, + file, + `file:///${file}`, + alwaysResolve, + controls + ); + expect(res.bodyRanges.length).toBe(expectBody); + expect(res.iconRanges.length).toBe(expectIcon); + expect(res.callRanges.length).toBe(expectCall); + } + ); }); diff --git a/src/analyzer/compute.ts b/src/analyzer/compute.ts index b3edb18..daef9c4 100644 --- a/src/analyzer/compute.ts +++ b/src/analyzer/compute.ts @@ -1,7 +1,13 @@ /** Highlight computation logic independent of VS Code. */ -import { scanServerFunctions } from '../core/definitions'; -import { scanCallSiteCandidates, collectImportedNames, collectLocalCallableNames, collectNamespaceImportNames } from '../core/calls'; -import type { ResolveFn, RuntimeControls } from './types'; + +import { + collectImportedNames, + collectLocalCallableNames, + collectNamespaceImportNames, + scanCallSiteCandidates, +} from "../core/calls"; +import { scanServerFunctions } from "../core/definitions"; +import type { ResolveFn, RuntimeControls } from "./types"; export type OffsetRange = { start: number; end: number }; @@ -11,13 +17,19 @@ export type OffsetRange = { start: number; end: number }; * - Filter: consider only imported or locally declared callables. * - Matching: keep only those that resolve to a Server Function via resolveFn (LSP-compatible). */ + +// biome-ignore lint: temporary ignore export async function computeHighlights( text: string, fileName: string, documentUri: string, resolveFn: ResolveFn, - controls?: RuntimeControls, -): Promise<{ bodyRanges: OffsetRange[]; iconRanges: OffsetRange[]; callRanges: OffsetRange[] }> { + controls?: RuntimeControls +): Promise<{ + bodyRanges: OffsetRange[]; + iconRanges: OffsetRange[]; + callRanges: OffsetRange[]; +}> { const spans = scanServerFunctions(text, fileName); const bodyRanges: OffsetRange[] = []; const iconRanges: OffsetRange[] = []; @@ -26,11 +38,11 @@ export async function computeHighlights( const startLine = lineStartOffset(text, s.bodyStart); const endLine = lineEndOffset(text, s.bodyEnd); bodyRanges.push({ start: startLine, end: endLine }); - if (text[s.bodyStart] === '{') { + if (text[s.bodyStart] === "{") { const endOfBraceLine = lineEndOffset(text, s.bodyStart); iconRanges.push({ start: endOfBraceLine, end: endOfBraceLine }); } - if (s.name && s.name !== '(inline)' && s.name !== 'default') { + if (s.name && s.name !== "(inline)" && s.name !== "default") { localFunctionNames.add(s.name); } } @@ -44,7 +56,10 @@ export async function computeHighlights( const add = (r: OffsetRange) => { const key = `${r.start}:${r.end}`; - if (!seen.has(key)) { seen.add(key); callRanges.push(r); } + if (!seen.has(key)) { + seen.add(key); + callRanges.push(r); + } }; // Order call candidates: visible range first (if provided) @@ -53,7 +68,11 @@ export async function computeHighlights( const outView: typeof calls = []; if (vr) { for (const c of calls) { - if (c.start <= vr.end && c.end >= vr.start) { inView.push(c); } else { outView.push(c); } + if (c.start <= vr.end && c.end >= vr.start) { + inView.push(c); + } else { + outView.push(c); + } } } const orderedCalls = vr ? [...inView, ...outView] : calls; @@ -63,7 +82,7 @@ export async function computeHighlights( maxConcurrent = 6, perPassBudgetMs = 2000, resolveTimeoutMs = 1500, - maxResolutions = 30 + maxResolutions = 30, } = controls?.bounds ?? {}; const useBounds = !!controls; @@ -75,16 +94,20 @@ export async function computeHighlights( const queue: Promise[] = []; const runWithTimeout = async (uri: string, off: number): Promise => { - if (!useBounds && !signal) { return resolveFn(uri, off); } + if (!(useBounds || signal)) { + return resolveFn(uri, off); + } return await Promise.race([ resolveFn(uri, off), - new Promise(res => setTimeout(() => res(false), resolveTimeoutMs)), - new Promise(res => { + new Promise((res) => + setTimeout(() => res(false), resolveTimeoutMs) + ), + new Promise((res) => { if (signal) { if (signal.aborted) { res(false); } else { - signal.addEventListener('abort', () => res(false), { once: true }); + signal.addEventListener("abort", () => res(false), { once: true }); } } }), @@ -96,17 +119,24 @@ export async function computeHighlights( // - When bounds are disabled, runs the task inline for simplicity. const schedule = async (task: () => Promise) => { // Fast path: no bounds configured → execute immediately. - if (!useBounds && !signal) { await task(); return; } + if (!(useBounds || signal)) { + await task(); + return; + } // If aborted, do not admit new tasks. - if (signal?.aborted) { return; } + if (signal?.aborted) { + return; + } // Backpressure: if the pool is full, wait until any in-flight task finishes. while (inFlight >= maxConcurrent) { // Wait for whichever promise settles first to free a slot. // eslint-disable-next-line no-await-in-loop await Promise.race(queue); - if (signal?.aborted) { return; } + if (signal?.aborted) { + return; + } } // Wrap the task to keep pool accounting correct. @@ -125,7 +155,9 @@ export async function computeHighlights( // Remove the settled promise from the queue to avoid unbounded growth. p.finally(() => { const idx = queue.indexOf(p); - if (idx >= 0) { queue.splice(idx, 1); } + if (idx >= 0) { + queue.splice(idx, 1); + } }); }; @@ -133,12 +165,25 @@ export async function computeHighlights( // Keep this conservative: core React hooks and Next.js client navigation only. const BUILTIN_IGNORED_CALLEES = new Set([ // React core hooks - 'useEffect', 'useLayoutEffect', 'useInsertionEffect', 'useMemo', 'useCallback', - 'useState', 'useReducer', 'useRef', 'useId', 'useSyncExternalStore', 'useDeferredValue', + "useEffect", + "useLayoutEffect", + "useInsertionEffect", + "useMemo", + "useCallback", + "useState", + "useReducer", + "useRef", + "useId", + "useSyncExternalStore", + "useDeferredValue", // Transitions / optimistic UI helpers - 'startTransition', 'useTransition', 'useOptimistic', + "startTransition", + "useTransition", + "useOptimistic", // Next.js navigation (client) - 'useRouter', 'usePathname', 'useSearchParams', + "useRouter", + "usePathname", + "useSearchParams", ]); // Merge with user-provided ignores from the extension layer. const IGNORED_CALLEES = new Set([ @@ -147,9 +192,17 @@ export async function computeHighlights( ]); for (const orderedCall of orderedCalls) { - if (signal?.aborted) { break; } - const site: OffsetRange = { start: orderedCall.start, end: orderedCall.end }; - if (orderedCall.kind === 'jsxAction' || orderedCall.kind === 'jsxFormAction') { + if (signal?.aborted) { + break; + } + const site: OffsetRange = { + start: orderedCall.start, + end: orderedCall.end, + }; + if ( + orderedCall.kind === "jsxAction" || + orderedCall.kind === "jsxFormAction" + ) { add(site); continue; } @@ -158,29 +211,54 @@ export async function computeHighlights( continue; } // Intra-file short-circuit: local server functions don't need LS - if (orderedCall.calleeName && localFunctionNames.has(orderedCall.calleeName)) { + if ( + orderedCall.calleeName && + localFunctionNames.has(orderedCall.calleeName) + ) { add(site); continue; } - if (orderedCall.calleeName && !imported.has(orderedCall.calleeName) && !locals.has(orderedCall.calleeName)) { + if ( + orderedCall.calleeName && + !imported.has(orderedCall.calleeName) && + !locals.has(orderedCall.calleeName) + ) { // Allow property access off a namespace import: ns.fn() - if (!orderedCall.qualifierName || !nsImports.has(orderedCall.qualifierName)) { + // biome-ignore lint/style/useCollapsedIf: temporary ignore + if ( + !(orderedCall.qualifierName && nsImports.has(orderedCall.qualifierName)) + ) { continue; } } - const inside = orderedCall.calleeName ? (orderedCall.start + Math.max(1, Math.floor(orderedCall.calleeName.length / 2))) : orderedCall.start; + const inside = orderedCall.calleeName + ? orderedCall.start + + Math.max(1, Math.floor(orderedCall.calleeName.length / 2)) + : orderedCall.start; // Pre-check bounds before scheduling to avoid enqueuing no-op tasks - if (useBounds && (resolutions >= maxResolutions || Date.now() - startedAt > perPassBudgetMs || signal?.aborted)) { + if ( + useBounds && + (resolutions >= maxResolutions || + Date.now() - startedAt > perPassBudgetMs || + signal?.aborted) + ) { break; } const resolveCallCandidate = async () => { const ok = await runWithTimeout(documentUri, inside); - if (ok) { add(site); } + if (ok) { + add(site); + } resolutions++; }; // eslint-disable-next-line no-await-in-loop await schedule(resolveCallCandidate); - if (useBounds && (resolutions >= maxResolutions || Date.now() - startedAt > perPassBudgetMs || signal?.aborted)) { + if ( + useBounds && + (resolutions >= maxResolutions || + Date.now() - startedAt > perPassBudgetMs || + signal?.aborted) + ) { break; } } @@ -188,7 +266,10 @@ export async function computeHighlights( // If aborted, do not block on outstanding tasks — but attach a catch handler // so late rejections don't surface as unhandled promise rejections. if (signal?.aborted) { - for (const p of queue) { void p.catch(() => {}); } + for (const p of queue) { + // biome-ignore lint: temporary ignore + void p.catch(() => {}); + } } else { // Take a snapshot: each promise removes itself from `queue` in its `finally` // handler above. Iterating the live array can skip entries. A copy ensures @@ -202,10 +283,12 @@ export async function computeHighlights( function lineStartOffset(text: string, at: number): number { let i = at; - while (i > 0 && text[i - 1] !== '\n') {i--;} + while (i > 0 && text[i - 1] !== "\n") { + i--; + } return i; } function lineEndOffset(text: string, at: number): number { - const idx = text.indexOf('\n', at); + const idx = text.indexOf("\n", at); return idx === -1 ? text.length : idx; } diff --git a/src/core/calls.ts b/src/core/calls.ts index d574ecf..029fe4d 100644 --- a/src/core/calls.ts +++ b/src/core/calls.ts @@ -1,4 +1,4 @@ -import ts from 'typescript'; +import ts from "typescript"; /** * Text range and kind of a call site (entry candidate). @@ -6,14 +6,19 @@ import ts from 'typescript'; * - start/end: character offset range of the expression (for directCall: from callee identifier start to the closing parenthesis) * - calleeName: identifier name (when obtainable) */ -export interface CallSiteSpan { - kind: 'jsxAction' | 'jsxFormAction' | 'directCall' | 'startTransition' | 'useActionState'; +export type CallSiteSpan = { + kind: + | "jsxAction" + | "jsxFormAction" + | "directCall" + | "startTransition" + | "useActionState"; start: number; end: number; calleeName?: string; /** Qualifier/base identifier name for property access calls, e.g., ns in ns.fn() */ qualifierName?: string; -} +}; /** * Extract Server Function call-site candidates from the current file. @@ -24,9 +29,18 @@ export interface CallSiteSpan { * - The first argument of useActionState(id, ...) * Ranges are defined per kind, and duplicates are suppressed using kind+start+end. */ -export function scanCallSiteCandidates(sourceText: string, fileName = 'file.tsx'): CallSiteSpan[] { - const kind = fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, kind); +export function scanCallSiteCandidates( + sourceText: string, + fileName = "file.tsx" +): CallSiteSpan[] { + const kind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sf = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + kind + ); const calls: CallSiteSpan[] = []; const spanOf = (n: ts.Node) => ({ start: n.getStart(sf), end: n.getEnd() }); @@ -35,23 +49,41 @@ export function scanCallSiteCandidates(sourceText: string, fileName = 'file.tsx' // Deduplicate by span only to avoid double-counting the same call // discovered via multiple paths (e.g., directCall vs startTransition). const key = `${c.start}:${c.end}`; - if (seen.has(key)) {return;} + if (seen.has(key)) { + return; + } seen.add(key); calls.push(c); }; /** Strip parentheses, non-null assertions, TS casts/assertions, and satisfies to reach the underlying expression. */ + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: temporary ignore const unwrap = (e: ts.Expression): ts.Expression => { let cur: ts.Expression = e; + const maxUnwrap = 8; // Unwrap repeatedly up to a safe bound to avoid pathological nesting - for (let i = 0; i < 8; i++) { - if (ts.isParenthesizedExpression(cur)) { cur = cur.expression; continue; } - if (ts.isNonNullExpression(cur)) { cur = cur.expression; continue; } - if (ts.isAsExpression(cur)) { cur = cur.expression; continue; } - if (ts.isTypeAssertionExpression(cur)) { cur = cur.expression; continue; } + for (let i = 0; i < maxUnwrap; i++) { + if (ts.isParenthesizedExpression(cur)) { + cur = cur.expression; + continue; + } + if (ts.isNonNullExpression(cur)) { + cur = cur.expression; + continue; + } + if (ts.isAsExpression(cur)) { + cur = cur.expression; + continue; + } + if (ts.isTypeAssertionExpression(cur)) { + cur = cur.expression; + continue; + } // satisfies operator (TS 4.9+) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if ((ts as any).isSatisfiesExpression && (ts as any).isSatisfiesExpression(cur)) { cur = (cur as any).expression; continue; } + if (ts.isSatisfiesExpression?.(cur)) { + cur = cur.expression; + continue; + } break; } return cur; @@ -60,44 +92,55 @@ export function scanCallSiteCandidates(sourceText: string, fileName = 'file.tsx' /** Get the callee identifier of a call expression (id / obj.id / tail name of a chain). */ const getCalleeIdent = (expr: ts.Expression): ts.Identifier | undefined => { const e = unwrap(expr); - if (ts.isIdentifier(e)) { return e; } + if (ts.isIdentifier(e)) { + return e; + } if (ts.isPropertyAccessExpression(e)) { const n = e.name; return ts.isIdentifier(n) ? n : undefined; } // Fallback for optional chaining or older TS where chain guards aren't available - const anyE: any = e as any; - if (anyE && anyE.name && ts.isIdentifier(anyE.name)) { + // biome-ignore lint/suspicious/noExplicitAny: temporary ignore + const anyE = e as any; + if (anyE?.name && ts.isIdentifier(anyE.name)) { return anyE.name as ts.Identifier; } - return undefined; + return; }; /** Get the base/qualifier identifier for a property access call (e.g., ns in ns.fn()). */ - const getQualifierIdent = (expr: ts.Expression): ts.Identifier | undefined => { + const getQualifierIdent = ( + expr: ts.Expression + ): ts.Identifier | undefined => { const e = unwrap(expr); if (ts.isPropertyAccessExpression(e)) { const base = e.expression; return ts.isIdentifier(base) ? base : undefined; } // Optional chaining property access (older TS fallback via duck typing) - const anyE: any = e as any; - if (anyE && anyE.expression && ts.isIdentifier(anyE.expression)) { + // biome-ignore lint/suspicious/noExplicitAny: temporary ignore + const anyE = e as any; + if (anyE?.expression && ts.isIdentifier(anyE.expression)) { return anyE.expression as ts.Identifier; } - return undefined; + return; }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: temporary ignore sf.forEachChild(function walk(node) { // JSX attributes: action / formAction if (ts.isJsxAttribute(node)) { const attrName = node.name.getText(sf); - if (attrName === 'action' || attrName === 'formAction') { + if (attrName === "action" || attrName === "formAction") { const init = node.initializer; if (init && ts.isJsxExpression(init) && init.expression) { const expr = init.expression; const { start, end } = spanOf(expr); - push({ kind: attrName === 'action' ? 'jsxAction' : 'jsxFormAction', start, end }); + push({ + kind: attrName === "action" ? "jsxAction" : "jsxFormAction", + start, + end, + }); } } } @@ -106,17 +149,23 @@ export function scanCallSiteCandidates(sourceText: string, fileName = 'file.tsx' if (ts.isCallExpression(node)) { const callee = node.expression; const id = getCalleeIdent(callee); - const calleeText = id?.text ?? ''; + const calleeText = id?.text ?? ""; if (id) { const { start } = spanOf(id); // Highlight from identifier through the end of the call (includes parentheses and args) const end = node.getEnd(); const qualifier = getQualifierIdent(callee); - push({ kind: 'directCall', start, end, calleeName: calleeText, qualifierName: qualifier?.text }); + push({ + kind: "directCall", + start, + end, + calleeName: calleeText, + qualifierName: qualifier?.text, + }); } // startTransition(() => id(...)) - if (calleeText === 'startTransition' && node.arguments.length > 0) { + if (calleeText === "startTransition" && node.arguments.length > 0) { const arg = node.arguments[0]; if (ts.isArrowFunction(arg) || ts.isFunctionExpression(arg)) { const visit = (n: ts.Node) => { @@ -126,7 +175,13 @@ export function scanCallSiteCandidates(sourceText: string, fileName = 'file.tsx' const { start } = spanOf(ident); const end = n.getEnd(); const qualifier = getQualifierIdent(n.expression); - push({ kind: 'startTransition', start, end, calleeName: ident.text, qualifierName: qualifier?.text }); + push({ + kind: "startTransition", + start, + end, + calleeName: ident.text, + qualifierName: qualifier?.text, + }); } } ts.forEachChild(n, visit); @@ -136,11 +191,11 @@ export function scanCallSiteCandidates(sourceText: string, fileName = 'file.tsx' } // useActionState(id, ...) - if (calleeText === 'useActionState' && node.arguments.length > 0) { + if (calleeText === "useActionState" && node.arguments.length > 0) { const first = node.arguments[0]; if (ts.isIdentifier(first)) { const { start, end } = spanOf(first); - push({ kind: 'useActionState', start, end, calleeName: first.text }); + push({ kind: "useActionState", start, end, calleeName: first.text }); } } } @@ -156,23 +211,43 @@ export function scanCallSiteCandidates(sourceText: string, fileName = 'file.tsx' * - Collects default imports and named imports (including aliases). * - Excludes namespace imports (import * as ns) since calleeName becomes a property name. */ -export function collectImportedNames(sourceText: string, fileName = 'file.tsx'): Set { - const kind = fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, kind); +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: temporary ignore +export function collectImportedNames( + sourceText: string, + fileName = "file.tsx" +): Set { + const kind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + + const sf = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + kind + ); const names = new Set(); for (const s of sf.statements) { - if (!ts.isImportDeclaration(s) || !s.importClause) {continue;} + if (!(ts.isImportDeclaration(s) && s.importClause)) { + continue; + } const ic = s.importClause; // Skip entirely if this is a type-only import clause: import type { X } from '...' - if (ic.isTypeOnly) { continue; } - if (ic.name) {names.add(ic.name.text);} // default import local name + if (ic.isTypeOnly) { + continue; + } + if (ic.name) { + names.add(ic.name.text); + } // default import local name if (ic.namedBindings) { + // biome-ignore lint/style/useCollapsedIf: temporary ignore if (ts.isNamedImports(ic.namedBindings)) { for (const el of ic.namedBindings.elements) { // Skip type-only specifiers: import { type X as Y } // `isTypeOnly` is available on ImportSpecifier in TS 4.5+ - if ((el as ts.ImportSpecifier).isTypeOnly) { continue; } + if ((el as ts.ImportSpecifier).isTypeOnly) { + continue; + } names.add(el.name.text); // local binding name (alias or original) } } @@ -187,16 +262,26 @@ export function collectImportedNames(sourceText: string, fileName = 'file.tsx'): * - Function declarations * - ArrowFunction / FunctionExpression assigned to variable declarations */ -export function collectLocalCallableNames(sourceText: string, fileName = 'file.tsx'): Set { - const kind = fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, kind); +export function collectLocalCallableNames( + sourceText: string, + fileName = "file.tsx" +): Set { + const kind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + const sf = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + kind + ); const names = new Set(); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: temporary ignore sf.forEachChild(function walk(node) { if (ts.isFunctionDeclaration(node) && node.name) { names.add(node.name.text); } - if (ts.isVariableStatement(node)) { + if (ts.isVariableStatement(node)) { for (const decl of node.declarationList.declarations) { if (ts.isIdentifier(decl.name)) { const init = decl.initializer; @@ -232,15 +317,28 @@ export function collectLocalCallableNames(sourceText: string, fileName = 'file.t /** * Collect namespace import local identifiers: import * as ns from '...'; => add 'ns'. */ -export function collectNamespaceImportNames(sourceText: string, fileName = 'file.tsx'): Set { - const kind = fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; +export function collectNamespaceImportNames( + sourceText: string, + fileName = "file.tsx" +): Set { + const kind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, kind); + const sf = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + kind + ); const names = new Set(); for (const s of sf.statements) { - if (!ts.isImportDeclaration(s) || !s.importClause) { continue; } + if (!(ts.isImportDeclaration(s) && s.importClause)) { + continue; + } const ic = s.importClause; - if (ic.isTypeOnly) { continue; } + if (ic.isTypeOnly) { + continue; + } if (ic.namedBindings && ts.isNamespaceImport(ic.namedBindings)) { names.add(ic.namedBindings.name.text); } diff --git a/src/core/definitions.ts b/src/core/definitions.ts index 9fa176f..e7cedb2 100644 --- a/src/core/definitions.ts +++ b/src/core/definitions.ts @@ -1,4 +1,4 @@ -import ts from 'typescript'; +import ts from "typescript"; /** * Information representing a detected Server Function. @@ -6,13 +6,13 @@ import ts from 'typescript'; * - bodyStart/bodyEnd: Character offset range for the function body (converted to VS Code positions by the caller) * - nameStart/nameEnd: Offset range of the definition identifier (undefined if absent) */ -export interface ServerFunctionSpan { +export type ServerFunctionSpan = { name: string; bodyStart: number; bodyEnd: number; nameStart?: number; nameEnd?: number; -} +}; /** * Detect Server Function candidates from the given source. @@ -25,36 +25,74 @@ export interface ServerFunctionSpan { * - Async function literals inside initializers (builder/factory arguments) * - Inline async () => { 'use server' } in JSX */ -export function scanServerFunctions(sourceText: string, fileName = 'file.tsx'): ServerFunctionSpan[] { - const kind = fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS; - - const sf = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, kind); +export function scanServerFunctions( + sourceText: string, + fileName = "file.tsx" +): ServerFunctionSpan[] { + const kind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + + const sf = ts.createSourceFile( + fileName, + sourceText, + ts.ScriptTarget.Latest, + true, + kind + ); const moduleHasUseServer = isUseServerPrologue(sf.statements); const serverFunctions: ServerFunctionSpan[] = []; const seen = new Set(); - const pushFunction = (name: string, start: number, end: number, nameStart?: number, nameEnd?: number) => { + const pushFunction = ({ + name, + span: { start, end }, + nameStart, + nameEnd, + }: { + name: string; + span: { start: number; end: number }; + nameStart?: number; + nameEnd?: number; + }) => { const key = `${start}:${end}`; - if (seen.has(key)) {return;} + if (seen.has(key)) { + return; + } seen.add(key); - serverFunctions.push({ name, bodyStart: start, bodyEnd: end, nameStart, nameEnd }); + serverFunctions.push({ + name, + bodyStart: start, + bodyEnd: end, + nameStart, + nameEnd, + }); }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: temporary ignore sf.forEachChild(function walk(node) { // function declarations (exported or local) if (ts.isFunctionDeclaration(node)) { const fn = node; const exported = isExported(node.modifiers); - const eligible = isAsync(fn) && ((exported && (moduleHasUseServer || hasUseServerInFunctionBody(fn))) || (!exported && hasUseServerInFunctionBody(fn))); + const eligible = + isAsync(fn) && + ((exported && (moduleHasUseServer || hasUseServerInFunctionBody(fn))) || + (!exported && hasUseServerInFunctionBody(fn))); if (eligible) { const span = getBodySpan(sf, fn); if (span) { const nameId = node.name; - const name = nameId?.text ?? (exported && isDefault(node.modifiers) ? 'default' : '(anonymous)'); + const name = + nameId?.text ?? + (exported && isDefault(node.modifiers) ? "default" : "(anonymous)"); const nameStart = nameId ? nameId.getStart(sf) : undefined; const nameEnd = nameId ? nameId.getEnd() : undefined; - pushFunction(name, span.start, span.end, nameStart, nameEnd); + pushFunction({ + name, + span: { start: span.start, end: span.end }, + nameStart, + nameEnd, + }); } } } @@ -70,24 +108,43 @@ export function scanServerFunctions(sourceText: string, fileName = 'file.tsx'): const init = decl.initializer; // direct function/arrow assignment if (ts.isArrowFunction(init) || ts.isFunctionExpression(init)) { - const eligible = isAsync(init) && ((exported && (moduleHasUseServer || hasUseServerInFunctionBody(init))) || (!exported && hasUseServerInFunctionBody(init))); + const eligible = + isAsync(init) && + ((exported && + (moduleHasUseServer || hasUseServerInFunctionBody(init))) || + (!exported && hasUseServerInFunctionBody(init))); if (eligible) { const span = getBodySpan(sf, init); if (span) { - pushFunction(exportName, span.start, span.end, exportNameStart, exportNameEnd); + pushFunction({ + name: exportName, + span: { start: span.start, end: span.end }, + nameStart: exportNameStart, + nameEnd: exportNameEnd, + }); } } } // nested: walk any expression tree and pick async function literals (builder/factory) + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: temporary ignore const visit = (n: ts.Node) => { if (ts.isArrowFunction(n) || ts.isFunctionExpression(n)) { const fn = n; - const eligible = isAsync(fn) && ((exported && (moduleHasUseServer || hasUseServerInFunctionBody(fn))) || (!exported && hasUseServerInFunctionBody(fn))); + const eligible = + isAsync(fn) && + ((exported && + (moduleHasUseServer || hasUseServerInFunctionBody(fn))) || + (!exported && hasUseServerInFunctionBody(fn))); if (eligible) { const span = getBodySpan(sf, fn); if (span) { - pushFunction(exportName, span.start, span.end, exportNameStart, exportNameEnd); + pushFunction({ + name: exportName, + span: { start: span.start, end: span.end }, + nameStart: exportNameStart, + nameEnd: exportNameEnd, + }); } } } @@ -104,7 +161,10 @@ export function scanServerFunctions(sourceText: string, fileName = 'file.tsx'): if (isAsync(fn) && hasUseServerInFunctionBody(fn)) { const span = getBodySpan(sf, fn); if (span) { - pushFunction('(inline)', span.start, span.end); + pushFunction({ + name: "(inline)", + span: { start: span.start, end: span.end }, + }); } } } @@ -121,7 +181,9 @@ export function scanServerFunctions(sourceText: string, fileName = 'file.tsx'): function isUseServerPrologue(statements: ts.NodeArray): boolean { for (const s of statements) { if (ts.isExpressionStatement(s) && ts.isStringLiteralLike(s.expression)) { - if (s.expression.text === 'use server') {return true;} + if (s.expression.text === "use server") { + return true; + } continue; } break; @@ -133,33 +195,49 @@ function isUseServerPrologue(statements: ts.NodeArray): boolean { * Check if the function body starts with a 'use server' directive. * Arrow functions with expression bodies cannot have directives, so this returns false in that case. */ -function hasUseServerInFunctionBody(fn: ts.FunctionLikeDeclarationBase): boolean { - if (!fn.body) {return false;} - if (ts.isBlock(fn.body)) {return isUseServerPrologue(fn.body.statements);} +function hasUseServerInFunctionBody( + fn: ts.FunctionLikeDeclarationBase +): boolean { + if (!fn.body) { + return false; + } + if (ts.isBlock(fn.body)) { + return isUseServerPrologue(fn.body.statements); + } return false; } /** Whether the export modifier is present. */ function isExported(mods?: readonly ts.ModifierLike[]): boolean { - return !!mods?.some(m => m.kind === ts.SyntaxKind.ExportKeyword); + return !!mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword); } /** Whether the default modifier is present. */ function isDefault(mods?: readonly ts.ModifierLike[]): boolean { - return !!mods?.some(m => m.kind === ts.SyntaxKind.DefaultKeyword); + return !!mods?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword); } /** Whether the async modifier is present. */ function isAsync(node: ts.Node): boolean { - return (ts.getCombinedModifierFlags(node as ts.Declaration) & ts.ModifierFlags.Async) !== 0; + return ( + // biome-ignore lint/suspicious/noBitwiseOperators: temporary ignore + (ts.getCombinedModifierFlags(node as ts.Declaration) & + ts.ModifierFlags.Async) !== + 0 + ); } /** * Return the text range of the function body (entire block including braces). */ -function getBodySpan(sf: ts.SourceFile, fn: ts.FunctionLikeDeclarationBase): { start: number; end: number } | undefined { +function getBodySpan( + sf: ts.SourceFile, + fn: ts.FunctionLikeDeclarationBase +): { start: number; end: number } | undefined { const body = fn.body; - if (!body) {return undefined;} + if (!body) { + return; + } const start = body.getStart(sf); const end = body.getEnd(); return { start, end }; diff --git a/src/extension.ts b/src/extension.ts index 758c360..1d47ea7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,9 +1,6 @@ -import * as vscode from 'vscode'; -import { registerVisualizer } from './extension/controller'; +import type * as vscode from "vscode"; +import { registerVisualizer } from "./extension/controller"; export function activate(context: vscode.ExtensionContext) { - console.log('nextjs-server-functions-visualizer activated'); registerVisualizer(context); } - -export function deactivate() {} diff --git a/src/extension/controller.ts b/src/extension/controller.ts index ce03dc7..b61d680 100644 --- a/src/extension/controller.ts +++ b/src/extension/controller.ts @@ -1,7 +1,12 @@ -import * as vscode from 'vscode'; -import { createDecorations, disposeDecorations, type Decorations } from './decorator'; -import { buildUpdateEditor } from './updater'; -import { makeVsCodeResolveFn } from './resolver'; +// biome-ignore lint/performance/noNamespaceImport: cannot import vscode as namespace +import * as vscode from "vscode"; +import { + createDecorations, + type Decorations, + disposeDecorations, +} from "./decorator"; +import { makeVsCodeResolveFn } from "./resolver"; +import { buildUpdateEditor } from "./updater"; /** * Register highlighting and wire the decoration lifecycle and events. @@ -10,7 +15,11 @@ import { makeVsCodeResolveFn } from './resolver'; */ export function registerVisualizer(context: vscode.ExtensionContext) { let decorations: Decorations = createDecorations(); - context.subscriptions.push(decorations.body, decorations.call, decorations.icon); + context.subscriptions.push( + decorations.body, + decorations.call, + decorations.icon + ); const getDecorations = () => decorations; const resolveFn = makeVsCodeResolveFn(); @@ -18,8 +27,13 @@ export function registerVisualizer(context: vscode.ExtensionContext) { let timer: NodeJS.Timeout | undefined; const scheduleUpdate = () => { - if (timer) { clearTimeout(timer); } - timer = setTimeout(() => { void updateEditor(vscode.window.activeTextEditor); }, 200); + if (timer) { + clearTimeout(timer); + } + const interval = 200; + timer = setTimeout(() => { + updateEditor(vscode.window.activeTextEditor); + }, interval); }; // Initial render @@ -28,29 +42,47 @@ export function registerVisualizer(context: vscode.ExtensionContext) { // Wire events context.subscriptions.push( vscode.window.onDidChangeActiveTextEditor(() => scheduleUpdate()), - vscode.workspace.onDidChangeTextDocument(e => { + vscode.workspace.onDidChangeTextDocument((e) => { const active = vscode.window.activeTextEditor; - if (active && e.document === active.document) {scheduleUpdate();} + if (active && e.document === active.document) { + scheduleUpdate(); + } }), - vscode.workspace.onDidOpenTextDocument(doc => { + vscode.workspace.onDidOpenTextDocument((doc) => { const active = vscode.window.activeTextEditor; - if (active && doc === active.document) {scheduleUpdate();} + if (active && doc === active.document) { + scheduleUpdate(); + } }), vscode.window.onDidChangeActiveColorTheme(() => { disposeDecorations(decorations); decorations = createDecorations(); - context.subscriptions.push(decorations.body, decorations.call, decorations.icon); + context.subscriptions.push( + decorations.body, + decorations.call, + decorations.icon + ); scheduleUpdate(); }), - vscode.workspace.onDidChangeConfiguration(e => { + vscode.workspace.onDidChangeConfiguration((e) => { if ( - e.affectsConfiguration('nextjs-server-functions-visualizer.highlight.definition') || - e.affectsConfiguration('nextjs-server-functions-visualizer.highlight.call') || - e.affectsConfiguration('nextjs-server-functions-visualizer.calls.ignoreCallees') + e.affectsConfiguration( + "nextjs-server-functions-visualizer.highlight.definition" + ) || + e.affectsConfiguration( + "nextjs-server-functions-visualizer.highlight.call" + ) || + e.affectsConfiguration( + "nextjs-server-functions-visualizer.calls.ignoreCallees" + ) ) { disposeDecorations(decorations); decorations = createDecorations(); - context.subscriptions.push(decorations.body, decorations.call, decorations.icon); + context.subscriptions.push( + decorations.body, + decorations.call, + decorations.icon + ); scheduleUpdate(); } }) diff --git a/src/extension/decorator.ts b/src/extension/decorator.ts index a470783..cb2a4bd 100644 --- a/src/extension/decorator.ts +++ b/src/extension/decorator.ts @@ -1,4 +1,5 @@ -import * as vscode from 'vscode'; +// biome-ignore lint/performance/noNamespaceImport: cannot import vscode as namespace +import * as vscode from "vscode"; /** * A bundle of decorations applied to the editor. @@ -16,10 +17,15 @@ export type Decorations = { */ function pickByTheme(light: string, dark: string, hc: string): string { const kind = vscode.window.activeColorTheme.kind; - if (kind === vscode.ColorThemeKind.Dark) { return dark; } + if (kind === vscode.ColorThemeKind.Dark) { + return dark; + } // HighContrastLight is not in all versions; compare by value if present // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (kind === vscode.ColorThemeKind.HighContrast || (vscode.ColorThemeKind as any).HighContrastLight === kind) { + if ( + kind === vscode.ColorThemeKind.HighContrast || + vscode.ColorThemeKind.HighContrastLight === kind + ) { return hc; } return light; @@ -30,16 +36,45 @@ function pickByTheme(light: string, dark: string, hc: string): string { * Colors are selected from settings (light/dark/high-contrast) and the current theme. */ export function createDecorations(): Decorations { - const cfg = vscode.workspace.getConfiguration('nextjs-server-functions-visualizer'); - const defLight = cfg.get('highlight.definition.tintLight', 'rgba(138, 99, 255, 0.14)'); - const defDark = cfg.get('highlight.definition.tintDark', 'rgba(138, 99, 255, 0.18)'); - const defHC = cfg.get('highlight.definition.tintHighContrast', 'rgba(138, 99, 255, 0.22)'); - const callLight = cfg.get('highlight.call.tintLight', 'rgba(118, 129, 255, 0.14)'); - const callDark = cfg.get('highlight.call.tintDark', 'rgba(118, 129, 255, 0.18)'); - const callHC = cfg.get('highlight.call.tintHighContrast', 'rgba(118, 129, 255, 0.22)'); - const underlineLight = cfg.get('highlight.call.underlineColorLight', 'rgba(118, 129, 255, 0.85)'); - const underlineDark = cfg.get('highlight.call.underlineColorDark', 'rgba(118, 129, 255, 0.85)'); - const underlineHC = cfg.get('highlight.call.underlineColorHighContrast', 'rgba(118, 129, 255, 0.9)'); + const cfg = vscode.workspace.getConfiguration( + "nextjs-server-functions-visualizer" + ); + const defLight = cfg.get( + "highlight.definition.tintLight", + "rgba(138, 99, 255, 0.14)" + ); + const defDark = cfg.get( + "highlight.definition.tintDark", + "rgba(138, 99, 255, 0.18)" + ); + const defHC = cfg.get( + "highlight.definition.tintHighContrast", + "rgba(138, 99, 255, 0.22)" + ); + const callLight = cfg.get( + "highlight.call.tintLight", + "rgba(118, 129, 255, 0.14)" + ); + const callDark = cfg.get( + "highlight.call.tintDark", + "rgba(118, 129, 255, 0.18)" + ); + const callHC = cfg.get( + "highlight.call.tintHighContrast", + "rgba(118, 129, 255, 0.22)" + ); + const underlineLight = cfg.get( + "highlight.call.underlineColorLight", + "rgba(118, 129, 255, 0.85)" + ); + const underlineDark = cfg.get( + "highlight.call.underlineColorDark", + "rgba(118, 129, 255, 0.85)" + ); + const underlineHC = cfg.get( + "highlight.call.underlineColorHighContrast", + "rgba(118, 129, 255, 0.9)" + ); const defTint = pickByTheme(defLight, defDark, defHC); const callTint = pickByTheme(callLight, callDark, callHC); @@ -55,15 +90,15 @@ export function createDecorations(): Decorations { // Call sites get an underline over the same range as the background textDecoration: `underline solid ${underline}`, after: { - contentText: '🚪', - margin: '0 0 0 0.25rem', + contentText: "🚪", + margin: "0 0 0 0.25rem", }, }); const icon = vscode.window.createTextEditorDecorationType({ isWholeLine: false, after: { - contentText: '🌐', - margin: '0 0 0 0.25rem', + contentText: "🌐", + margin: "0 0 0 0.25rem", }, }); diff --git a/src/extension/exclude.ts b/src/extension/exclude.ts index 792d7dd..0199294 100644 --- a/src/extension/exclude.ts +++ b/src/extension/exclude.ts @@ -1,4 +1,5 @@ -import * as vscode from 'vscode'; +// biome-ignore lint/performance/noNamespaceImport: cannot import vscode as namespace +import * as vscode from "vscode"; function compilePatterns(patterns: string[]): RegExp[] { const regs: RegExp[] = []; @@ -13,21 +14,25 @@ function compilePatterns(patterns: string[]): RegExp[] { } export function getExcludeRegexes(): RegExp[] { - const cfg = vscode.workspace.getConfiguration('nextjs-server-functions-visualizer'); - const arr = cfg.get('files.exclude', [ - '\\.(stories|story)\\.[tj]sx?$', - '\\.(test|spec)\\.[tj]sx?$', - '/__tests__/', - '/\\.storybook/', + const cfg = vscode.workspace.getConfiguration( + "nextjs-server-functions-visualizer" + ); + const arr = cfg.get("files.exclude", [ + "\\.(stories|story)\\.[tj]sx?$", + "\\.(test|spec)\\.[tj]sx?$", + "/__tests__/", + "/\\.storybook/", ]); return compilePatterns(Array.isArray(arr) ? arr : []); } export function isExcludedFileName(fileName: string, regs?: RegExp[]): boolean { const patterns = regs ?? getExcludeRegexes(); - const path = fileName.replace(/\\/g, '/'); + const path = fileName.replace(/\\/g, "/"); for (const r of patterns) { - if (r.test(path)) { return true; } + if (r.test(path)) { + return true; + } } return false; } diff --git a/src/extension/resolver.ts b/src/extension/resolver.ts index 1e77d72..d3be15d 100644 --- a/src/extension/resolver.ts +++ b/src/extension/resolver.ts @@ -1,45 +1,75 @@ -import * as vscode from 'vscode'; -import { scanServerFunctions } from '../core/definitions'; -import type { ResolveFn } from '../analyzer/types'; -import { isExcludedUri } from './exclude'; +// biome-ignore lint/performance/noNamespaceImport: cannot import vscode as namespace +import * as vscode from "vscode"; +import type { ResolveFn } from "../analyzer/types"; +import { scanServerFunctions } from "../core/definitions"; +import { isExcludedUri } from "./exclude"; /** * Create a ResolveFn backed by VS Code's TypeScript Language Service. * Keeps the VS Code dependency isolated here; upper layers (highlight/updater) receive it via DI. */ export function makeVsCodeResolveFn(): ResolveFn { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: temporary ignore return async (uri: string, offset: number): Promise => { type QueueItem = { uri: string; offset: number }; const visited = new Set(); const queue: QueueItem[] = [{ uri, offset }]; let hops = 0; - while (queue.length && hops < 3) { + const maxHops = 3; + while (queue.length && hops < maxHops) { hops++; - const { uri: curUri, offset: curOffset } = queue.shift()!; + const cur = queue.shift(); + if (!cur) { + break; + } + const { uri: curUri, offset: curOffset } = cur; const key = `${curUri}@${curOffset}`; - if (visited.has(key)) { continue; } + if (visited.has(key)) { + continue; + } visited.add(key); - const doc = await vscode.workspace.openTextDocument(vscode.Uri.parse(curUri)); + const doc = await vscode.workspace.openTextDocument( + vscode.Uri.parse(curUri) + ); const position = doc.positionAt(curOffset); - const defs = await vscode.commands.executeCommand('vscode.executeDefinitionProvider', doc.uri, position); - const impls = await vscode.commands.executeCommand('vscode.executeImplementationProvider', doc.uri, position); + const defs = await vscode.commands.executeCommand< + vscode.Location[] | undefined + >("vscode.executeDefinitionProvider", doc.uri, position); + const impls = await vscode.commands.executeCommand< + vscode.Location[] | undefined + >("vscode.executeImplementationProvider", doc.uri, position); const all: vscode.Location[] = []; - if (defs?.length) {all.push(...defs);} - if (impls?.length) {all.push(...impls);} - if (!all.length) {continue;} + if (defs?.length) { + all.push(...defs); + } + if (impls?.length) { + all.push(...impls); + } + if (!all.length) { + continue; + } for (const loc of all) { try { // Skip excluded targets early - if (isExcludedUri(loc.uri)) { continue; } + if (isExcludedUri(loc.uri)) { + continue; + } const targetDoc = await vscode.workspace.openTextDocument(loc.uri); const targetText = targetDoc.getText(); - const serverFunctions = scanServerFunctions(targetText, targetDoc.fileName); + const serverFunctions = scanServerFunctions( + targetText, + targetDoc.fileName + ); const defOffset = targetDoc.offsetAt(loc.range.start); for (const sf of serverFunctions) { - const inName = sf.nameStart !== undefined && sf.nameEnd !== undefined && defOffset >= sf.nameStart && defOffset <= sf.nameEnd; + const inName = + sf.nameStart !== undefined && + sf.nameEnd !== undefined && + defOffset >= sf.nameStart && + defOffset <= sf.nameEnd; const inBody = defOffset >= sf.bodyStart && defOffset <= sf.bodyEnd; if (inName || inBody) { return true; diff --git a/src/extension/updater.ts b/src/extension/updater.ts index 2cd220e..a10f074 100644 --- a/src/extension/updater.ts +++ b/src/extension/updater.ts @@ -1,10 +1,11 @@ -import * as vscode from 'vscode'; -import type { Decorations } from './decorator'; -import type { ResolveFn } from '../analyzer/types'; -import { computeHighlights } from '../analyzer/compute'; -import { isExcludedFileName } from './exclude'; +// biome-ignore lint/performance/noNamespaceImport: cannot import vscode as namespace +import * as vscode from "vscode"; +import { computeHighlights } from "../analyzer/compute"; +import type { ResolveFn } from "../analyzer/types"; +import type { Decorations } from "./decorator"; +import { isExcludedFileName } from "./exclude"; -const SUPPORTED = new Set(['typescript', 'typescriptreact']); +const SUPPORTED = new Set(["typescript", "typescriptreact"]); /** * Build a function that updates decorations for the active editor. @@ -12,15 +13,26 @@ const SUPPORTED = new Set(['typescript', 'typescriptreact']); * - Call sites: extract candidates → pre-filter (imports/locals) → resolve via LS → if it matches a Server Function, highlight the expression range (with 🚪). * - Merge duplicate ranges to avoid double drawing. */ -export function buildUpdateEditor(getDecorations: () => Decorations, resolveFn: ResolveFn) { +export function buildUpdateEditor( + getDecorations: () => Decorations, + resolveFn: ResolveFn +) { let seq = 0; let currentAbort: AbortController | undefined; - return async function updateEditor(editor?: vscode.TextEditor): Promise { + return async function updateEditor( + editor?: vscode.TextEditor + ): Promise { const runId = ++seq; // Abort previous run (if any) and create a fresh controller for this run. - try { currentAbort?.abort(); } catch { /* noop */ } + try { + currentAbort?.abort(); + } catch { + /* noop */ + } currentAbort = new AbortController(); - if (!editor) {return;} + if (!editor) { + return; + } const { document } = editor; const { body, call, icon } = getDecorations(); if (!SUPPORTED.has(document.languageId)) { @@ -40,31 +52,68 @@ export function buildUpdateEditor(getDecorations: () => Decorations, resolveFn: const text = document.getText(); const visible = editor.visibleRanges[0]; - const visibleRange = visible ? { start: document.offsetAt(visible.start), end: document.offsetAt(visible.end) } : undefined; - const cfg = vscode.workspace.getConfiguration('nextjs-server-functions-visualizer'); + const visibleRange = visible + ? { + start: document.offsetAt(visible.start), + end: document.offsetAt(visible.end), + } + : undefined; + const cfg = vscode.workspace.getConfiguration( + "nextjs-server-functions-visualizer" + ); // Support both the new key and the previous one for compatibility. - const ignoreCallees = cfg.get('calls.ignoreCallees') ?? []; + const ignoreCallees = cfg.get("calls.ignoreCallees") ?? []; const { bodyRanges, iconRanges, callRanges } = await computeHighlights( text, document.fileName, document.uri.toString(), resolveFn, visibleRange - ? { visibleRange, bounds: { maxConcurrent: 6, perPassBudgetMs: 2000, resolveTimeoutMs: 1500, maxResolutions: 30 }, signal: currentAbort.signal, ignoreCallees } - : { signal: currentAbort.signal, ignoreCallees }, + ? { + visibleRange, + bounds: { + maxConcurrent: 6, + perPassBudgetMs: 2000, + resolveTimeoutMs: 1500, + maxResolutions: 30, + }, + signal: currentAbort.signal, + ignoreCallees, + } + : { signal: currentAbort.signal, ignoreCallees } ); - if (runId !== seq) { return; } + if (runId !== seq) { + return; + } editor.setDecorations( body, - bodyRanges.map(r => new vscode.Range(document.positionAt(r.start), document.positionAt(r.end))) + bodyRanges.map( + (r) => + new vscode.Range( + document.positionAt(r.start), + document.positionAt(r.end) + ) + ) ); editor.setDecorations( icon, - iconRanges.map(r => new vscode.Range(document.positionAt(r.start), document.positionAt(r.end))) + iconRanges.map( + (r) => + new vscode.Range( + document.positionAt(r.start), + document.positionAt(r.end) + ) + ) ); editor.setDecorations( call, - callRanges.map(r => new vscode.Range(document.positionAt(r.start), document.positionAt(r.end))) + callRanges.map( + (r) => + new vscode.Range( + document.positionAt(r.start), + document.positionAt(r.end) + ) + ) ); }; } diff --git a/tsconfig.json b/tsconfig.json index 137bdec..246cc0c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,26 +1,20 @@ { - "compilerOptions": { - "module": "nodenext", - "target": "esnext", - "outDir": "dist", - "lib": [ - "esnext" - ], - "sourceMap": true, - "rootDir": "src", - "strict": true, - "jsx": "react-jsx", + "compilerOptions": { + "module": "nodenext", + "target": "esnext", + "outDir": "dist", + "lib": ["esnext"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "jsx": "react-jsx", "isolatedModules": true, "noUncheckedSideEffectImports": true, "moduleDetection": "force", "skipLibCheck": true, - "noUnusedParameters": true, - }, - "include": [ - "src" - ], - "exclude": [ - "node_modules", - "dist", - ] + "noUnusedParameters": true, + "strictNullChecks": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] } diff --git a/vitest.config.ts b/vitest.config.ts index 98fee8a..477bf66 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,3 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from "vitest/config"; -export default defineConfig({ - test: { - }, -}); \ No newline at end of file +export default defineConfig({ test: {} });