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 `<div data-controller="|">`, 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<void> {
if (this.client) {
await this.client.stop()
}
}
async sendNotification(method: string, params: any) {
return await this.client.sendNotification(method, params)
}
async sendRequest<T>(method: string, params: any) {
return await this.client.sendRequest<T>(method, params)
}
async requestControllerDefinitions(): Promise<ControllerDefinitionsResponse> {
return await this.sendRequest<ControllerDefinitionsResponse>("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<ControllerDefinitionTreeItem>, Disposable {
private client: Client
private readonly treeView: TreeView<ControllerDefinitionTreeItem>
private readonly subscriptions: Disposable[] = []
private _onDidChangeTreeData: EventEmitter<any> = new EventEmitter<any>()
readonly onDidChangeTreeData: Event<any> = 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<ControllerDefinitionsResponse> {
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<void> {
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=<port> 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<ActionDescriptor> {
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<string>
ignoredAttributes: Array<string>
}
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<string> {
return this.options.ignoredControllerIdentifiers
}
get ignoredAttributes(): Array<string> {
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<TextDocument, Diagnostic[]> = 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 = ["<%", "<%=", "<%-", "%>", "<?=", "<?php", "?>", "{{", "}}"]
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<TextDocument>
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: <%=, <%, <%-, <?php, <?=, {{
// - Closing: %>, -%>, ?>, }}
const pattern = /(?<!<%=|<%|<%-|<\?php|<\?=|\{\{.*?)\s+(?![^<]*?%>|-%>|\?>|\}\})/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<ControllerDefinitionsResponse> {
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<ControllerDefinitionsResponse> =>
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<string, Thenable<StimulusSettings>> = 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<StimulusSettings> {
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" },
]
}
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
SYMBOL INDEX (198 symbols across 21 files)
FILE: client/src/client.ts
class Client (line 9) | class Client {
method constructor (line 16) | constructor(context: ExtensionContext) {
method start (line 29) | async start() {
method stop (line 39) | async stop(): Promise<void> {
method sendNotification (line 45) | async sendNotification(method: string, params: any) {
method sendRequest (line 49) | async sendRequest<T>(method: string, params: any) {
method requestControllerDefinitions (line 53) | async requestControllerDefinitions(): Promise<ControllerDefinitionsRes...
method debugOptions (line 59) | private get debugOptions() {
method serverOptions (line 67) | private get serverOptions(): ServerOptions {
method clientOptions (line 81) | private get clientOptions(): LanguageClientOptions {
FILE: client/src/controller_tree_view.ts
type ControllerDefinitionTreeItem (line 19) | type ControllerDefinitionTreeItem = ControllerTreeItem | ControllerDefin...
class ControllerTreeView (line 21) | class ControllerTreeView implements TreeDataProvider<ControllerDefinitio...
method constructor (line 28) | constructor(client: Client) {
method dispose (line 48) | dispose() {
method getTreeItem (line 53) | getTreeItem(element: ControllerDefinitionTreeItem) {
method getChildren (line 57) | async getChildren(element?: ControllerDefinitionTreeItem) {
method refresh (line 73) | refresh() {
method registerControllerDefinition (line 77) | registerControllerDefinition(item: ControllerTreeItem) {
method requestControllerDefinitions (line 90) | private async requestControllerDefinitions(): Promise<ControllerDefini...
class ControllerDefinitionsStateItem (line 95) | class ControllerDefinitionsStateItem extends TreeItem {
method constructor (line 98) | constructor(name: string, children: ControllerDefinitionsOrigin[]) {
method getChildren (line 111) | getChildren() {
method controllerTreeItems (line 115) | private get controllerTreeItems() {
method controllerDefinitions (line 119) | private get controllerDefinitions(): [ControllerDefinition, Controller...
class ControllerTreeItem (line 130) | class ControllerTreeItem extends TreeItem {
method constructor (line 134) | constructor(item: ControllerDefinition, origin: ControllerDefinitionsO...
method isImportable (line 156) | get isImportable() {
method getChildren (line 164) | getChildren() {
FILE: client/src/extension.ts
function activate (line 6) | async function activate(context: ExtensionContext) {
function deactivate (line 12) | async function deactivate(): Promise<void> {
FILE: client/src/requests.ts
type ControllerDefinition (line 3) | type ControllerDefinition = {
type ControllerDefinitionsOrigin (line 12) | interface ControllerDefinitionsOrigin {
type ProjectControllerDefinitions (line 17) | interface ProjectControllerDefinitions extends ControllerDefinitionsOrig...
type ControllerDefinitionsRequest (line 21) | type ControllerDefinitionsRequest = object
type ControllerDefinitionsResponse (line 22) | type ControllerDefinitionsResponse = {
FILE: server/src/action_descriptor.ts
type ActionDescriptor (line 3) | interface ActionDescriptor {
function parseActionDescriptorString (line 15) | function parseActionDescriptorString(descriptorString: string): Partial<...
function parseEventOptions (line 36) | function parseEventOptions(eventOptions: string): AddEventListenerOptions {
FILE: server/src/code_actions.ts
class CodeActions (line 13) | class CodeActions {
method constructor (line 17) | constructor(documentService: DocumentService, project: Project) {
method onCodeAction (line 22) | onCodeAction(params: CodeActionParams): CodeAction[] {
method handleInvalidControllerDiagnostics (line 40) | private handleInvalidControllerDiagnostics(diagnostics: Diagnostic[]) {
method handleInvalidActionDiagnostics (line 169) | private handleInvalidActionDiagnostics(diagnostics: Diagnostic[]) {
method handleDeprecatedPackageImports (line 193) | private handleDeprecatedPackageImports(diagnostics: Diagnostic[]) {
FILE: server/src/code_lens.ts
class CodeLensProvider (line 7) | class CodeLensProvider {
method constructor (line 11) | constructor(documentService: DocumentService, project: Project) {
method onCodeLens (line 16) | onCodeLens(params: CodeLensParams) {
method onCodeLensResolve (line 57) | onCodeLensResolve(codeLens: CodeLens) {
FILE: server/src/commands.ts
type SerializedTextDocument (line 8) | type SerializedTextDocument = {
class Commands (line 16) | class Commands {
method constructor (line 20) | constructor(project: Project, connection: Connection) {
method updateControllerReference (line 25) | async updateControllerReference(identifier: string, diagnostic: Diagno...
method registerControllerDefinition (line 40) | async registerControllerDefinition(importStatement: string, identifier...
method createController (line 67) | async createController(identifier: string, diagnostic: Diagnostic, con...
method updateControllerActionReference (line 90) | async updateControllerActionReference(actionName: string, diagnostic: ...
method implementControllerAction (line 105) | async implementControllerAction(actionName: string, identifier: string...
method updateImportSource (line 140) | async updateImportSource(diagnostic: Diagnostic) {
method createStimulusLSPConfig (line 164) | async createStimulusLSPConfig() {
method addIgnoredControllerToConfig (line 183) | async addIgnoredControllerToConfig(identifier: string) {
method addIgnoredAttributeToConfig (line 197) | async addIgnoredAttributeToConfig(attribute: string) {
method controllerTemplateFor (line 211) | private controllerTemplateFor(identifier: string) {
FILE: server/src/config.ts
type StimulusConfigOptions (line 1) | type StimulusConfigOptions = {
type StimulusLSPConfig (line 6) | type StimulusLSPConfig = {
class Config (line 17) | class Config {
method constructor (line 23) | constructor(projectPath: string, config: StimulusLSPConfig) {
method version (line 28) | get version(): string {
method createdAt (line 32) | get createdAt(): Date {
method updatedAt (line 36) | get updatedAt(): Date {
method options (line 40) | get options(): StimulusConfigOptions {
method ignoredControllerIdentifiers (line 44) | get ignoredControllerIdentifiers(): Array<string> {
method ignoredAttributes (line 48) | get ignoredAttributes(): Array<string> {
method addIgnoredController (line 52) | public addIgnoredController(identifier: string) {
method addIgnoredAttribute (line 59) | public addIgnoredAttribute(attribute: string) {
method toJSON (line 66) | public toJSON() {
method updateTimestamp (line 70) | private updateTimestamp() {
method updateVersion (line 74) | private updateVersion() {
method write (line 78) | async write() {
method read (line 90) | async read() {
method configPathFromProjectPath (line 94) | static configPathFromProjectPath(projectPath: string) {
method fromPathOrNew (line 98) | static async fromPathOrNew(projectPath: string) {
method fromPath (line 106) | static async fromPath(projectPath: string) {
method newConfig (line 118) | static newConfig(projectPath: string): Config {
FILE: server/src/data_providers/stimulus_html_data_provider.ts
class StimulusHTMLDataProvider (line 8) | class StimulusHTMLDataProvider implements IHTMLDataProvider {
method constructor (line 12) | constructor(id: string, project: Project) {
method controllers (line 17) | get controllers() {
method controllerRoots (line 21) | get controllerRoots() {
method isApplicable (line 25) | isApplicable() {
method getId (line 29) | getId() {
method provideTags (line 33) | provideTags() {
method provideAttributes (line 37) | provideAttributes(_tag: string) {
method provideValues (line 67) | provideValues(_tag: string, attribute: string) {
FILE: server/src/definitions.ts
class Definitions (line 13) | class Definitions {
method constructor (line 17) | constructor(documentService: DocumentService, stimulusDataProvider: St...
method controllers (line 22) | get controllers() {
method onDefinition (line 26) | onDefinition(params: DefinitionParams) {
method resolveAttributeNameDefinition (line 81) | private resolveAttributeNameDefinition(
method findControllerIdentifierInAttribute (line 130) | private findControllerIdentifierInAttribute(withoutPrefix: string): st...
method resolveControllerDefinition (line 164) | private resolveControllerDefinition(
method resolveActionDefinition (line 186) | private resolveActionDefinition(
method controllerLinks (line 236) | private controllerLinks(identifiers: string[], originRange: Range): Lo...
method methodLinks (line 251) | private methodLinks(identifier: string, methodName: string, originRang...
method nextIndex (line 283) | private nextIndex(string: string, tokens: string[], offset: number) {
method previousIndex (line 293) | private previousIndex(string: string, tokens: string[], offset: number) {
FILE: server/src/diagnostics.ts
type InvalidControllerDiagnosticData (line 19) | interface InvalidControllerDiagnosticData {
type DeprecatedPackageImportsDiagnosticData (line 24) | interface DeprecatedPackageImportsDiagnosticData {
type InvalidActionDiagnosticData (line 31) | interface InvalidActionDiagnosticData {
class Diagnostics (line 37) | class Diagnostics {
method constructor (line 51) | constructor(
method controllers (line 65) | get controllers() {
method controllerIdentifiers (line 69) | get controllerIdentifiers() {
method validateParsedControllerWithoutErrors (line 73) | validateParsedControllerWithoutErrors(node: Node, textDocument: TextDo...
method populateSourceFileErrorsAsDiagnostics (line 89) | populateSourceFileErrorsAsDiagnostics(sourceFile: SourceFile, textDocu...
method validateDataControllerAttribute (line 108) | validateDataControllerAttribute(node: Node, textDocument: TextDocument) {
method validateDataActionAttribute (line 121) | validateDataActionAttribute(node: Node, textDocument: TextDocument) {
method validateDataValueAttribute (line 159) | validateDataValueAttribute(node: Node, textDocument: TextDocument) {
method validateDataClassAttribute (line 286) | validateDataClassAttribute(_node: Node, _textDocument: TextDocument) {
method validateDataTargetAttribute (line 290) | validateDataTargetAttribute(node: Node, textDocument: TextDocument) {
method validateStimulusImports (line 328) | validateStimulusImports(sourceFile: SourceFile, textDocument: TextDocu...
method validateValueDefinitions (line 366) | validateValueDefinitions(sourceFile: SourceFile, textDocument: TextDoc...
method visitNode (line 410) | visitNode(node: Node, textDocument: TextDocument) {
method validate (line 423) | validate(textDocument: TextDocument) {
method validateJavaScriptDocument (line 433) | validateJavaScriptDocument(textDocument: TextDocument) {
method validateHTMLDocument (line 443) | validateHTMLDocument(textDocument: TextDocument) {
method refreshDocument (line 452) | refreshDocument(document: TextDocument) {
method refreshAllDocuments (line 456) | refreshAllDocuments() {
method rangeFromLoc (line 462) | private rangeFromLoc(textDocument: TextDocument, loc?: Acorn.SourceLoc...
method rangeFromNode (line 475) | private rangeFromNode(textDocument: TextDocument, node: Node) {
method attributeNameRange (line 479) | private attributeNameRange(textDocument: TextDocument, node: Node, att...
method rangeForAttributeName (line 496) | private rangeForAttributeName(
method attributeValueRange (line 512) | private attributeValueRange(textDocument: TextDocument, node: Node, at...
method rangeForAttributeValue (line 529) | private rangeForAttributeValue(
method createParseErrorDiagnosticFor (line 547) | private createParseErrorDiagnosticFor(identifier: string, error: strin...
method isIgnoredController (line 557) | private isIgnoredController(identifier: string) {
method isIgnoredAttribute (line 563) | private isIgnoredAttribute(attribute: string) {
method createInvalidControllerDiagnosticFor (line 569) | private createInvalidControllerDiagnosticFor(identifier: string, textD...
method createInvalidActionDiagnosticFor (line 587) | private createInvalidActionDiagnosticFor(action: string, textDocument:...
method createInvalidControllerActionDiagnosticFor (line 593) | private createInvalidControllerActionDiagnosticFor(
method createAttributeFormatMismatchDiagnosticFor (line 612) | private createAttributeFormatMismatchDiagnosticFor(
method createMissingValueOnControllerDiagnosticFor (line 627) | private createMissingValueOnControllerDiagnosticFor(
method createMissingTargetOnControllerDiagnosticFor (line 646) | private createMissingTargetOnControllerDiagnosticFor(
method createValueMismatchOnControllerDiagnosticFor (line 665) | private createValueMismatchOnControllerDiagnosticFor(
method pushDiagnostic (line 682) | private pushDiagnostic(
method sendDiagnosticsFor (line 707) | private sendDiagnosticsFor(textDocument: TextDocument) {
method parseValueType (line 718) | private parseValueType(string: any) {
method foundSkippableTags (line 732) | private foundSkippableTags(value: string) {
FILE: server/src/document_service.ts
class DocumentService (line 4) | class DocumentService {
method constructor (line 8) | constructor(connection: Connection) {
method get (line 16) | get(uri: string) {
method getAll (line 20) | getAll() {
method onDidChangeContent (line 24) | get onDidChangeContent() {
method onDidOpen (line 28) | get onDidOpen() {
method onDidClose (line 32) | get onDidClose() {
FILE: server/src/events.ts
constant EVENTS (line 1) | const EVENTS = [
FILE: server/src/html_util.ts
function attributeValue (line 3) | function attributeValue(node: Node, attribute: string) {
function tokenList (line 13) | function tokenList(node: Node, attribute: string) {
function unquote (line 25) | function unquote(string: string) {
function reverseString (line 29) | function reverseString(string: string) {
function squish (line 33) | function squish(string: string) {
function splitOnSpaceIgnoreTags (line 37) | function splitOnSpaceIgnoreTags(string: string) {
FILE: server/src/levenshtein.ts
function levenshtein (line 25) | function levenshtein(a: string, b: string): number {
FILE: server/src/requests.ts
type ControllerDefinition (line 3) | type ControllerDefinition = {
type ControllerDefinitionsOrigin (line 12) | interface ControllerDefinitionsOrigin {
type ProjectControllerDefinitions (line 17) | interface ProjectControllerDefinitions extends ControllerDefinitionsOrig...
type ControllerDefinitionsRequest (line 21) | type ControllerDefinitionsRequest = object
type ControllerDefinitionsResponse (line 22) | type ControllerDefinitionsResponse = {
FILE: server/src/requests/controller_definitions.ts
class ControllerDefinitionsRequest (line 14) | class ControllerDefinitionsRequest {
method constructor (line 17) | constructor(service: Service) {
method handleRequest (line 21) | async handleRequest(_request: ControllerDefinitionsRequestType): Promi...
method controllerSort (line 37) | private controllerSort(a: ControllerDefinitionRequestType, b: Controll...
method positionFromNode (line 41) | private positionFromNode(node: ClassDeclarationNode | undefined) {
method registeredControllerPaths (line 77) | private get registeredControllerPaths() {
method unregisteredControllerDefinitions (line 81) | private get unregisteredControllerDefinitions() {
method detectedNodeModules (line 87) | private get detectedNodeModules() {
method registeredControllers (line 91) | private get registeredControllers() {
method unregisteredControllers (line 95) | private get unregisteredControllers() {
method nodeModuleControllers (line 99) | private get nodeModuleControllers() {
FILE: server/src/service.ts
class Service (line 17) | class Service {
method constructor (line 31) | constructor(connection: Connection, params: InitializeParams) {
method init (line 50) | async init() {
method refresh (line 72) | async refresh() {
method refreshConfig (line 78) | async refreshConfig() {
FILE: server/src/settings.ts
type StimulusSettings (line 3) | interface StimulusSettings {}
class Settings (line 5) | class Settings {
method constructor (line 21) | constructor(params: InitializeParams, connection: Connection) {
method projectPath (line 41) | get projectPath() {
method getDocumentSettings (line 45) | getDocumentSettings(resource: string): Thenable<StimulusSettings> {
FILE: server/src/utils.ts
function rank (line 6) | function rank(input: string, list: string[]) {
function didyoumean (line 16) | function didyoumean(input: string, list: string[]): string | null {
function camelize (line 26) | function camelize(value: string) {
function dasherize (line 30) | function dasherize(value: string) {
function capitalize (line 34) | function capitalize(value: string) {
function importStatementForController (line 38) | function importStatementForController(controllerDefinition: ControllerDe...
function importSourceForController (line 47) | function importSourceForController(controllerDefinition: ControllerDefin...
function nodeModleForController (line 57) | function nodeModleForController(controllerDefinition: ControllerDefiniti...
function localNameForExportDeclaration (line 61) | function localNameForExportDeclaration(exportDeclaration: ExportDeclarat...
function importStatementFromExportDeclaration (line 67) | function importStatementFromExportDeclaration(
function relativeControllersFilePath (line 86) | function relativeControllersFilePath(project: Project, filePath: string)...
function exportDeclarationFromControllerDefinition (line 108) | function exportDeclarationFromControllerDefinition(controllerDefinition:...
Condensed preview — 50 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (128K chars).
[
{
"path": ".eslintignore",
"chars": 100,
"preview": "node_modules/**\nclient/node_modules/**\nclient/out/**\nserver/node_modules/**\nserver/out/**\n**/*.d.ts\n"
},
{
"path": ".eslintrc",
"chars": 497,
"preview": "{\n \"root\": true,\n \"parser\": \"@typescript-eslint/parser\",\n \"plugins\": [\n \"@typescript-eslint\",\n \"prettier\"\n ],\n"
},
{
"path": ".github/dependabot.yml",
"chars": 909,
"preview": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where "
},
{
"path": ".github/workflows/build.yml",
"chars": 508,
"preview": "name: Build\n\npermissions:\n contents: read\n\non: [push, pull_request]\n\njobs:\n tests:\n name: JavaScript Test Action\n "
},
{
"path": ".github/workflows/publish.yml",
"chars": 577,
"preview": "name: Publish\n\npermissions:\n contents: read\n\non:\n release:\n types:\n - created\n\njobs:\n publish:\n name: Publ"
},
{
"path": ".gitignore",
"chars": 95,
"preview": "client/out/\nserver/out/\nnode_modules/\n\n.vscode-test/\n\n*.tsbuildinfo\n*.vsix\n*.tgz\n*~\n\n.DS_Store\n"
},
{
"path": ".node-version",
"chars": 8,
"preview": "20.14.0\n"
},
{
"path": ".prettierrc.json",
"chars": 70,
"preview": " {\n \"singleQuote\": false,\n \"printWidth\": 120,\n \"semi\": false\n }\n"
},
{
"path": ".vscode/extensions.json",
"chars": 319,
"preview": "{\n // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.\n // Extension ident"
},
{
"path": ".vscode/launch.json",
"chars": 1232,
"preview": "// A launch configuration that compiles the extension and then opens it inside a new window\n{\n \"version\": \"0.2.0\",\n \"c"
},
{
"path": ".vscode/settings.json",
"chars": 223,
"preview": "{\n \"editor.insertSpaces\": false,\n \"tslint.enable\": true,\n \"typescript.tsc.autoDetect\": \"off\",\n \"typescript.preferenc"
},
{
"path": ".vscode/tasks.json",
"chars": 573,
"preview": "{\n \"version\": \"2.0.0\",\n \"tasks\": [\n {\n \"type\": \"npm\",\n \"script\": \"compile\",\n \"group\": \"build\",\n "
},
{
"path": ".vscodeignore",
"chars": 549,
"preview": ".gitignore\n.eslintrc\n.eslintignore\n.prettierrc.json\n.node-version\n\n**/*.ts\n**/*.map\n**/tsconfig.json\n**/tsconfig.base.js"
},
{
"path": "LICENSE.txt",
"chars": 1067,
"preview": "MIT License\n\nCopyright (c) 2021 Marco Roth\n\nPermission is hereby granted, free of charge, to any person obtaining a copy"
},
{
"path": "README.md",
"chars": 3594,
"preview": "# Stimulus LSP\n\nIntelligent Stimulus tooling for Visual Studio Code and other editors which support the Language Server "
},
{
"path": "client/package.json",
"chars": 554,
"preview": "{\n \"name\": \"vscode-stimulus\",\n \"description\": \"Intelligent Stimulus tooling for Visual Studio Code\",\n \"author\": \"Marc"
},
{
"path": "client/src/client.ts",
"chars": 2963,
"preview": "import * as path from \"path\"\n\nimport { workspace, ExtensionContext } from \"vscode\"\nimport { LanguageClient, LanguageClie"
},
{
"path": "client/src/controller_tree_view.ts",
"chars": 5133,
"preview": "import {\n TreeView,\n TreeItem,\n TreeItemCollapsibleState,\n TreeDataProvider,\n Disposable,\n ThemeIcon,\n EventEmitt"
},
{
"path": "client/src/extension.ts",
"chars": 345,
"preview": "import { ExtensionContext } from \"vscode\"\nimport { Client } from \"./client\"\n\nlet client: Client\n\nexport async function a"
},
{
"path": "client/src/requests.ts",
"chars": 687,
"preview": "import { Position } from \"vscode-languageclient\"\n\nexport type ControllerDefinition = {\n identifier: string\n path: stri"
},
{
"path": "client/tsconfig.json",
"chars": 280,
"preview": "{\n \"compilerOptions\": {\n \"module\": \"commonjs\",\n \"target\": \"es2019\",\n \"lib\": [\"ES2019\"],\n \"outDir\": \"out\",\n "
},
{
"path": "package.json",
"chars": 3879,
"preview": "{\n \"name\": \"stimulus-lsp\",\n \"displayName\": \"Stimulus LSP\",\n \"description\": \"Intelligent Stimulus tooling\",\n \"license"
},
{
"path": "scripts/e2e.sh",
"chars": 163,
"preview": "#!/usr/bin/env bash\n\nexport CODE_TESTS_PATH=\"$(pwd)/client/out/test\"\nexport CODE_TESTS_WORKSPACE=\"$(pwd)/client/testFixt"
},
{
"path": "server/.npmignore",
"chars": 216,
"preview": ".babelrc\n.babelrc.js\n.DS_Store\n.gitignore\n.yarn.lock\n\n*.log\n*.tsbuildinfo\n*.tgz\n\nREADME.md\nrollup.config.js\ntsconfig.jso"
},
{
"path": "server/README.md",
"chars": 610,
"preview": "# Stimulus Language Server\n\n[Language Server Protocol](https://github.com/Microsoft/language-server-protocol) implementa"
},
{
"path": "server/package.json",
"chars": 1310,
"preview": "{\n \"name\": \"stimulus-language-server\",\n \"description\": \"Intelligent Stimulus tooling\",\n \"version\": \"1.1.0\",\n \"author"
},
{
"path": "server/scripts/executable.mjs",
"chars": 673,
"preview": "import { readFileSync, writeFileSync } from 'fs'\nimport { dirname, resolve } from 'path'\nimport { fileURLToPath } from '"
},
{
"path": "server/src/action_descriptor.ts",
"chars": 1378,
"preview": "// https://github.com/hotwired/stimulus/blob/8cbca6db3b1b2ddb384deb3dd98397d3609d25a0/src/core/action_descriptor.ts\n\nexp"
},
{
"path": "server/src/code_actions.ts",
"chars": 7697,
"preview": "import { CodeAction, CodeActionKind, CodeActionParams, Command, Diagnostic } from \"vscode-languageserver/node\"\n\nimport {"
},
{
"path": "server/src/code_lens.ts",
"chars": 2536,
"preview": "import { CodeLens, CodeLensParams, Range, Command } from \"vscode-languageserver/node\"\n\nimport { DocumentService } from \""
},
{
"path": "server/src/commands.ts",
"chars": 7708,
"preview": "import dedent from \"dedent\"\n\nimport { Connection, TextDocumentEdit, TextEdit, CreateFile, Range, Diagnostic } from \"vsco"
},
{
"path": "server/src/config.ts",
"chars": 3146,
"preview": "export type StimulusConfigOptions = {\n ignoredControllerIdentifiers: Array<string>\n ignoredAttributes: Array<string>\n}"
},
{
"path": "server/src/data_providers/stimulus_html_data_provider.ts",
"chars": 6481,
"preview": "import { IHTMLDataProvider } from \"@herb-tools/language-service\"\n\nimport { EVENTS } from \"../events\"\n\nimport { Project }"
},
{
"path": "server/src/definitions.ts",
"chars": 9827,
"preview": "import { Herb } from \"@herb-tools/node-wasm\"\nimport { Range, DefinitionParams, LocationLink } from \"vscode-languageserve"
},
{
"path": "server/src/diagnostics.ts",
"chars": 25247,
"preview": "import dedent from \"dedent\"\nimport { Connection, Diagnostic, DiagnosticSeverity, Position, Range } from \"vscode-language"
},
{
"path": "server/src/document_service.ts",
"chars": 814,
"preview": "import { Connection, TextDocuments } from \"vscode-languageserver/node\"\nimport { TextDocument } from \"vscode-languageserv"
},
{
"path": "server/src/events.ts",
"chars": 984,
"preview": "export const EVENTS = [\n \"DOMContentLoaded\",\n \"abort\",\n \"animationcancel\",\n \"animationend\",\n \"animationiteration\",\n"
},
{
"path": "server/src/html_util.ts",
"chars": 1120,
"preview": "import { Node } from \"@herb-tools/language-service\"\n\nexport function attributeValue(node: Node, attribute: string) {\n i"
},
{
"path": "server/src/levenshtein.ts",
"chars": 3020,
"preview": "/*\n * The following code is derived from the \"js-levenshtein\" repository,\n * Copyright (c) 2017 Gustaf Andersson (https:"
},
{
"path": "server/src/requests/controller_definitions.ts",
"chars": 3761,
"preview": "import { Position } from \"vscode-languageserver/node\"\nimport { RegisteredController, ControllerDefinition, ClassDeclarat"
},
{
"path": "server/src/requests.ts",
"chars": 687,
"preview": "import { Position } from \"vscode-languageserver\"\n\nexport type ControllerDefinition = {\n identifier: string\n path: stri"
},
{
"path": "server/src/server.ts",
"chars": 6983,
"preview": "import {\n createConnection,\n ProposedFeatures,\n InitializeParams,\n DidChangeConfigurationNotification,\n DidChangeWa"
},
{
"path": "server/src/service.ts",
"chars": 3033,
"preview": "import { Connection, InitializeParams } from \"vscode-languageserver/node\"\nimport { getLanguageService, LanguageService }"
},
{
"path": "server/src/settings.ts",
"chars": 2115,
"preview": "import { ClientCapabilities, Connection, InitializeParams } from \"vscode-languageserver/node\"\n\nexport interface Stimulus"
},
{
"path": "server/src/utils.ts",
"chars": 4171,
"preview": "import path from \"path\"\nimport { levenshtein } from \"./levenshtein\"\n\nimport type { Project, ExportDeclaration, Controlle"
},
{
"path": "server/tsconfig.json",
"chars": 472,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"es2019\",\n \"lib\": [\"ES2019\", \"dom\"],\n \"module\": \"commonjs\",\n \"moduleReso"
},
{
"path": "server/types/typescript-eslint__typescript-estree/index.d.ts",
"chars": 124,
"preview": "declare module \"@typescript-eslint/typescript-estree\" {\n export * from \"@typescript-eslint/typescript-estree/dist/index"
},
{
"path": "server/types/typescript-eslint__typescript-types/index.d.ts",
"chars": 100,
"preview": "declare module \"@typescript-eslint/types\" {\n export * from \"@typescript-eslint/types/dist/index\"\n}\n"
},
{
"path": "server/types/typescript-eslint__visitor-keys/index.d.ts",
"chars": 114,
"preview": "declare module \"@typescript-eslint/visitor-keys\" {\n export * from \"@typescript-eslint/visitor-keys/dist/index\"\n}\n"
},
{
"path": "tsconfig.json",
"chars": 456,
"preview": "{\n \"compilerOptions\": {\n \"module\": \"commonjs\",\n \"moduleResolution\": \"node\",\n \"esModuleInterop\": true,\n \"tar"
}
]
About this extraction
This page contains the full source code of the marcoroth/stimulus-lsp GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 50 files (116.3 KB), approximately 28.0k tokens, and a symbol index with 198 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.