Repository: marcoroth/stimulus-lsp Branch: main Commit: ec584d90f48a Files: 50 Total size: 116.3 KB Directory structure: gitextract_y0vvpjxj/ ├── .eslintignore ├── .eslintrc ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── build.yml │ └── publish.yml ├── .gitignore ├── .node-version ├── .prettierrc.json ├── .vscode/ │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── .vscodeignore ├── LICENSE.txt ├── README.md ├── client/ │ ├── package.json │ ├── src/ │ │ ├── client.ts │ │ ├── controller_tree_view.ts │ │ ├── extension.ts │ │ └── requests.ts │ └── tsconfig.json ├── package.json ├── scripts/ │ └── e2e.sh ├── server/ │ ├── .npmignore │ ├── README.md │ ├── package.json │ ├── scripts/ │ │ └── executable.mjs │ ├── src/ │ │ ├── action_descriptor.ts │ │ ├── code_actions.ts │ │ ├── code_lens.ts │ │ ├── commands.ts │ │ ├── config.ts │ │ ├── data_providers/ │ │ │ └── stimulus_html_data_provider.ts │ │ ├── definitions.ts │ │ ├── diagnostics.ts │ │ ├── document_service.ts │ │ ├── events.ts │ │ ├── html_util.ts │ │ ├── levenshtein.ts │ │ ├── requests/ │ │ │ └── controller_definitions.ts │ │ ├── requests.ts │ │ ├── server.ts │ │ ├── service.ts │ │ ├── settings.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── types/ │ ├── typescript-eslint__typescript-estree/ │ │ └── index.d.ts │ ├── typescript-eslint__typescript-types/ │ │ └── index.d.ts │ └── typescript-eslint__visitor-keys/ │ └── index.d.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ node_modules/** client/node_modules/** client/out/** server/node_modules/** server/out/** **/*.d.ts ================================================ FILE: .eslintrc ================================================ { "root": true, "parser": "@typescript-eslint/parser", "plugins": [ "@typescript-eslint", "prettier" ], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended", "prettier" ], "rules": { "prettier/prettier": ["error"], "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] }, "env": { "node": true } } ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "npm" directory: "/" schedule: interval: "daily" groups: typescript-eslint: patterns: - "@typescript-eslint*" eslint: patterns: - "eslint" - "eslint-config-*" - "eslint-plugin-*" - "prettier-*" - package-ecosystem: "npm" directory: "/server" schedule: interval: "daily" groups: vscode: patterns: - "vscode*" - package-ecosystem: "npm" directory: "/client" schedule: interval: "daily" ================================================ FILE: .github/workflows/build.yml ================================================ name: Build permissions: contents: read on: [push, pull_request] jobs: tests: name: JavaScript Test Action runs-on: ubuntu-latest strategy: matrix: node: [20] steps: - name: Checkout uses: actions/checkout@v3 - name: Setup Node uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} cache: 'yarn' - name: Yarn install run: yarn install - name: Yarn build run: yarn build ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish permissions: contents: read on: release: types: - created jobs: publish: name: Publish VSCode extension runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v3 with: node-version: 20 cache: 'yarn' - name: Yarn install run: yarn install - name: Yarn build run: yarn build - name: Publish run: yarn run deploy env: VSCE_PAT: ${{ secrets.VSCE_PAT }} ================================================ FILE: .gitignore ================================================ client/out/ server/out/ node_modules/ .vscode-test/ *.tsbuildinfo *.vsix *.tgz *~ .DS_Store ================================================ FILE: .node-version ================================================ 20.14.0 ================================================ FILE: .prettierrc.json ================================================ { "singleQuote": false, "printWidth": 120, "semi": false } ================================================ FILE: .vscode/extensions.json ================================================ { // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp // List of extensions which should be recommended for users of this workspace. "recommendations": [ "dbaeumer.vscode-eslint" ] } ================================================ FILE: .vscode/launch.json ================================================ // A launch configuration that compiles the extension and then opens it inside a new window { "version": "0.2.0", "configurations": [ { "type": "extensionHost", "request": "launch", "name": "Launch Client", "runtimeExecutable": "${execPath}", "args": ["--extensionDevelopmentPath=${workspaceRoot}"], "outFiles": ["${workspaceRoot}/client/out/**/*.js"], "preLaunchTask": { "type": "npm", "script": "watch" } }, { "type": "node", "request": "attach", "name": "Attach to Server", "port": 6009, "restart": true, "outFiles": ["${workspaceRoot}/server/out/**/*.js"] }, { "name": "Language Server E2E Test", "type": "extensionHost", "request": "launch", "runtimeExecutable": "${execPath}", "args": [ "--extensionDevelopmentPath=${workspaceRoot}", "--extensionTestsPath=${workspaceRoot}/client/out/test/index", "${workspaceRoot}/client/testFixture" ], "outFiles": ["${workspaceRoot}/client/out/test/**/*.js"] } ], "compounds": [ { "name": "Client + Server", "configurations": ["Launch Client", "Attach to Server"] } ] } ================================================ FILE: .vscode/settings.json ================================================ { "editor.insertSpaces": false, "tslint.enable": true, "typescript.tsc.autoDetect": "off", "typescript.preferences.quoteStyle": "single", "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" } } ================================================ FILE: .vscode/tasks.json ================================================ { "version": "2.0.0", "tasks": [ { "type": "npm", "script": "compile", "group": "build", "presentation": { "panel": "dedicated", "reveal": "never" }, "problemMatcher": [ "$tsc" ] }, { "type": "npm", "script": "watch", "isBackground": true, "group": { "kind": "build", "isDefault": true }, "presentation": { "panel": "dedicated", "reveal": "never" }, "problemMatcher": [ "$tsc-watch" ] } ] } ================================================ FILE: .vscodeignore ================================================ .gitignore .eslintrc .eslintignore .prettierrc.json .node-version **/*.ts **/*.map **/tsconfig.json **/tsconfig.base.json .vscode/** .github/** scripts/** client/node_modules/** !client/node_modules/vscode-jsonrpc/** !client/node_modules/vscode-languageclient/** !client/node_modules/vscode-languageserver-protocol/** !client/node_modules/vscode-languageserver-types/** !client/node_modules/brace-expansion/** !client/node_modules/balanced-match/** !client/node_modules/lru-cache/** !client/node_modules/yallist/** !client/node_modules/semver/** ================================================ FILE: LICENSE.txt ================================================ MIT License Copyright (c) 2021 Marco Roth Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Stimulus LSP Intelligent Stimulus tooling for Visual Studio Code and other editors which support the Language Server Protocol. ![](/assets/stimulus-lsp.png) ## Functionality Currently, this Language Server only works for HTML, though its utility extends to various file types such as ERB, PHP, or Blade files. ### Completions * Data Attributes * Completions for controller identifiers * Completions for controller actions * Completions for controller targets * Completions for controller values * Completions for controller classes ### Diagnostics #### HTML Files * Missing controllers (`stimulus.controller.invalid`) * Missing controller actions (`stimulus.action.invalid`) * Missing controller targets (`stimulus.controller.target.missing`) * Missing controller values (`stimulus.controller.value.missing`) * Invalid action descriptors (`stimulus.action.invalid`) * Data attributes format mismatches (`stimulus.attribute.mismatch`) * Controller values type mismatches (`stimulus.controller.value.type_mismatch`) #### JavaScript Files/Stimulus Controller Files * Controller value definition default value type mismatch (`stimulus.controller.value_definition.default_value.type_mismatch`) * Unknown value definition type (`stimulus.controller.value_definition.unknown_type`) * Controller parsing errors (`stimulus.controller.parse_error`) * Import from deprecated packages (`stimulus.package.deprecated.import`) ### Quick-Fixes * Create a controller with the given identifier (`stimulus.controller.create`) * Update controller identifier with did you mean suggestion (`stimulus.controller.update`) * Register a controller definition from your project or a NPM package (`stimulus.controller.register`) * Update controller action name with did you mean suggestion (`stimulus.controller.action.update`) * Implement a missing controller action on controller (`stimulus.controller.action.implement`) * Create a default config file at `.stimulus-lsp/config.json` (`stimulus.config.create`) * Ignore diagnostics for a HTML attribute by adding it to the `ignoredAttributes` config (`stimulus.config.attribute.ignore`) * Ignore diagnostics for a Stimulus controller identifier by adding it to the `ignoredControllerIdentifiers` config (`stimulus.config.controller.ignore`) ## Structure ``` . ├── package.json // The extension manifest. | ├── client // Language Client │ └── src │ └── extension.ts // Language Client entry point | └── server // Language Server └── src └── server.ts // Language Server entry point ``` ## Running the extension locally - Run `yarn install` in this folder. This installs all necessary npm modules in both the client and server folder - Open VS Code on this folder. - Press Ctrl+Shift+B to compile the client and server. - Switch to the Debug viewlet. - Select `Launch Client` from the drop down. - Run the launch config. - If you want to debug the server as well use the launch configuration `Attach to Server` - In the [Extension Development Host] instance of VSCode, open a HTML file. - Type `
`, place your cursor where the `|` is, hit Ctrl+Space and you should see completions. ## Install instructions ### VS Code Install the [Stimulus LSP extension](https://marketplace.visualstudio.com/items?itemName=marcoroth.stimulus-lsp) from the Visual Studio Marketplace. ### Neovim [Install instructions can be found at nvim-lspconfig](https://github.com/neovim/nvim-lspconfig/blob/master/doc/configs.md#stimulus_ls) ### Zed Install the [Zed Stimulus](https://github.com/vitallium/zed-stimulus) extension. ================================================ FILE: client/package.json ================================================ { "name": "vscode-stimulus", "description": "Intelligent Stimulus tooling for Visual Studio Code", "author": "Marco Roth", "license": "MIT", "version": "1.1.0", "publisher": "Marco Roth", "repository": { "type": "git", "url": "https://github.com/marcoroth/stimulus-lsp" }, "engines": { "vscode": "^1.52.0" }, "dependencies": { "brace-expansion": "^5.0.5", "minimatch": "^10.2.5", "typescript": "^5.9.3", "vscode-languageclient": "^9.0.1" }, "devDependencies": { "@types/vscode": "^1.115.0" } } ================================================ FILE: client/src/client.ts ================================================ import * as path from "path" import { workspace, ExtensionContext } from "vscode" import { LanguageClient, LanguageClientOptions, ServerOptions, TransportKind } from "vscode-languageclient/node" import { ControllerTreeView } from "./controller_tree_view" import type { ControllerDefinitionsResponse } from "./requests" export class Client { private client: LanguageClient private serverModule: string private languageClientId = "languageServerStimulus" private languageClientName = "Stimulus LSP" private context: ExtensionContext constructor(context: ExtensionContext) { this.context = context this.serverModule = context.asAbsolutePath(path.join("server", "out", "server.js")) this.client = new LanguageClient( this.languageClientId, this.languageClientName, this.serverOptions, this.clientOptions, ) } async start() { try { this.client.start() this.context.subscriptions.push(new ControllerTreeView(this)) } catch (error: any) { console.error(`Error restarting the server: ${error.message}`) return } } async stop(): Promise { if (this.client) { await this.client.stop() } } async sendNotification(method: string, params: any) { return await this.client.sendNotification(method, params) } async sendRequest(method: string, params: any) { return await this.client.sendRequest(method, params) } async requestControllerDefinitions(): Promise { return await this.sendRequest("stimulus-lsp/controllerDefinitions", {}) } // The debug options for the server // --inspect=6009: runs the server in Node's Inspector mode so VS Code can attach to the server for debugging private get debugOptions() { return { execArgv: ["--nolazy", "--inspect=6009"], } } // If the extension is launched in debug mode then the debug server options are used // Otherwise the run options are used private get serverOptions(): ServerOptions { return { run: { module: this.serverModule, transport: TransportKind.ipc, }, debug: { module: this.serverModule, transport: TransportKind.ipc, options: this.debugOptions, }, } } private get clientOptions(): LanguageClientOptions { return { documentSelector: [ { scheme: "file", language: "ruby" }, { scheme: "file", language: "erb" }, { scheme: "file", language: "blade" }, { scheme: "file", language: "php" }, { scheme: "file", language: "html" }, { scheme: "file", language: "javascript" }, { scheme: "file", language: "typescript" }, ], synchronize: { // Notify the server about file changes to '.clientrc files contained in the workspace fileEvents: workspace.createFileSystemWatcher("**/.clientrc"), }, } } } ================================================ FILE: client/src/controller_tree_view.ts ================================================ import { TreeView, TreeItem, TreeItemCollapsibleState, TreeDataProvider, Disposable, ThemeIcon, EventEmitter, Uri, Event, } from "vscode" import * as vscode from "vscode" import { Client } from "./client" import type { ControllerDefinition, ControllerDefinitionsResponse, ControllerDefinitionsOrigin } from "./requests" type ControllerDefinitionTreeItem = ControllerTreeItem | ControllerDefinitionsStateItem export class ControllerTreeView implements TreeDataProvider, Disposable { private client: Client private readonly treeView: TreeView private readonly subscriptions: Disposable[] = [] private _onDidChangeTreeData: EventEmitter = new EventEmitter() readonly onDidChangeTreeData: Event = this._onDidChangeTreeData.event constructor(client: Client) { this.client = client this.treeView = vscode.window.createTreeView("controllerDefinitions", { treeDataProvider: this, showCollapseAll: true, }) vscode.commands.registerCommand("controllerDefinitions.refreshEntry", () => this.refresh()) vscode.commands.registerCommand("controllerDefinitions.registerControllerDefinition", (item) => this.registerControllerDefinition(item), ) this.subscriptions.push( this.treeView.onDidChangeVisibility(() => this.refresh()), vscode.workspace.onDidRenameFiles(() => this.refresh()), vscode.workspace.onDidSaveTextDocument(() => this.refresh()), ) } dispose() { this.subscriptions.forEach((item) => item.dispose()) this.treeView.dispose() } getTreeItem(element: ControllerDefinitionTreeItem) { return element } async getChildren(element?: ControllerDefinitionTreeItem) { if (element) { return element.getChildren() } else { const response = await this.requestControllerDefinitions() return [ new ControllerDefinitionsStateItem("Unregistered", [ response.unregistered.project, ...response.unregistered.nodeModules, ]), new ControllerDefinitionsStateItem("Registered", [response.registered]), ] } } refresh() { this._onDidChangeTreeData.fire(undefined) } registerControllerDefinition(item: ControllerTreeItem) { if (item.isImportable) { this.client.sendRequest("workspace/executeCommand", { command: "stimulus.controller.register", arguments: [ item.controllerDefinition.importStatement, item.controllerDefinition.identifier, item.controllerDefinition.localName, ], }) } } private async requestControllerDefinitions(): Promise { return await this.client.requestControllerDefinitions() } } class ControllerDefinitionsStateItem extends TreeItem { public children: ControllerDefinitionsOrigin[] = [] constructor(name: string, children: ControllerDefinitionsOrigin[]) { const collapisbleState = name === "Registered" ? TreeItemCollapsibleState.Expanded : TreeItemCollapsibleState.Collapsed super(name, collapisbleState) this.tooltip = name this.children = children const controllersCount = this.children.flatMap((c) => c.controllerDefinitions).length this.description = `(${controllersCount} controller${controllersCount == 1 ? "" : "s"})` } getChildren() { return this.controllerTreeItems.sort((a, b) => a.label.toString().localeCompare(b.label.toString())) } private get controllerTreeItems() { return this.controllerDefinitions.flatMap(([definition, child]) => new ControllerTreeItem(definition, child)) } private get controllerDefinitions(): [ControllerDefinition, ControllerDefinitionsOrigin][] { return this.children .map((child) => child.controllerDefinitions.map( (definition) => [definition, child] as [ControllerDefinition, ControllerDefinitionsOrigin], ), ) .flat(1) } } class ControllerTreeItem extends TreeItem { public registered: boolean = false public controllerDefinition: ControllerDefinition constructor(item: ControllerDefinition, origin: ControllerDefinitionsOrigin) { super(item.identifier, TreeItemCollapsibleState.None) this.controllerDefinition = item this.id = `${item.path}-${item.identifier}-${item.registered}` this.tooltip = item.path this.registered = item.registered this.iconPath = new ThemeIcon("outline-view-icon") this.resourceUri = Uri.parse(`file://${item.path}`) this.contextValue = `controllerDefinition-${item.registered ? "registered" : "unregistered"}${this.isImportable ? "importable" : "non-importable"}` if (!item.registered) { this.description = `(${origin.name})` } this.command = { command: "vscode.open", title: "Open", arguments: [this.resourceUri], } } get isImportable() { return ( !!this.controllerDefinition.importStatement && !!this.controllerDefinition.identifier && !!this.controllerDefinition.localName ) } getChildren() { return [] } } ================================================ FILE: client/src/extension.ts ================================================ import { ExtensionContext } from "vscode" import { Client } from "./client" let client: Client export async function activate(context: ExtensionContext) { client = new Client(context) await client.start() } export async function deactivate(): Promise { if (client) { await client.stop() } else { return undefined } } ================================================ FILE: client/src/requests.ts ================================================ import { Position } from "vscode-languageclient" export type ControllerDefinition = { identifier: string path: string registered: boolean position: Position importStatement?: string localName?: string } export interface ControllerDefinitionsOrigin { name: string controllerDefinitions: ControllerDefinition[] } export interface ProjectControllerDefinitions extends ControllerDefinitionsOrigin { name: "project" } export type ControllerDefinitionsRequest = object export type ControllerDefinitionsResponse = { registered: ProjectControllerDefinitions unregistered: { project: ProjectControllerDefinitions nodeModules: ControllerDefinitionsOrigin[] } } ================================================ FILE: client/tsconfig.json ================================================ { "compilerOptions": { "module": "commonjs", "target": "es2019", "lib": ["ES2019"], "outDir": "out", "rootDir": "src", "sourceMap": true, "skipLibCheck": true, "erasableSyntaxOnly": true }, "include": ["src"], "exclude": ["node_modules"] } ================================================ FILE: package.json ================================================ { "name": "stimulus-lsp", "displayName": "Stimulus LSP", "description": "Intelligent Stimulus tooling", "license": "MIT", "pricing": "Free", "version": "1.1.0", "icon": "icon.png", "publisher": "marcoroth", "author": { "name": "Marco Roth" }, "categories": [ "Programming Languages", "Language Packs", "Linters" ], "keywords": [ "Stimulus", "Hotwire", "Ruby on Rails" ], "sponsor": { "url": "http://github.com/sponsors/marcoroth" }, "repository": "https://github.com/marcoroth/stimulus-lsp", "engines": { "vscode": "^1.43.0" }, "extensionDependencies": [ "marcoroth.herb-lsp" ], "activationEvents": [ "onLanguage:ruby", "onLanguage:erb", "onLanguage:blade", "onLanguage:php", "onLanguage:html", "onLanguage:javascript", "onLanguage:typescript", "onView:controllerDefinitions" ], "main": "./client/out/extension", "contributes": { "configurationDefaults": { "[html]": { "editor.quickSuggestions": { "strings": "on" } }, "[erb]": { "editor.quickSuggestions": { "strings": "on" } } }, "configuration": { "type": "object", "title": "Stimulus configuration", "properties": { "languageServerStimulus.trace.server": { "scope": "window", "type": "string", "enum": [ "off", "messages", "verbose" ], "default": "verbose", "description": "Traces the communication between VS Code and the language server." } } }, "views": { "explorer": [ { "id": "controllerDefinitions", "name": "Stimulus Controllers", "icon": "assets/stimulus.svg", "description": "View and inspect detected Stimulus Controllers", "contextualTitle": "Stimulus Controllers" } ] }, "commands": [ { "command": "controllerDefinitions.refreshEntry", "title": "Refresh Stimulus Controller Definitions", "icon": "$(refresh)" }, { "command": "controllerDefinitions.registerControllerDefinition", "title": "Register controller definition on the Stimulus Application", "icon": "$(add)" } ], "menus": { "view/title": [ { "command": "controllerDefinitions.refreshEntry", "when": "view == controllerDefinitions", "group": "navigation" } ], "view/item/context": [ { "command": "controllerDefinitions.registerControllerDefinition", "when": "view == controllerDefinitions && viewItem == controllerDefinition-unregistered-importable", "group": "inline" } ] }, "viewsWelcome": [ { "view": "controllerDefinitions", "contents": "No Stimulus Controller found [learn more](https://stimulus.hotwired.dev/handbook/installing)." } ] }, "scripts": { "vscode:prepublish": "yarn run build", "prebuild": "yarn run clean", "clean": "yarn rimraf client/out && yarn rimraf server/out", "deploy": "vsce publish --yarn", "build": "tsc -b", "watch": "tsc -b -w", "lint": "eslint client/**/*.ts server/**/*.ts --no-ignore", "format": "yarn lint --fix", "postinstall": "cd client && yarn install && cd ../server && yarn install && cd ..", "test": "sh ./scripts/e2e.sh" }, "devDependencies": { "@types/node": "^25.6.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vscode/vsce": "^3.0.0", "eslint": "^10.0.0", "eslint-config-prettier": "^10.0.1", "eslint-plugin-prettier": "^5.0.1", "prettier": "^3.1.0", "rimraf": "^6.0.0", "typescript": "^5.8.2" } } ================================================ FILE: scripts/e2e.sh ================================================ #!/usr/bin/env bash export CODE_TESTS_PATH="$(pwd)/client/out/test" export CODE_TESTS_WORKSPACE="$(pwd)/client/testFixture" node "$(pwd)/client/out/test/runTest" ================================================ FILE: server/.npmignore ================================================ .babelrc .babelrc.js .DS_Store .gitignore .yarn.lock *.log *.tsbuildinfo *.tgz README.md rollup.config.js tsconfig.json yarn-error.log *~ /.git /.github /.gitattributes /node_modules /src /test /coverage /assets ================================================ FILE: server/README.md ================================================ # Stimulus Language Server [Language Server Protocol](https://github.com/Microsoft/language-server-protocol) implementation for [Stimulus](https://stimulus.hotwired.dev), used by [Stimulus LSP for VS Code](https://marketplace.visualstudio.com/items?itemName=marcoroth.stimulus-lsp). ## Install ```bash npm install -g stimulus-language-server ``` ```bash yarn global add stimulus-language-server ``` ## Run ```bash stimulus-language-server --stdio ``` ``` Usage: stimulus-language-server [options] Options: --stdio use stdio --node-ipc use node-ipc --socket= use socket ``` ================================================ FILE: server/package.json ================================================ { "name": "stimulus-language-server", "description": "Intelligent Stimulus tooling", "version": "1.1.0", "author": "Marco Roth", "license": "MIT", "engines": { "node": "*" }, "bugs": "https://github.com/marcoroth/stimulus-lsp/issues", "repository": "https://github.com/marcoroth/stimulus-lsp", "homepage": "https://hotwire.io/ecosystem/tooling/stimulus-lsp", "bin": { "stimulus-language-server": "./out/stimulus-language-server" }, "scripts": { "clean": "rimraf out", "prebuild": "yarn run clean", "build": "tsc -b", "postbuild": "node scripts/executable.mjs", "watch": "tsc -b -w" }, "files": [ "out" ], "dependencies": { "@hotwired/stimulus": "https://github.com/hotwired/dev-builds/archive/refs/tags/@hotwired/stimulus/8cbca6d.tar.gz", "dedent": "^1.5.1", "stimulus-parser": "^0.3.2", "typescript": "^5.8.2", "@herb-tools/core": "0.9.5", "@herb-tools/language-service": "0.9.5", "@herb-tools/node-wasm": "0.9.5", "vscode-html-languageservice": "^5.1.1", "vscode-languageserver": "^9.0.1", "vscode-languageserver-textdocument": "^1.0.1" }, "devDependencies": { "@types/estree": "^1.0.5", "acorn": "^8.11.3", "astring": "^1.8.6", "rimraf": "^6.0.0", "source-map": "^0.7.4" } } ================================================ FILE: server/scripts/executable.mjs ================================================ import { readFileSync, writeFileSync } from 'fs' import { dirname, resolve } from 'path' import { fileURLToPath } from 'url' import { exec } from 'child_process' const __dirname = dirname(fileURLToPath(import.meta.url)) const infile = resolve(__dirname, '../out/server.js') const outfile = resolve(__dirname, '../out/stimulus-language-server') writeFileSync( outfile, '#!/usr/bin/env node\n' + readFileSync(infile, 'utf-8'), 'utf-8' ) exec('chmod +x out/stimulus-language-server', (error, _stdout, _stderr) => { if (error) { console.error(`Error setting file permissions: ${error}`); } else { console.log('File permissions set successfully'); } }); ================================================ FILE: server/src/action_descriptor.ts ================================================ // https://github.com/hotwired/stimulus/blob/8cbca6db3b1b2ddb384deb3dd98397d3609d25a0/src/core/action_descriptor.ts export interface ActionDescriptor { eventTarget: string eventOptions: AddEventListenerOptions eventName: string identifier: string methodName: string keyFilter: string } // capture nos.: 1 1 2 2 3 3 4 4 5 5 6 6 7 7 const descriptorPattern = /^(?:(?:([^.]+?)\+)?(.+?)(?:\.(.+?))?(?:@(window|document))?->)?(.+?)(?:#([^:]+?))(?::(.+))?$/ export function parseActionDescriptorString(descriptorString: string): Partial { const source = descriptorString.trim() const matches = source.match(descriptorPattern) || [] let eventName = matches[2] let keyFilter = matches[3] if (keyFilter && !["keydown", "keyup", "keypress"].includes(eventName)) { eventName += `.${keyFilter}` keyFilter = "" } return { eventTarget: matches[4], eventName, eventOptions: matches[7] ? parseEventOptions(matches[7]) : {}, identifier: matches[5], methodName: matches[6], keyFilter: matches[1] || keyFilter, } } function parseEventOptions(eventOptions: string): AddEventListenerOptions { return eventOptions .split(":") .reduce((options, token) => Object.assign(options, { [token.replace(/^!/, "")]: !/^!/.test(token) }), {}) } ================================================ FILE: server/src/code_actions.ts ================================================ import { CodeAction, CodeActionKind, CodeActionParams, Command, Diagnostic } from "vscode-languageserver/node" import { DocumentService } from "./document_service" import { InvalidActionDiagnosticData, InvalidControllerDiagnosticData, DeprecatedPackageImportsDiagnosticData, } from "./diagnostics" import { importStatementForController } from "./utils" import { Project } from "stimulus-parser" export class CodeActions { private readonly documentService: DocumentService private readonly project: Project constructor(documentService: DocumentService, project: Project) { this.documentService = documentService this.project = project } onCodeAction(params: CodeActionParams): CodeAction[] { const { diagnostics } = params.context if (diagnostics.length === 0) return [] const textDocument = this.documentService.get(params.textDocument.uri) if (textDocument === undefined) return [] const invalidControllerDiagnostics = diagnostics.filter((d) => d.code === "stimulus.controller.invalid") const invalidActionDiagnostics = diagnostics.filter((d) => d.code === "stimulus.controller.action.invalid") const deprecatedPackageImports = diagnostics.filter((d) => d.code === "stimulus.package.deprecated.import") return [ ...this.handleInvalidControllerDiagnostics(invalidControllerDiagnostics), ...this.handleInvalidActionDiagnostics(invalidActionDiagnostics), ...this.handleDeprecatedPackageImports(deprecatedPackageImports), ] } private handleInvalidControllerDiagnostics(diagnostics: Diagnostic[]) { return diagnostics.flatMap((diagnostic) => { const codeActions: CodeAction[] = [] const { identifier, suggestion } = diagnostic.data as InvalidControllerDiagnosticData // Code Action: stimulus.package.deprecated.import if (diagnostic.code === "stimulus.package.deprecated.import") { const updateImport = `Replace "${identifier}" with suggestion: "${suggestion}"` const updateDeprecatedImport = CodeAction.create( updateImport, Command.create( updateImport, "stimulus.package.deprecated.controller.update", identifier, diagnostic, suggestion, ), CodeActionKind.QuickFix, ) codeActions.push(updateDeprecatedImport) } // Code Action: stimulus.controller.update if (suggestion) { const updateTitle = `Replace "${identifier}" with suggestion: "${suggestion}"` const updateReferenceAction = CodeAction.create( updateTitle, Command.create(updateTitle, "stimulus.controller.update", identifier, diagnostic, suggestion), CodeActionKind.QuickFix, ) codeActions.push(updateReferenceAction) } // Code Action: stimulus.controller.register if (identifier) { const projectControllers = this.project.projectFiles.flatMap((file) => file.exportedControllerDefinitions) const entrypointExports = this.project.detectedNodeModules.flatMap( (m) => m.entrypointSourceFile?.exportDeclarations || [], ) const nodeModulesControllers = entrypointExports.flatMap((exportDeclaration) => { try { return exportDeclaration.nextResolvedClassDeclaration?.controllerDefinition || [] } catch (error: any) { return [] } }) const controllers = projectControllers .concat(nodeModulesControllers) .filter((controller) => controller.guessedIdentifier === identifier) controllers.forEach((controller) => { const { localName, importStatement, importSource } = importStatementForController(controller, this.project) if (importStatement) { const registerTitle = `Register controller "${identifier}" from "${importSource}"` codeActions.push( CodeAction.create( registerTitle, Command.create(registerTitle, "stimulus.controller.register", importStatement, identifier, localName), CodeActionKind.QuickFix, ), ) } }) } // Code Action: stimulus.controller.create const controllerRootsInProject = this.project.controllerRoots.filter( (project) => !project.includes("node_modules"), ) const manyRoots = controllerRootsInProject.length > 1 if (controllerRootsInProject.length === 0) controllerRootsInProject.push(this.project.controllerRootFallback) const createControllerActions = controllerRootsInProject.map((root) => { const folder = `${manyRoots ? ` in "${root}/"` : ""}` const title = `Create "${identifier}" Stimulus Controller${folder}` return CodeAction.create( title, Command.create(title, "stimulus.controller.create", identifier, diagnostic, root), CodeActionKind.QuickFix, ) }) codeActions.push(...createControllerActions) // Code Action: stimulus.config.attribute.ignore const { attribute } = diagnostic?.data?.data || {} if (attribute) { const ignoreAttributeTitle = `Ignore diagnostics for "${attribute}" attribute.` const ignoreAttributeAction = CodeAction.create( ignoreAttributeTitle, Command.create(ignoreAttributeTitle, "stimulus.config.attribute.ignore", attribute, diagnostic), CodeActionKind.QuickFix, ) codeActions.push(ignoreAttributeAction) } // Code Action: stimulus.config.controller.ignore const ignoreControllerTitle = `Ignore diagnostics for "${identifier}" controller.` const ignoreControllerAction = CodeAction.create( ignoreControllerTitle, Command.create(ignoreControllerTitle, "stimulus.config.controller.ignore", identifier, diagnostic), CodeActionKind.QuickFix, ) codeActions.push(ignoreControllerAction) return codeActions }) } private handleInvalidActionDiagnostics(diagnostics: Diagnostic[]) { return diagnostics.flatMap((diagnostic) => { const { actionName, suggestion, identifier } = diagnostic.data as InvalidActionDiagnosticData const updateTitle = `Replace "${actionName}" with suggestion: "${suggestion}"` const updateReferenceAction = CodeAction.create( updateTitle, Command.create(updateTitle, "stimulus.controller.action.update", actionName, diagnostic, suggestion), CodeActionKind.QuickFix, ) const implementTitle = `Implement "${actionName}" action on "${identifier}" controller` const implementControllerAction = CodeAction.create( implementTitle, Command.create(implementTitle, "stimulus.controller.action.implement", actionName, identifier, diagnostic), CodeActionKind.QuickFix, ) return [updateReferenceAction, implementControllerAction] }) } private handleDeprecatedPackageImports(diagnostics: Diagnostic[]) { return diagnostics.flatMap((diagnostic) => { const codeActions: CodeAction[] = [] const { identifier, suggestion } = diagnostic.data as DeprecatedPackageImportsDiagnosticData // Code Action: stimulus.package.deprecated.import const updateImport = `Replace "${identifier}" with suggestion: "${suggestion}"` const updateDeprecatedImport = CodeAction.create( updateImport, Command.create(updateImport, "stimulus.import.source.update", diagnostic), CodeActionKind.QuickFix, ) codeActions.push(updateDeprecatedImport) return codeActions }) } } ================================================ FILE: server/src/code_lens.ts ================================================ import { CodeLens, CodeLensParams, Range, Command } from "vscode-languageserver/node" import { DocumentService } from "./document_service" import type { Project } from "stimulus-parser" export class CodeLensProvider { private readonly documentService: DocumentService private readonly project: Project constructor(documentService: DocumentService, project: Project) { this.documentService = documentService this.project = project } onCodeLens(params: CodeLensParams) { const textDocument = this.documentService.get(params.textDocument.uri) if (!textDocument) return [] const file = this.project.projectFiles.find((file) => `file://${file.path}` === textDocument.uri) if (!file) return [] if (file.controllerDefinitions.length === 0) return [] return file.controllerDefinitions.flatMap((definition) => { const loc = definition.classDeclaration.node?.loc if (!loc) return [] const registeredController = this.project.registeredControllers.find( (registered) => registered.controllerDefinition === definition, ) const range = Range.create(loc.start.line - 1, loc.start.column, loc.end.line - 1, loc.start.column) if (registeredController) { return [ CodeLens.create(range, { filePath: file.path, registered: true, identifier: registeredController.identifier, }), ] } else { return [ CodeLens.create(range, { filePath: file.path, registered: false, identifier: definition.guessedIdentifier, }), ] } }) } onCodeLensResolve(codeLens: CodeLens) { const identifier = codeLens.data?.identifier const registered = codeLens.data?.registered const file = this.project.projectFiles.find((file) => file.path === codeLens.data?.filePath) if (!file) return codeLens if (file.controllerDefinitions.length === 0) return codeLens const registeredController = this.project.registeredControllers.find( (definition) => definition.identifier === identifier, ) if (registered && registeredController) { codeLens.command = Command.create( `Stimulus: Connects to data-controller="${registeredController.identifier}"`, "", ) } else { codeLens.command = Command.create( `Stimulus: The "${identifier}" controller isn't registered on your Stimulus Application`, "", ) } return codeLens } } ================================================ FILE: server/src/commands.ts ================================================ import dedent from "dedent" import { Connection, TextDocumentEdit, TextEdit, CreateFile, Range, Diagnostic } from "vscode-languageserver/node" import { DeprecatedPackageImportsDiagnosticData } from "./diagnostics" import { Config } from "./config" import { Project, ControllerDefinition } from "stimulus-parser" type SerializedTextDocument = { _uri: string _languageId: string _version: number _content: string _lineOffsets: number[] } export class Commands { private readonly project: Project private readonly connection: Connection constructor(project: Project, connection: Connection) { this.project = project this.connection = connection } async updateControllerReference(identifier: string, diagnostic: Diagnostic, suggestion: string) { if (identifier === undefined) return if (diagnostic === undefined) return if (suggestion === undefined) return const { textDocument, range } = diagnostic.data as { textDocument: SerializedTextDocument; range: Range } const document = { uri: textDocument._uri, version: textDocument._version } const textEdit: TextEdit = { range, newText: suggestion } const documentChanges: TextDocumentEdit[] = [TextDocumentEdit.create(document, [textEdit])] await this.connection.workspace.applyEdit({ documentChanges }) } async registerControllerDefinition(importStatement: string, identifier: string, localName: string) { if (importStatement === undefined) return if (identifier === undefined) return if (localName === undefined) return if (this.project.controllersIndexFiles.length === 0) return // TODO: there must be a better way to get the end of the file without having the textDocument const endOfFile = { line: 10000000, character: 0 } // TODO: don't always choose first contollersFile const uri = `file://${this.project.controllersIndexFiles[0].path}` const document = { uri, version: null } const textEdit: TextEdit = { range: { start: endOfFile, end: endOfFile }, newText: `\n\n${importStatement}\napplication.register("${identifier}", ${localName})\n`, } const documentChanges: TextDocumentEdit[] = [TextDocumentEdit.create(document, [textEdit])] await this.connection.workspace.applyEdit({ documentChanges }) await this.connection.window.showDocument({ uri, external: false, takeFocus: true, }) } async createController(identifier: string, diagnostic: Diagnostic, controllerRoot: string) { if (identifier === undefined) return if (diagnostic === undefined) return if (controllerRoot === undefined) controllerRoot = this.project.controllerRoot const path = ControllerDefinition.controllerPathForIdentifier(identifier) const newControllerPath = `file://${this.project.projectPath}/${controllerRoot}/${path}` const createFile: CreateFile = { kind: "create", uri: newControllerPath } await this.connection.workspace.applyEdit({ documentChanges: [createFile] }) const documentRange: Range = Range.create(0, 0, 0, 0) const textEdit: TextEdit = { range: documentRange, newText: this.controllerTemplateFor(identifier) } const textDocumentEdit = TextDocumentEdit.create({ uri: newControllerPath, version: 1 }, [textEdit]) await this.connection.workspace.applyEdit({ documentChanges: [textDocumentEdit] }) await this.connection.window.showDocument({ uri: textDocumentEdit.textDocument.uri, external: false, takeFocus: true, }) } async updateControllerActionReference(actionName: string, diagnostic: Diagnostic, suggestion: string) { if (actionName === undefined) return if (diagnostic === undefined) return if (suggestion === undefined) return const { textDocument, range } = diagnostic.data as { textDocument: SerializedTextDocument; range: Range } const document = { uri: textDocument._uri, version: textDocument._version } const textEdit: TextEdit = { range, newText: suggestion } const documentChanges: TextDocumentEdit[] = [TextDocumentEdit.create(document, [textEdit])] await this.connection.workspace.applyEdit({ documentChanges }) } async implementControllerAction(actionName: string, identifier: string, diagnostic: Diagnostic) { if (identifier === undefined) return if (actionName === undefined) return if (diagnostic === undefined) return const controller = this.project.registeredControllers.find((controller) => controller.identifier === identifier) if (controller === undefined) return const loc = controller.controllerDefinition.classDeclaration?.node?.loc if (!loc) return const position = { line: loc.end.line - 1, character: 0 } const textEdit: TextEdit = { range: { start: position, end: position }, newText: ` ${actionName}(event) { console.log("${identifier}#${actionName}", event) } `, } const uri = `file://${controller.sourceFile.path}` const document = { uri, version: null } const documentChanges: TextDocumentEdit[] = [TextDocumentEdit.create(document, [textEdit])] await this.connection.workspace.applyEdit({ documentChanges }) await this.connection.window.showDocument({ uri, external: false, takeFocus: true, }) } async updateImportSource(diagnostic: Diagnostic) { const { textDocument, importSourceRange: range, suggestion: newText, } = diagnostic.data as DeprecatedPackageImportsDiagnosticData & { textDocument: SerializedTextDocument } const textEdit: TextEdit = { range, newText, } const uri = textDocument._uri const document = { uri, version: null } const documentChanges: TextDocumentEdit[] = [TextDocumentEdit.create(document, [textEdit])] await this.connection.workspace.applyEdit({ documentChanges }) await this.connection.window.showDocument({ uri, external: false, takeFocus: true, }) } async createStimulusLSPConfig() { const config = await Config.fromPathOrNew(this.project.projectPath) const configPath = config.path const createFile: CreateFile = { kind: "create", uri: configPath } await this.connection.workspace.applyEdit({ documentChanges: [createFile] }) const documentRange: Range = Range.create(0, 0, 0, 0) const textEdit: TextEdit = { range: documentRange, newText: config.toJSON() } const textDocumentEdit = TextDocumentEdit.create({ uri: configPath, version: 1 }, [textEdit]) await this.connection.workspace.applyEdit({ documentChanges: [textDocumentEdit] }) await this.connection.window.showDocument({ uri: textDocumentEdit.textDocument.uri, external: false, takeFocus: true, }) } async addIgnoredControllerToConfig(identifier: string) { const config = await Config.fromPathOrNew(this.project.projectPath) config.addIgnoredController(identifier) await config.write() await this.connection.window.showDocument({ uri: `file://${config.path}`, external: false, takeFocus: true, }) } async addIgnoredAttributeToConfig(attribute: string) { const config = await Config.fromPathOrNew(this.project.projectPath) config.addIgnoredAttribute(attribute) await config.write() await this.connection.window.showDocument({ uri: `file://${config.path}`, external: false, takeFocus: true, }) } private controllerTemplateFor(identifier: string) { return dedent` import { Controller } from "@hotwired/stimulus" export default class extends Controller { connect() { console.log("${identifier} controller connected") } } ` } } ================================================ FILE: server/src/config.ts ================================================ export type StimulusConfigOptions = { ignoredControllerIdentifiers: Array ignoredAttributes: Array } export type StimulusLSPConfig = { version: string createdAt: string updatedAt: string options: StimulusConfigOptions } import path from "path" import { version } from "../package.json" import { promises as fs } from "fs" export class Config { static configPath = ".stimulus-lsp/config.json" public readonly path: string public config: StimulusLSPConfig constructor(projectPath: string, config: StimulusLSPConfig) { this.path = Config.configPathFromProjectPath(projectPath) this.config = config } get version(): string { return this.config.version } get createdAt(): Date { return new Date(this.config.createdAt) } get updatedAt(): Date { return new Date(this.config.updatedAt) } get options(): StimulusConfigOptions { return this.config.options } get ignoredControllerIdentifiers(): Array { return this.options.ignoredControllerIdentifiers } get ignoredAttributes(): Array { return this.options.ignoredAttributes } public addIgnoredController(identifier: string) { const identifiers = this.ignoredControllerIdentifiers identifiers.push(identifier) this.options.ignoredControllerIdentifiers = Array.from(new Set(identifiers)).sort() } public addIgnoredAttribute(attribute: string) { const attributes = this.ignoredAttributes attributes.push(attribute) this.options.ignoredAttributes = Array.from(new Set(attributes)).sort() } public toJSON() { return JSON.stringify(this.config, null, " ") } private updateTimestamp() { this.config.updatedAt = new Date().toISOString() } private updateVersion() { this.config.version = version } async write() { this.updateVersion() this.updateTimestamp() const folder = path.dirname(this.path) fs.stat(folder) .then(() => {}) .catch(async () => await fs.mkdir(folder)) .finally(async () => await fs.writeFile(this.path, this.toJSON())) } async read() { return await fs.readFile(this.path, "utf8") } static configPathFromProjectPath(projectPath: string) { return path.join(projectPath, this.configPath) } static async fromPathOrNew(projectPath: string) { try { return await this.fromPath(projectPath) } catch (error: any) { return Config.newConfig(projectPath) } } static async fromPath(projectPath: string) { const configPath = Config.configPathFromProjectPath(projectPath) try { const config = JSON.parse(await fs.readFile(configPath, "utf8")) return new Config(projectPath, config) } catch (error: any) { throw new Error(`Error reading config file at: ${configPath}. Error: ${error.message}`) } } static newConfig(projectPath: string): Config { return new Config(projectPath, { version, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), options: { ignoredControllerIdentifiers: [], ignoredAttributes: [] } }) } } ================================================ FILE: server/src/data_providers/stimulus_html_data_provider.ts ================================================ import { IHTMLDataProvider } from "@herb-tools/language-service" import { EVENTS } from "../events" import { Project } from "stimulus-parser" import { dasherize } from "../utils" export class StimulusHTMLDataProvider implements IHTMLDataProvider { private id: string; private project: Project constructor(id: string, project: Project) { this.id = id; this.project = project; } get controllers() { return this.project.registeredControllers } get controllerRoots() { return this.project.controllerRoots } isApplicable() { return true } getId() { return this.id } provideTags() { return [] } provideAttributes(_tag: string) { const targetAttribtues = this.controllers .filter((controller) => controller.controllerDefinition.targetNames.length > 0) .map((controller) => { const name = `data-${controller.identifier}-target` return { name } }) const valueAttribtues = this.controllers.flatMap((controller) => { return controller.controllerDefinition.values.map((definition) => { return { name: `data-${controller.identifier}-${dasherize(definition.name)}-value` } }) }) const classAttribtues = this.controllers.flatMap((controller) => { return controller.controllerDefinition.classNames.map((klass) => { return { name: `data-${controller.identifier}-${dasherize(klass)}-class` } }) }) return [ { name: "data-controller" }, { name: "data-action" }, { name: "data-target" }, ...targetAttribtues, ...valueAttribtues, ...classAttribtues, ] } provideValues(_tag: string, attribute: string) { if (attribute == "data-controller") { return this.controllers.map((controller) => ({ name: controller.identifier })) } if (attribute == "data-action") { const events = EVENTS.map((name) => ({ name })) const controllers = this.controllers.map((controller) => ({ name: `${controller.identifier}`, controller })) // const keys = [ // "alt", // "ctrl", // "meta", // "shift", // "enter", // "tab", // "esc", // "space", // "up", // "down", // "left", // "right", // "home", // "end", // "page_up", // "page_down", // ..."abcdefghijklmnopqrstuvwxyz".split(""), // ..."0123456789".split(""), // ] const controllersWithEvents = EVENTS.flatMap((event) => { return controllers.flatMap((item) => { const { controller } = item // const keyEvents = (["keydown", "keyup", "keypress"].includes(event)) ? keys.flatMap((key1) => // keys.flatMap((key2) => [ // { name: `${event}.${key1}+${key2}->${controller.identifier}`, controller }, // { name: `${event}.${key1}+${key2}@window->${controller.identifier}`, controller }, // { name: `${event}.${key1}+${key2}@document->${controller.identifier}`, controller } // ]) // ) : [] return [ { name: `${event}->${item.controller.identifier}`, controller }, { name: `${event}@window->${item.controller.identifier}`, controller }, { name: `${event}@document->${item.controller.identifier}`, controller }, // ...keyEvents ] }) }) const controllersWithActions = controllers.concat(controllersWithEvents).flatMap((item) => { const { controller } = item const { actionNames } = controller.controllerDefinition return actionNames.map((action) => { return { name: `${item.name}#${action}`, controller } }) }) // const options = [ // "capture", // "once", // "passive", // "!passive", // "stop", // "self", // ] // const controllersWithActionOptions = controllersWithActions.flatMap((item) => { // const { controller } = item // // return options.map((option) => { // return { name: `${item.name}:${option}`, controller } // }) // }) return [ ...events, ...controllers, ...controllersWithEvents, ...controllersWithActions, // ...controllersWithActionOptions, ] } const targetMatches = attribute.match(/data-(.+)-target/) if (targetMatches && Array.isArray(targetMatches) && targetMatches[1]) { const identifier = targetMatches[1] const controller = this.controllers.find((controller) => controller.identifier == identifier) if (!controller) return [] return controller.controllerDefinition.targetNames.map((name) => ({ name })) } const valueMatches = attribute.match(/data-(.+)-(.+)-value/) if (valueMatches && Array.isArray(valueMatches) && valueMatches[1]) { const identifier = valueMatches[1] const value = valueMatches[2] const controller = this.controllers.find((controller) => controller.identifier == identifier) if (controller) { const valueDefiniton = controller.controllerDefinition.values.find((definition) => definition.name === value) if (!valueDefiniton) return [] const defaultValue = (valueDefiniton.hasExplicitDefaultValue) ? { name: JSON.stringify(valueDefiniton.default).replace(/"/g, '\\"') } : { name: "" } if (valueDefiniton.type === "Boolean") { return [ defaultValue, { name: "true" }, { name: "false" }, { name: "null" }, ] } if (valueDefiniton.type === "Number") { return [ { name: "-1" }, { name: "0" }, defaultValue, { name: "1" }, { name: "2" }, { name: "3" }, { name: "4" }, { name: "5" }, { name: "6" }, { name: "7" }, { name: "8" }, { name: "9" }, { name: "10" }, ] } if (valueDefiniton.type === "Object") { return [defaultValue, { name: "{}" }] } if (valueDefiniton.type === "Array") { return [defaultValue, { name: "[]" }] } if (valueDefiniton.type === "String") { return [defaultValue, { name: identifier }, { name: value }] } } } return [] } } ================================================ FILE: server/src/definitions.ts ================================================ import { Herb } from "@herb-tools/node-wasm" import { Range, DefinitionParams, LocationLink } from "vscode-languageserver/node" import { DocumentService } from "./document_service" import { StimulusHTMLDataProvider } from "./data_providers/stimulus_html_data_provider" import { getLanguageService } from "@herb-tools/language-service" import { parseActionDescriptorString } from "./action_descriptor" import { tokenList, reverseString } from "./html_util" import type { Node, HerbHTMLNode } from "@herb-tools/language-service" import type { TextDocument } from "vscode-languageserver-textdocument" export class Definitions { private readonly documentService: DocumentService private readonly stimulusDataProvider: StimulusHTMLDataProvider constructor(documentService: DocumentService, stimulusDataProvider: StimulusHTMLDataProvider) { this.documentService = documentService this.stimulusDataProvider = stimulusDataProvider } get controllers() { return this.stimulusDataProvider.controllers } onDefinition(params: DefinitionParams) { const textDocument = this.documentService.get(params.textDocument.uri) if (!textDocument) return const html = getLanguageService({ herb: Herb }).parseHTMLDocument(textDocument) const offset = textDocument.offsetAt(params.position) const node = html.findNodeAt(offset) const content = textDocument.getText() const attributeNameResult = this.resolveAttributeNameDefinition(node, offset, content, textDocument) if (attributeNameResult) return attributeNameResult const herbNode = node as HerbHTMLNode let activeAttribute: string | null = null if (herbNode.attributeSourceRanges) { for (const [name, range] of Object.entries(herbNode.attributeSourceRanges)) { if (offset >= range.valueStart && offset <= range.valueEnd) { activeAttribute = name break } } } if (!activeAttribute) return [] const attributeStart = this.previousIndex(content, ["'", '"'], offset) const attributeEnd = this.nextIndex(content, ["'", '"'], offset) const fullValue = content.substring(attributeStart, attributeEnd) let token: string let tokenStart: number if (!fullValue.includes(" ")) { token = fullValue tokenStart = attributeStart } else { const relativeStart = this.previousIndex(fullValue, [" "], offset - attributeStart) const relativeEnd = this.nextIndex(fullValue, [" "], offset - attributeStart) token = fullValue.substring(relativeStart, relativeEnd) tokenStart = attributeStart + relativeStart } if (activeAttribute === "data-action") { return this.resolveActionDefinition(token, tokenStart, offset, textDocument) } if (activeAttribute === "data-controller") { return this.resolveControllerDefinition(token, tokenStart, node, textDocument) } return [] } private resolveAttributeNameDefinition( node: Node, offset: number, _content: string, textDocument: TextDocument, ): LocationLink[] | null { const herbNode = node as HerbHTMLNode if (!herbNode.attributeSourceRanges) return null for (const [attributeName, sourceRange] of Object.entries(herbNode.attributeSourceRanges)) { if (offset < sourceRange.nameStart || offset > sourceRange.nameEnd) continue if (!attributeName.startsWith("data-")) return [] if (attributeName === "data-controller" || attributeName === "data-action") return [] if (attributeName.startsWith("aria-")) return [] const withoutPrefix = attributeName.slice(5) const identifier = this.findControllerIdentifierInAttribute(withoutPrefix) if (!identifier) continue const nameRange = herbNode.getAttributeNameRange(attributeName) if (!nameRange) continue const source = textDocument.getText() const nameInSource = source.slice(nameRange.start, nameRange.end) const identifierUnderscored = identifier.replace(/-/g, "_") const identifierPosition = nameInSource.indexOf(identifierUnderscored) !== -1 ? nameInSource.indexOf(identifierUnderscored) : nameInSource.indexOf(identifier) !== -1 ? nameInSource.indexOf(identifier) : 0 const identifierLength = nameInSource.indexOf(identifierUnderscored) !== -1 ? identifierUnderscored.length : identifier.length const originRange = Range.create( textDocument.positionAt(nameRange.start + identifierPosition), textDocument.positionAt(nameRange.start + identifierPosition + identifierLength), ) return this.controllerLinks([identifier], originRange) } return null } private findControllerIdentifierInAttribute(withoutPrefix: string): string | null { const suffixes = ["-target", "-class"] const controllerIdentifiers = this.controllers.map((controller) => controller.identifier) for (const suffix of suffixes) { if (withoutPrefix.endsWith(suffix)) { const candidate = withoutPrefix.slice(0, -suffix.length) if (controllerIdentifiers.includes(candidate)) { return candidate } } } if (withoutPrefix.endsWith("-value")) { const withoutValue = withoutPrefix.slice(0, -6) const parts = withoutValue.split("-") for (let splitIndex = 1; splitIndex < parts.length; splitIndex++) { const candidate = parts.slice(0, splitIndex).join("-") if (controllerIdentifiers.includes(candidate)) { return candidate } } } if (controllerIdentifiers.includes(withoutPrefix)) { return withoutPrefix } return null } private resolveControllerDefinition( identifier: string, identifierStart: number, node: Node, textDocument: TextDocument, ): LocationLink[] { let identifiers: string[] if (this.controllers.some((controller) => controller.identifier === identifier)) { identifiers = [identifier] } else { identifiers = tokenList(node, "data-controller") } const originRange = Range.create( textDocument.positionAt(identifierStart), textDocument.positionAt(identifierStart + identifier.length), ) return this.controllerLinks(identifiers, originRange) } private resolveActionDefinition( actionString: string, actionStringStart: number, cursorOffset: number, textDocument: TextDocument, ): LocationLink[] { const descriptor = parseActionDescriptorString(actionString) if (!descriptor.identifier || !descriptor.methodName) return [] const arrowIndex = actionString.indexOf("->") const hashIndex = actionString.indexOf("#") const cursorRelative = cursorOffset - actionStringStart if (arrowIndex !== -1 && cursorRelative < arrowIndex) { return [] } if (hashIndex !== -1 && cursorRelative > hashIndex) { const methodStart = actionStringStart + hashIndex + 1 const colonIndex = actionString.indexOf(":", hashIndex) const methodEnd = colonIndex !== -1 ? actionStringStart + colonIndex : actionStringStart + actionString.length const originRange = Range.create( textDocument.positionAt(methodStart), textDocument.positionAt(methodEnd), ) return this.methodLinks(descriptor.identifier, descriptor.methodName, originRange) } const identifierStart = arrowIndex !== -1 ? actionStringStart + arrowIndex + 2 : actionStringStart const identifierEnd = hashIndex !== -1 ? actionStringStart + hashIndex : actionStringStart + actionString.length const originRange = Range.create( textDocument.positionAt(identifierStart), textDocument.positionAt(identifierEnd), ) return this.controllerLinks([descriptor.identifier], originRange) } private controllerLinks(identifiers: string[], originRange: Range): LocationLink[] { const controllers = this.controllers.filter( (controller) => identifiers.includes(controller.identifier), ) return controllers.map((controller) => LocationLink.create( `file://${controller.path}`, Range.create(0, 0, 0, 0), Range.create(0, 0, 0, 0), originRange, ), ) } private methodLinks(identifier: string, methodName: string, originRange: Range): LocationLink[] { const controller = this.controllers.find( (controller) => controller.identifier === identifier, ) if (!controller) return [] const methodDefinition = controller.controllerDefinition.methodDefinitions.find( (method: any) => method.name === methodName, ) if (methodDefinition?.node?.loc) { const targetRange = Range.create( methodDefinition.node.loc.start.line - 1, methodDefinition.node.loc.start.column, methodDefinition.node.loc.end.line - 1, methodDefinition.node.loc.end.column, ) return [ LocationLink.create( `file://${controller.path}`, targetRange, targetRange, originRange, ), ] } return this.controllerLinks([identifier], originRange) } private nextIndex(string: string, tokens: string[], offset: number) { const indexes = tokens .map((token) => string.indexOf(token, offset)) .filter((index) => index !== -1) if (indexes.length === 0) return string.length return Math.min(...indexes) } private previousIndex(string: string, tokens: string[], offset: number) { const indexes = tokens .map((token) => reverseString(string).indexOf(token, string.length - offset)) .filter((index) => index !== -1) .map((index) => string.length - index) if (indexes.length === 0) return 0 return Math.min(...indexes) } } ================================================ FILE: server/src/diagnostics.ts ================================================ import dedent from "dedent" import { Connection, Diagnostic, DiagnosticSeverity, Position, Range } from "vscode-languageserver/node" import { TextDocument } from "vscode-languageserver-textdocument" import { getLanguageService, Node, findTokenIndex } from "@herb-tools/language-service" import type { HerbHTMLNode } from "@herb-tools/language-service" import { Herb } from "@herb-tools/node-wasm" import { parseActionDescriptorString } from "./action_descriptor" import { DocumentService } from "./document_service" import { attributeValue, tokenList } from "./html_util" import { didyoumean, camelize, dasherize } from "./utils" import { StimulusHTMLDataProvider } from "./data_providers/stimulus_html_data_provider" import type { Service } from "./service" import type { Project, SourceFile } from "stimulus-parser" import type * as Acorn from "acorn" export interface InvalidControllerDiagnosticData { identifier: string suggestion: string } export interface DeprecatedPackageImportsDiagnosticData { identifier: string suggestion: string importSourceRange: Range textDocument: TextDocument } export interface InvalidActionDiagnosticData { identifier: string actionName: string suggestion: string } export class Diagnostics { private readonly connection: Connection private readonly stimulusDataProvider: StimulusHTMLDataProvider private readonly documentService: DocumentService private readonly project: Project private readonly service: Service private readonly diagnosticsSource = "Stimulus LSP " private diagnostics: Map = new Map() controllerAttribute = "data-controller" actionAttribute = "data-action" targetAttribute = /data-(.+)-target/ valueAttribute = /data-(.+)-(.+)-value/ constructor( connection: Connection, stimulusDataProvider: StimulusHTMLDataProvider, documentService: DocumentService, project: Project, service: Service, ) { this.connection = connection this.stimulusDataProvider = stimulusDataProvider this.documentService = documentService this.project = project this.service = service } get controllers() { return this.stimulusDataProvider.controllers } get controllerIdentifiers() { return this.controllers.map((controller) => controller.identifier) } validateParsedControllerWithoutErrors(node: Node, textDocument: TextDocument) { const identifiers = tokenList(node, this.controllerAttribute) identifiers.forEach((identifier) => { const controller = this.controllers.find((controller) => controller.identifier === identifier) if (!controller || !controller.controllerDefinition.hasErrors) return const attributeValueRange = this.attributeValueRange(textDocument, node, this.controllerAttribute, identifier) controller.controllerDefinition.errors.forEach((error) => { this.createParseErrorDiagnosticFor(identifier, error.message || "", textDocument, attributeValueRange) }) }) } populateSourceFileErrorsAsDiagnostics(sourceFile: SourceFile, textDocument: TextDocument) { const errors = sourceFile.errors.concat( sourceFile.classDeclarations.flatMap((classDeclaration) => classDeclaration.controllerDefinition?.errors || []), ) errors.map((error) => { const range = this.rangeFromLoc(textDocument, error.loc) this.pushDiagnostic( error.message, "stimulus.source_file.error", range, textDocument, {}, DiagnosticSeverity.Warning, ) }) } validateDataControllerAttribute(node: Node, textDocument: TextDocument) { const identifiers = tokenList(node, this.controllerAttribute) const invalidIdentifiers = identifiers.filter( (identifier) => !this.controllerIdentifiers.includes(identifier) && !this.foundSkippableTags(identifier), ) invalidIdentifiers.forEach((identifier) => { const attributeValueRange = this.attributeValueRange(textDocument, node, this.controllerAttribute, identifier) this.createInvalidControllerDiagnosticFor(identifier, textDocument, attributeValueRange) }) } validateDataActionAttribute(node: Node, textDocument: TextDocument) { const actions = tokenList(node, this.actionAttribute) actions.forEach((action) => { const actionDescriptor = parseActionDescriptorString(action) const { identifier, methodName } = actionDescriptor if (!identifier || !methodName) { const attributeValueRange = this.attributeValueRange(textDocument, node, this.actionAttribute, action) this.createInvalidActionDiagnosticFor(action, textDocument, attributeValueRange) return } const controller = this.controllers.find((controller) => controller.identifier === identifier) if (!controller && !this.foundSkippableTags(identifier)) { const attributeValueRange = this.attributeValueRange(textDocument, node, this.actionAttribute, identifier) this.createInvalidControllerDiagnosticFor(identifier, textDocument, attributeValueRange) } if (controller && controller.controllerDefinition.hasErrors) return if ( controller && methodName && !controller.controllerDefinition.actionNames.includes(methodName) && !this.foundSkippableTags(methodName) ) { const attributeValueRange = this.attributeValueRange(textDocument, node, this.actionAttribute, methodName) this.createInvalidControllerActionDiagnosticFor(identifier, methodName, textDocument, attributeValueRange) } }) } validateDataValueAttribute(node: Node, textDocument: TextDocument) { const attributes = node.attributes || {} const valueAttributeNames = Object.keys(attributes).filter((attribute) => attribute.match(this.valueAttribute)) valueAttributeNames.forEach((attribute) => { const value = attributeValue(node, attribute) || "" const attributeMatches = attribute.match(this.valueAttribute) if (this.isIgnoredAttribute(attribute)) { return } // cannot analyze value if it is interpolated if (this.foundSkippableTags(value)) { return } if (attributeMatches && Array.isArray(attributeMatches) && attributeMatches[1]) { let identifier = attributeMatches[1] let valueName = attributeMatches[2] let controller = this.controllers.find((controller) => controller.identifier === identifier) if (!controller) { const identifierSplits = identifier.split("--") let valuePart let namespacePart // has namespace if (identifierSplits.length > 1) { namespacePart = identifierSplits.slice(0, -1).join("--") valuePart = identifierSplits[identifierSplits.length - 1] } else { namespacePart = null valuePart = identifierSplits[0] } const allParts = valuePart.split("-").concat(valueName.split("-")) for (let i = 1; i <= allParts.length; i++) { if (controller) continue let potentialIdentifier = allParts.slice(0, i).join("-") if (namespacePart) { potentialIdentifier = `${namespacePart}--${potentialIdentifier}` } const potentialValueName = allParts.slice(i, allParts.length).join("-") controller = this.controllers.find((controller) => controller.identifier === potentialIdentifier) if (controller) { identifier = potentialIdentifier valueName = potentialValueName } } } if (!controller) { const attributeNameRange = this.attributeNameRange(textDocument, node, attribute, identifier) this.createInvalidControllerDiagnosticFor(identifier, textDocument, attributeNameRange, { attribute }) return } const hasUppercaseLetter = valueName.match(/[A-Z]/g) if (hasUppercaseLetter) { const attributeNameRange = this.attributeNameRange(textDocument, node, attribute, valueName) this.createAttributeFormatMismatchDiagnosticFor(identifier, valueName, textDocument, attributeNameRange) return } const camelizedValueName = camelize(valueName) const valueDefiniton = controller.controllerDefinition.values.find( (definition) => definition.name === camelizedValueName, ) if (controller && controller.controllerDefinition.hasErrors) return if (controller && !valueDefiniton) { const attributeNameRange = this.attributeNameRange(textDocument, node, attribute, valueName) this.createMissingValueOnControllerDiagnosticFor( identifier, camelizedValueName, textDocument, attributeNameRange, ) return } if (!valueDefiniton) return let actualType const expectedType = valueDefiniton.type try { actualType = this.parseValueType(JSON.parse(value)) } catch (e) { try { actualType = this.parseValueType(JSON.parse(`"${value}"`)) } catch (e: any) { actualType = e?.message || "unparsable" } } if (actualType !== expectedType) { const attributeValueRange = this.attributeValueRange(textDocument, node, attribute, value) this.createValueMismatchOnControllerDiagnosticFor( identifier, camelizedValueName, expectedType, actualType, textDocument, attributeValueRange, ) } } }) } validateDataClassAttribute(_node: Node, _textDocument: TextDocument) { // TODO: implement } validateDataTargetAttribute(node: Node, textDocument: TextDocument) { const attributes = node.attributes || {} const targetAttributeNames = Object.keys(attributes).filter((attribute) => attribute.match(this.targetAttribute)) targetAttributeNames.forEach((attribute) => { if (this.isIgnoredAttribute(attribute)) return const targetName = attributeValue(node, attribute) || "" const targetMatches = attribute.match(this.targetAttribute) const matchedTarget = targetMatches && Array.isArray(targetMatches) const identifier = matchedTarget && targetMatches[1] if (identifier) { const controller = this.controllers.find((controller) => controller.identifier === identifier) if (!controller) { const attributeNameRange = this.attributeNameRange(textDocument, node, attribute, identifier) this.createInvalidControllerDiagnosticFor(identifier, textDocument, attributeNameRange, { attribute }) return } if (controller && controller.controllerDefinition.hasErrors) return if ( controller && !controller.controllerDefinition.targetNames.includes(targetName) && this.foundSkippableTags(targetName) ) { const attributeNameRange = this.attributeValueRange(textDocument, node, attribute, targetName) this.createMissingTargetOnControllerDiagnosticFor(identifier, targetName, textDocument, attributeNameRange) } } }) } validateStimulusImports(sourceFile: SourceFile, textDocument: TextDocument) { if (sourceFile.importDeclarations.length === 0) return const replacements: { [key: string]: string } = { stimulus: "@hotwired/stimulus", "@stimulus/webpack-helpers": "@hotwired/stimulus-webpack-helpers", } sourceFile.importDeclarations.forEach((importDeclaration) => { if (!importDeclaration.node.loc) return const range = this.rangeFromLoc(textDocument, importDeclaration.node.loc) const importSourceRange = this.rangeFromLoc(textDocument, importDeclaration.node.source.loc) // Strip out the quotes importSourceRange.start.character += 1 importSourceRange.end.character -= 1 const data: DeprecatedPackageImportsDiagnosticData = { identifier: importDeclaration.source, suggestion: replacements[importDeclaration.source], textDocument, importSourceRange, } if (Object.keys(replacements).includes(importDeclaration.source)) { this.pushDiagnostic( `You are importing from the deprecated \`${importDeclaration.source}\` package.\nPlease use the new \`${replacements[importDeclaration.source]}\` package.\n`, "stimulus.package.deprecated.import", range, textDocument, data, DiagnosticSeverity.Information, ) } }) } validateValueDefinitions(sourceFile: SourceFile, textDocument: TextDocument) { sourceFile.controllerDefinitions.forEach((controller) => { if (controller.values.length === 0) return controller.values.forEach((valueDefinition) => { const defaultValueType = this.parseValueType(valueDefinition.default) if (!["Array", "Boolean", "Number", "Object", "String"].includes(valueDefinition.type)) { const range = this.rangeFromLoc(textDocument, valueDefinition.typeLoc) this.pushDiagnostic( `Unknown Value type. The "${valueDefinition.name}" value is defined as type "${valueDefinition.type}". \nPossible Values: \`Array\`, \`Boolean\`, \`Number\`, \`Object\`, or \`String\`.\n`, "stimulus.controller.value_definition.unknown_type", range, textDocument, {}, DiagnosticSeverity.Error, ) return } if (valueDefinition.type !== defaultValueType) { const range = this.rangeFromLoc(textDocument, valueDefinition.defaultValueLoc) const message = dedent` The type of the default value you provided doesn't match the type you defined. The "${valueDefinition.name}" Stimulus Value is of type \`${valueDefinition.type}\`. The default value you provided for "${valueDefinition.name}" is of type \`${defaultValueType}\`. ` this.pushDiagnostic( message, "stimulus.controller.value_definition.default_value.type_mismatch", range, textDocument, {}, DiagnosticSeverity.Error, ) } }) }) } visitNode(node: Node, textDocument: TextDocument) { this.validateParsedControllerWithoutErrors(node, textDocument) this.validateDataControllerAttribute(node, textDocument) this.validateDataActionAttribute(node, textDocument) this.validateDataValueAttribute(node, textDocument) this.validateDataClassAttribute(node, textDocument) this.validateDataTargetAttribute(node, textDocument) node.children.forEach((child) => { this.visitNode(child, textDocument) }) } validate(textDocument: TextDocument) { if (["javascript", "typescript"].includes(textDocument.languageId)) { this.validateJavaScriptDocument(textDocument) } else { this.validateHTMLDocument(textDocument) } this.sendDiagnosticsFor(textDocument) } validateJavaScriptDocument(textDocument: TextDocument) { const sourceFile = this.project.projectFiles.find((file) => `file://${file.path}` === textDocument.uri) if (sourceFile) { this.populateSourceFileErrorsAsDiagnostics(sourceFile, textDocument) this.validateValueDefinitions(sourceFile, textDocument) this.validateStimulusImports(sourceFile, textDocument) } } validateHTMLDocument(textDocument: TextDocument) { const service = getLanguageService({ herb: Herb }) const html = service.parseHTMLDocument(textDocument) html.roots.forEach((node: Node) => { this.visitNode(node, textDocument) }) } refreshDocument(document: TextDocument) { this.validate(document) } refreshAllDocuments() { this.documentService.getAll().forEach((document) => { this.refreshDocument(document) }) } private rangeFromLoc(textDocument: TextDocument, loc?: Acorn.SourceLocation | null): Range { let range = Range.create(textDocument.positionAt(0), textDocument.positionAt(0)) if (loc) { const start = Position.create(loc.start.line - 1, loc.start.column) const end = Position.create(loc.end.line - 1, loc.end.column) range = Range.create(start, end) } return range } private rangeFromNode(textDocument: TextDocument, node: Node) { return Range.create(textDocument.positionAt(node.start), textDocument.positionAt(node.startTagEnd || node.end)) } private attributeNameRange(textDocument: TextDocument, node: Node, attribute: string, search: string) { const herbNode = node as HerbHTMLNode const nameRange = herbNode.getAttributeNameRange?.(attribute) if (nameRange) { return Range.create( textDocument.positionAt(nameRange.start), textDocument.positionAt(nameRange.end), ) } const range = this.rangeFromNode(textDocument, node) const startTagContent = textDocument.getText(range) return this.rangeForAttributeName(textDocument, startTagContent, node, attribute, search) } private rangeForAttributeName( textDocument: TextDocument, tagContent: string, node: Node, attribute: string, search: string, ) { const searchIndex = attribute.indexOf(search) || 0 const attributeNameStartIndex = tagContent.indexOf(attribute) const attributeNameStart = node.start + attributeNameStartIndex + searchIndex const attributeNameEnd = attributeNameStart + search.length return Range.create(textDocument.positionAt(attributeNameStart), textDocument.positionAt(attributeNameEnd)) } private attributeValueRange(textDocument: TextDocument, node: Node, attribute: string, search: string) { const herbNode = node as HerbHTMLNode const tokenRange = herbNode.getAttributeValueTokenRange?.(attribute, search, textDocument.getText()) if (tokenRange) { return Range.create( textDocument.positionAt(tokenRange.start), textDocument.positionAt(tokenRange.end), ) } const range = this.rangeFromNode(textDocument, node) const startTagContent = textDocument.getText(range) return this.rangeForAttributeValue(textDocument, startTagContent, node, attribute, search) } private rangeForAttributeValue( textDocument: TextDocument, tagContent: string, node: Node, attribute: string, search: string, ) { const value = attributeValue(node, attribute) || "" const searchIndex = findTokenIndex(value, search) !== -1 ? findTokenIndex(value, search) : 0 const attributeStartIndex = tagContent.indexOf(attribute) const attributeValueStart = node.start + attributeStartIndex + attribute.length + searchIndex + 2 const attributeValueEnd = attributeValueStart + search.length return Range.create(textDocument.positionAt(attributeValueStart), textDocument.positionAt(attributeValueEnd)) } private createParseErrorDiagnosticFor(identifier: string, error: string, textDocument: TextDocument, range: Range) { this.pushDiagnostic( `There was an error parsing the "${identifier}" Stimulus controller. \nPlease check the controller for the following error: ${error}`, "stimulus.controller.parse_error", range, textDocument, { identifier }, ) } private isIgnoredController(identifier: string) { const ignoredIdentifiers = this.service.config?.ignoredControllerIdentifiers || [] return ignoredIdentifiers.includes(identifier) } private isIgnoredAttribute(attribute: string) { const ignoredAttributes = this.service.config?.ignoredAttributes || [] return ignoredAttributes.includes(attribute) } private createInvalidControllerDiagnosticFor(identifier: string, textDocument: TextDocument, range: Range, data?: Object) { const match = didyoumean( identifier, this.controllers.map((controller) => controller.identifier), ) const suggestion = match ? `Did you mean "${match}"?` : "" if (this.isIgnoredController(identifier)) return this.pushDiagnostic( `"${identifier}" isn't a valid Stimulus controller. ${suggestion}`, "stimulus.controller.invalid", range, textDocument, { identifier, suggestion: match, textDocument, range, data }, ) } private createInvalidActionDiagnosticFor(action: string, textDocument: TextDocument, range: Range) { this.pushDiagnostic(`"${action}" isn't a valid action descriptor`, "stimulus.action.invalid", range, textDocument, { action, }) } private createInvalidControllerActionDiagnosticFor( identifier: string, actionName: string, textDocument: TextDocument, range: Range, ) { const controller = this.controllers.find((controller) => controller.identifier === identifier) const match = controller ? didyoumean(actionName, controller.controllerDefinition.actionNames) : null const suggestion = match ? `Did you mean "${match}"?` : "" this.pushDiagnostic( `"${actionName}" isn't a valid Controller Action on the "${identifier}" controller. ${suggestion}`, "stimulus.controller.action.invalid", range, textDocument, { identifier, actionName, suggestion: match, textDocument, range }, ) } private createAttributeFormatMismatchDiagnosticFor( identifier: string, valueName: string, textDocument: TextDocument, range: Range, ) { this.pushDiagnostic( `The data attribute for "${valueName}" on the "${identifier}" controller is camelCased, but should be dasherized ("${dasherize(valueName)}"). Please use dashes for Stimulus data attributes.`, "stimulus.attribute.mismatch", range, textDocument, { identifier, valueName }, ) } private createMissingValueOnControllerDiagnosticFor( identifier: string, valueName: string, textDocument: TextDocument, range: Range, ) { const controller = this.controllers.find((controller) => controller.identifier === identifier) const match = controller ? didyoumean(valueName, Object.keys(controller.controllerDefinition.values)) : null const suggestion = match ? `Did you mean "${match}"?` : "" this.pushDiagnostic( `"${valueName}" isn't a valid Stimulus Value name on the "${identifier}" controller. ${suggestion}`, "stimulus.controller.value.missing", range, textDocument, { identifier, valueName }, ) } private createMissingTargetOnControllerDiagnosticFor( identifier: string, targetName: string, textDocument: TextDocument, range: Range, ) { const controller = this.controllers.find((controller) => controller.identifier === identifier) const match = controller ? didyoumean(targetName, controller.controllerDefinition.targetNames) : null const suggestion = match ? `Did you mean "${match}"?` : "" this.pushDiagnostic( `"${targetName}" isn't a valid Stimulus Target on the "${identifier}" controller. ${suggestion}`, "stimulus.controller.target.missing", range, textDocument, { identifier, targetName }, ) } private createValueMismatchOnControllerDiagnosticFor( identifier: string, valueName: string, expectedType: string, actualType: string, textDocument: TextDocument, range: Range, ) { this.pushDiagnostic( `The value you passed for the "${valueName}" Stimulus Value is of type "${actualType}". But the "${valueName}" Stimulus Value defined in the "${identifier}" controller is of type "${expectedType}".`, "stimulus.controller.value.type_mismatch", range, textDocument, { identifier, valueName }, ) } private pushDiagnostic( message: string, code: string, range: Range, textDocument: TextDocument, data = {}, severity: DiagnosticSeverity = DiagnosticSeverity.Error, ) { const diagnostic: Diagnostic = { source: this.diagnosticsSource, severity, range, message, code, data, } const diagnostics = this.diagnostics.get(textDocument) || [] diagnostics.push(diagnostic) this.diagnostics.set(textDocument, diagnostics) return diagnostic } private sendDiagnosticsFor(textDocument: TextDocument) { const diagnostics = this.diagnostics.get(textDocument) || [] this.connection.sendDiagnostics({ uri: textDocument.uri, diagnostics, }) this.diagnostics.delete(textDocument) } private parseValueType(string: any) { switch (typeof string) { case "boolean": return "Boolean" case "number": return "Number" case "string": return "String" } if (Array.isArray(string)) return "Array" if (Object.prototype.toString.call(string) === "[object Object]") return "Object" } private foundSkippableTags(value: string) { const skippableTags = ["<%", "<%=", "<%-", "%>", "", "{{", "}}"] return skippableTags.some((tag) => value.includes(tag)) } } ================================================ FILE: server/src/document_service.ts ================================================ import { Connection, TextDocuments } from "vscode-languageserver/node" import { TextDocument } from "vscode-languageserver-textdocument" export class DocumentService { public documents: TextDocuments document?: TextDocument constructor(connection: Connection) { this.documents = new TextDocuments(TextDocument) // Make the text document manager listen on the connection // for open, change and close text document events this.documents.listen(connection) } get(uri: string) { return this.documents.get(uri) } getAll() { return this.documents.all() } get onDidChangeContent() { return this.documents.onDidChangeContent } get onDidOpen() { return this.documents.onDidOpen } get onDidClose() { return this.documents.onDidClose } } ================================================ FILE: server/src/events.ts ================================================ export const EVENTS = [ "DOMContentLoaded", "abort", "animationcancel", "animationend", "animationiteration", "animationstart", "auxclick", "change", "click", "compositionend", "compositionstart", "compositionupdate", "contextmenu", "copy", "cut", "dblclick", "drag", "dragend", "dragenter", "dragleave", "dragover", "dragstart", "drop", "error", "focusin", "focusout", "fullscreenchange", "fullscreenerror", "hashchange", "input", "keydown", "keyup", "mousedown", "mousemove", "mouseout", "mouseover", "mouseup", "paste", "pointercancel", "pointerdown", "pointerlockchange", "pointerlockerror", "pointermove", "pointerout", "pointerover", "pointerup", "popstate", "reset", "scroll", "select", "submit", "touchcancel", "touchend", "touchmove", "touchstart", "transitioncancel", "transitionend", "transitionrun", "transitionstart", "visibilitychange", "wheel", ] ================================================ FILE: server/src/html_util.ts ================================================ import { Node } from "@herb-tools/language-service" export function attributeValue(node: Node, attribute: string) { if (!node.attributes) return null const value = node.attributes[attribute] if (!value) return null return unquote(value) } export function tokenList(node: Node, attribute: string) { let value = attributeValue(node, attribute) if (!value) return [] value = squish(value).trim() if (value.length === 0) return [] return splitOnSpaceIgnoreTags(value) } export function unquote(string: string) { return string.substr(1, string.length - 2) } export function reverseString(string: string) { return string.split("").reverse().join("") } export function squish(string: string) { return string.replace(/\s+/g, " ") } export function splitOnSpaceIgnoreTags(string: string) { // All spaces inside certain opening/closing tags are ignored in this regex pattern // Supported tags: // - Opening: <%=, <%, <%-, , -%>, ?>, }} const pattern = /(?|-%>|\?>|\}\})/g return string.split(pattern) } ================================================ FILE: server/src/levenshtein.ts ================================================ /* * The following code is derived from the "js-levenshtein" repository, * Copyright (c) 2017 Gustaf Andersson (https://github.com/gustf/js-levenshtein) * Licensed under the MIT License (https://github.com/gustf/js-levenshtein/blob/master/LICENSE). * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ export function levenshtein(a: string, b: string): number { function _min(d0: any, d1: any, d2: any, bx: any, ay: any) { return d0 < d1 || d2 < d1 ? (d0 > d2 ? d2 + 1 : d0 + 1) : bx === ay ? d1 : d1 + 1 } if (a === b) { return 0 } if (a.length > b.length) { const tmp = a a = b b = tmp } let la = a.length let lb = b.length while (la > 0 && a.charCodeAt(la - 1) === b.charCodeAt(lb - 1)) { la-- lb-- } let offset = 0 while (offset < la && a.charCodeAt(offset) === b.charCodeAt(offset)) { offset++ } la -= offset lb -= offset if (la === 0 || lb < 3) { return lb } let x = 0 let y let d0 let d1 let d2 let d3 let dd let dy let ay let bx0 let bx1 let bx2 let bx3 const vector = [] for (y = 0; y < la; y++) { vector.push(y + 1) vector.push(a.charCodeAt(offset + y)) } const len = vector.length - 1 for (; x < lb - 3; ) { bx0 = b.charCodeAt(offset + (d0 = x)) bx1 = b.charCodeAt(offset + (d1 = x + 1)) bx2 = b.charCodeAt(offset + (d2 = x + 2)) bx3 = b.charCodeAt(offset + (d3 = x + 3)) dd = x += 4 for (y = 0; y < len; y += 2) { dy = vector[y] ay = vector[y + 1] d0 = _min(dy, d0, d1, bx0, ay) d1 = _min(d0, d1, d2, bx1, ay) d2 = _min(d1, d2, d3, bx2, ay) dd = _min(d2, d3, dd, bx3, ay) vector[y] = dd d3 = d2 d2 = d1 d1 = d0 d0 = dy } } for (; x < lb; ) { bx0 = b.charCodeAt(offset + (d0 = x)) dd = ++x for (y = 0; y < len; y += 2) { dy = vector[y] vector[y] = dd = _min(dy, d0, dd, bx0, vector[y + 1]) d0 = dy } } return dd } ================================================ FILE: server/src/requests/controller_definitions.ts ================================================ import { Position } from "vscode-languageserver/node" import { RegisteredController, ControllerDefinition, ClassDeclarationNode } from "stimulus-parser" import { Service } from "../service" import { importStatementForController } from "../utils" import type { ControllerDefinition as ControllerDefinitionRequestType, ControllerDefinitionsRequest as ControllerDefinitionsRequestType, ControllerDefinitionsResponse, } from "../requests" export class ControllerDefinitionsRequest { private service: Service constructor(service: Service) { this.service = service } async handleRequest(_request: ControllerDefinitionsRequestType): Promise { return { registered: { name: "project", controllerDefinitions: this.registeredControllers, }, unregistered: { project: { name: "project", controllerDefinitions: this.unregisteredControllers, }, nodeModules: this.nodeModuleControllers, }, } } private controllerSort(a: ControllerDefinitionRequestType, b: ControllerDefinitionRequestType) { return a.identifier.localeCompare(b.identifier) } private positionFromNode(node: ClassDeclarationNode | undefined) { return Position.create(node?.loc?.start?.line || 1, node?.loc?.start?.column || 1) } private mapControllerDefinition = (controllerDefinition: ControllerDefinition) => { const { path, guessedIdentifier: identifier, classDeclaration } = controllerDefinition const registered = false const position = this.positionFromNode(classDeclaration.node) const { localName, importStatement } = importStatementForController(controllerDefinition, this.service.project) return { path, identifier, position, registered, importStatement, localName, } } private mapRegisteredController = (registeredController: RegisteredController) => { const { path, identifier, classDeclaration } = registeredController const registered = true const position = this.positionFromNode(classDeclaration.node) return { path, identifier, position, registered, } } private get registeredControllerPaths() { return this.service.project.registeredControllers.map((c) => c.path) } private get unregisteredControllerDefinitions() { return this.service.project.controllerDefinitions.filter( (definition) => !this.registeredControllerPaths.includes(definition.path), ) } private get detectedNodeModules() { return this.service.project.detectedNodeModules } private get registeredControllers() { return this.service.project.registeredControllers.map(this.mapRegisteredController).sort(this.controllerSort) } private get unregisteredControllers() { return this.unregisteredControllerDefinitions.map(this.mapControllerDefinition).sort(this.controllerSort) } private get nodeModuleControllers() { // Stimulus-Use's controllers are "abstract" and meant to be extended. So we shouldn't suggest to register them. const excludeList = ["stimulus-use"] const nodeModules = this.detectedNodeModules .filter((module) => !excludeList.includes(module.name)) .map((detectedModule) => { const { name } = detectedModule const controllerDefinitions = detectedModule.controllerDefinitions .filter((definition) => !this.registeredControllerPaths.includes(definition.path)) .map(this.mapControllerDefinition) .sort(this.controllerSort) return { name, controllerDefinitions } }) return nodeModules.filter((m) => m.controllerDefinitions.length > 0).sort((a, b) => a.name.localeCompare(b.name)) } } ================================================ FILE: server/src/requests.ts ================================================ import { Position } from "vscode-languageserver" export type ControllerDefinition = { identifier: string path: string registered: boolean position: Position importStatement?: string localName?: string } export interface ControllerDefinitionsOrigin { name: string controllerDefinitions: ControllerDefinition[] } export interface ProjectControllerDefinitions extends ControllerDefinitionsOrigin { name: "project" } export type ControllerDefinitionsRequest = object export type ControllerDefinitionsResponse = { registered: ProjectControllerDefinitions unregistered: { project: ProjectControllerDefinitions nodeModules: ControllerDefinitionsOrigin[] } } ================================================ FILE: server/src/server.ts ================================================ import { createConnection, ProposedFeatures, InitializeParams, DidChangeConfigurationNotification, DidChangeWatchedFilesNotification, TextDocumentSyncKind, InitializeResult, Diagnostic, } from "vscode-languageserver/node" import { Service } from "./service" import { StimulusSettings } from "./settings" import { version } from "../package.json" import { ControllerDefinitionsRequest } from "./requests/controller_definitions" import type { ControllerDefinitionsRequest as ControllerDefinitionsRequestType, ControllerDefinitionsResponse, } from "./requests" let service: Service const connection = createConnection(ProposedFeatures.all) connection.onInitialize(async (params: InitializeParams) => { service = new Service(connection, params) await service.init() const result: InitializeResult = { serverInfo: { name: "Stimulus LSP", version }, capabilities: { textDocumentSync: TextDocumentSyncKind.Incremental, completionProvider: { resolveProvider: true, triggerCharacters: ['"', "'", " ", "=", "<", "/", "-", ">", "#", "."], }, codeLensProvider: { resolveProvider: true }, codeActionProvider: true, definitionProvider: true, executeCommandProvider: { commands: [ "stimulus.controller.create", "stimulus.controller.update", "stimulus.controller.register", "stimulus.controller.action.update", "stimulus.controller.action.implement", "stimulus.config.create", "stimulus.config.controller.ignore", "stimulus.config.attribute.ignore", "stimulus.import.source.update", ], }, }, } if (service.settings.hasWorkspaceFolderCapability) { result.capabilities.workspace = { workspaceFolders: { supported: true, }, } } return result }) connection.onInitialized(() => { if (service.settings.hasConfigurationCapability) { // Register for all configuration changes. connection.client.register(DidChangeConfigurationNotification.type, undefined) } if (service.settings.hasWorkspaceFolderCapability) { connection.workspace.onDidChangeWorkspaceFolders((_event) => { connection.console.log("Workspace folder change event received.") }) } connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: service.project.controllerRoots.map((root) => ({ globPattern: `**/${root}/**/*` })), }) connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [ { globPattern: `**/**/*.{ts,js}` }, { globPattern: `**/**/.stimulus-lsp/config.json` }, ], }) }) connection.onDidChangeConfiguration((change) => { if (service.settings.hasConfigurationCapability) { // Reset all cached document settings service.settings.documentSettings.clear() } else { service.settings.globalSettings = ( (change.settings.languageServerStimulus || service.settings.defaultSettings) ) as StimulusSettings } service.refresh() }) connection.onDidOpenTextDocument((params) => { const document = service.documentService.get(params.textDocument.uri) if (document) { service.diagnostics.refreshDocument(document) } }) connection.onDidChangeWatchedFiles((params) => { params.changes.forEach(async (event) => { if (event.uri.endsWith("/.stimulus-lsp/config.json")) { await service.refreshConfig() service.documentService.getAll().forEach((document) => { service.diagnostics.refreshDocument(document) }) } }) }) connection.onDefinition((params) => service.definitions.onDefinition(params)) connection.onCodeAction((params) => service.codeActions.onCodeAction(params)) connection.onCodeLens((params) => service.codeLens.onCodeLens(params)) connection.onCodeLensResolve((codeLens) => service.codeLens.onCodeLensResolve(codeLens)) connection.onExecuteCommand((params) => { if (!params.arguments) return if (params.command === "stimulus.controller.create") { const [identifier, diagnostic, controllerRoot] = params.arguments as [string, Diagnostic, string] service.commands.createController(identifier, diagnostic, controllerRoot) } if (params.command === "stimulus.controller.update") { const [identifier, diagnostic, suggestion] = params.arguments as [string, Diagnostic, string] service.commands.updateControllerReference(identifier, diagnostic, suggestion) } if (params.command === "stimulus.controller.action.update") { const [actionName, diagnostic, suggestion] = params.arguments as [string, Diagnostic, string] service.commands.updateControllerActionReference(actionName, diagnostic, suggestion) } if (params.command === "stimulus.controller.action.implement") { const [identifer, actionName, diagnostic] = params.arguments as [string, string, Diagnostic] service.commands.implementControllerAction(identifer, actionName, diagnostic) } if (params.command === "stimulus.import.source.update") { const [diagnostic] = params.arguments as [Diagnostic] service.commands.updateImportSource(diagnostic) } if (params.command === "stimulus.config.create") { const [_identifier, _diagnostic] = params.arguments as [string, Diagnostic] service.commands.createStimulusLSPConfig() } if (params.command === "stimulus.config.controller.ignore") { const [identifier, _diagnostic] = params.arguments as [string, Diagnostic] service.commands.addIgnoredControllerToConfig(identifier) } if (params.command === "stimulus.config.attribute.ignore") { const [attribute, _diagnostic] = params.arguments as [string, Diagnostic] service.commands.addIgnoredAttributeToConfig(attribute) } if (params.command === "stimulus.controller.register") { const [importStatement, identifier, localName] = params.arguments as [string, string, string] service.commands.registerControllerDefinition(importStatement, identifier, localName) } }) connection.onCompletion((textDocumentPosition) => { const document = service.documentService.get(textDocumentPosition.textDocument.uri) if (!document) return null return service.htmlLanguageService.doComplete( document, textDocumentPosition.position, service.htmlLanguageService.parseHTMLDocument(document), ) }) // This handler resolves additional information for the item selected in // the completion list. connection.onCompletionResolve((item) => { if (item.data?.detail) item.detail = item.data.detail if (item.data?.documentation) item.documentation = item.data.documentation if (item.data?.kind) item.kind = item.data.kind return item }) connection.onRequest( "stimulus-lsp/controllerDefinitions", (request: ControllerDefinitionsRequestType): Promise => new ControllerDefinitionsRequest(service).handleRequest(request), ) // Listen on the connection connection.listen() ================================================ FILE: server/src/service.ts ================================================ import { Connection, InitializeParams } from "vscode-languageserver/node" import { getLanguageService, LanguageService } from "@herb-tools/language-service" import { Herb } from "@herb-tools/node-wasm" import { StimulusHTMLDataProvider } from "./data_providers/stimulus_html_data_provider" import { Settings } from "./settings" import { DocumentService } from "./document_service" import { Diagnostics } from "./diagnostics" import { Definitions } from "./definitions" import { Commands } from "./commands" import { CodeActions } from "./code_actions" import { Config } from "./config" import { CodeLensProvider as CodeLens } from "./code_lens" import { Project } from "stimulus-parser" export class Service { connection: Connection settings: Settings htmlLanguageService: LanguageService stimulusDataProvider: StimulusHTMLDataProvider diagnostics: Diagnostics definitions: Definitions commands: Commands documentService: DocumentService codeActions: CodeActions project: Project codeLens: CodeLens config?: Config constructor(connection: Connection, params: InitializeParams) { this.connection = connection this.settings = new Settings(params, this.connection) this.documentService = new DocumentService(this.connection) this.project = new Project(this.settings.projectPath.replace("file://", "")) this.codeActions = new CodeActions(this.documentService, this.project) this.stimulusDataProvider = new StimulusHTMLDataProvider("id", this.project) this.diagnostics = new Diagnostics(this.connection, this.stimulusDataProvider, this.documentService, this.project, this) this.definitions = new Definitions(this.documentService, this.stimulusDataProvider) this.commands = new Commands(this.project, this.connection) this.codeLens = new CodeLens(this.documentService, this.project) this.htmlLanguageService = getLanguageService({ herb: Herb, customDataProviders: [this.stimulusDataProvider], tokenListAttributes: ["data-controller", "data-action"], }) } async init() { await Herb.load() await this.project.initialize() // TODO: we need to setup a file listener to check when new packages get installed await this.project.detectAvailablePackages() await this.project.analyzeAllDetectedModules() this.config = await Config.fromPathOrNew(this.project.projectPath) // Only keep settings for open documents this.documentService.onDidClose((change) => { this.settings.documentSettings.delete(change.document.uri) }) // The content of a text document has changed. This event is emitted // when the text document first opened or when its content has changed. this.documentService.onDidChangeContent((change) => { this.diagnostics.refreshDocument(change.document) }) } async refresh() { await this.project.refresh() this.diagnostics.refreshAllDocuments() } async refreshConfig() { this.config = await Config.fromPathOrNew(this.project.projectPath) } } ================================================ FILE: server/src/settings.ts ================================================ import { ClientCapabilities, Connection, InitializeParams } from "vscode-languageserver/node" export interface StimulusSettings {} export class Settings { // The global settings, used when the `workspace/configuration` request is not supported by the client. // Please note that this is not the case when using this server with the client provided in this example // but could happen with other clients. defaultSettings: StimulusSettings = {} globalSettings: StimulusSettings = this.defaultSettings documentSettings: Map> = new Map() hasConfigurationCapability = false hasWorkspaceFolderCapability = false hasDiagnosticRelatedInformationCapability = false params: InitializeParams capabilities: ClientCapabilities connection: Connection constructor(params: InitializeParams, connection: Connection) { this.params = params this.capabilities = params.capabilities this.connection = connection // Does the client support the `workspace/configuration` request? // If not, we fall back using global settings. this.hasConfigurationCapability = !!(this.capabilities.workspace && !!this.capabilities.workspace.configuration) this.hasWorkspaceFolderCapability = !!( this.capabilities.workspace && !!this.capabilities.workspace.workspaceFolders ) this.hasDiagnosticRelatedInformationCapability = !!( this.capabilities.textDocument && this.capabilities.textDocument.publishDiagnostics && this.capabilities.textDocument.publishDiagnostics.relatedInformation ) } get projectPath() { return this.params.rootUri || "" } getDocumentSettings(resource: string): Thenable { if (!this.hasConfigurationCapability) { return Promise.resolve(this.globalSettings) } let result = this.documentSettings.get(resource) if (!result) { result = this.connection.workspace.getConfiguration({ scopeUri: resource, section: "languageServerStimulus", }) this.documentSettings.set(resource, result) } return result } } ================================================ FILE: server/src/utils.ts ================================================ import path from "path" import { levenshtein } from "./levenshtein" import type { Project, ExportDeclaration, ControllerDefinition } from "stimulus-parser" function rank(input: string, list: string[]) { return list .map((item) => { const score = levenshtein(input.toLowerCase(), item.toLowerCase()) return { item, score } }) .sort((a, b) => a.score - b.score) } export function didyoumean(input: string, list: string[]): string | null { if (list.length === 0) return null const scores = rank(input, list) if (scores.length === 0) return null return scores[0].item } export function camelize(value: string) { return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase()) } export function dasherize(value: string) { return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`) } export function capitalize(value: string) { return value.charAt(0).toUpperCase() + value.slice(1) } export function importStatementForController(controllerDefinition: ControllerDefinition, project: Project) { const importSource = importSourceForController(controllerDefinition, project) const exportDeclaration = exportDeclarationFromControllerDefinition(controllerDefinition, project) if (!exportDeclaration) return { importStatement: undefined, localName: undefined, importSpecifier: undefined, importSource, exportDeclaration } return importStatementFromExportDeclaration(exportDeclaration, controllerDefinition, importSource) } export function importSourceForController(controllerDefinition: ControllerDefinition, project: Project) { if (controllerDefinition.sourceFile.isProjectFile) { return relativeControllersFilePath(project, controllerDefinition.sourceFile.path) } const nodeModule = nodeModleForController(controllerDefinition, project) return nodeModule?.name || "" } export function nodeModleForController(controllerDefinition: ControllerDefinition, project: Project) { return project.detectedNodeModules.find((module) => module.sourceFiles.includes(controllerDefinition.sourceFile)) } export function localNameForExportDeclaration(exportDeclaration: ExportDeclaration, controller: ControllerDefinition) { return exportDeclaration.type === "default" ? controller.classDeclaration.className || `${capitalize(camelize(controller.guessedIdentifier))}Controller` : exportDeclaration.exportedName || controller.guessedIdentifier } export function importStatementFromExportDeclaration( exportDeclaration: ExportDeclaration, controller: ControllerDefinition, importSource: string, ) { const exportType = exportDeclaration?.type const localName = localNameForExportDeclaration(exportDeclaration, controller) const importSpecifier = exportType === "default" ? localName : `{ ${localName} }` const importStatement = `import ${importSpecifier} from "${importSource}"` return { exportDeclaration, localName, importSpecifier, importStatement, importSource, } } export function relativeControllersFilePath(project: Project, filePath: string): string { if (project.controllersIndexFiles.length === 0) return "" // TODO: Account for importmaps const relativePath = path.relative( path.dirname(project.controllersIndexFiles[0].path), filePath, ) const fileName = path.basename( relativePath, path.extname(relativePath) ) const controllerPath = path.join( path.dirname(relativePath), fileName ) return controllerPath.startsWith(".") ? controllerPath : `./${controllerPath}` } export function exportDeclarationFromControllerDefinition(controllerDefinition: ControllerDefinition, project: Project) { if (controllerDefinition.sourceFile.isProjectFile) return controllerDefinition.classDeclaration.exportDeclaration const nodeModule = nodeModleForController(controllerDefinition, project) if (!nodeModule) return undefined return nodeModule.entrypointSourceFile?.exportDeclarations.find((exportDeclaration) => { try { return exportDeclaration.nextResolvedClassDeclaration?.controllerDefinition === controllerDefinition } catch(error: any) { return false } }) } ================================================ FILE: server/tsconfig.json ================================================ { "compilerOptions": { "target": "es2019", "lib": ["ES2019", "dom"], "module": "commonjs", "moduleResolution": "node", "resolveJsonModule": true, "esModuleInterop": true, "sourceMap": true, "strict": true, "skipLibCheck": true, "outDir": "out", "rootDir": "src", "erasableSyntaxOnly": true, "typeRoots": [ "./types", "./node_modules/@types" ] }, "include": ["src"], "exclude": ["node_modules"] } ================================================ FILE: server/types/typescript-eslint__typescript-estree/index.d.ts ================================================ declare module "@typescript-eslint/typescript-estree" { export * from "@typescript-eslint/typescript-estree/dist/index" } ================================================ FILE: server/types/typescript-eslint__typescript-types/index.d.ts ================================================ declare module "@typescript-eslint/types" { export * from "@typescript-eslint/types/dist/index" } ================================================ FILE: server/types/typescript-eslint__visitor-keys/index.d.ts ================================================ declare module "@typescript-eslint/visitor-keys" { export * from "@typescript-eslint/visitor-keys/dist/index" } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "module": "commonjs", "moduleResolution": "node", "esModuleInterop": true, "target": "es2019", "lib": ["ES2019"], "outDir": "out", "rootDir": "src", "sourceMap": true, "skipLibCheck": true, "erasableSyntaxOnly": true }, "include": [ "src" ], "exclude": [ "node_modules", ".vscode-test" ], "references": [ { "path": "./client" }, { "path": "./server" }, ] }