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.

## 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" },
]
}