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