Skip to content

Commit bd698f4

Browse files
authored
Language Service: Implement @herb-tools/language-service package (#1446)
This pull request introduces a new package, `@herb-tools/language-service`, which provides an HTML+ERB language service with a compatible API to [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice). It uses the Herb parser to understand both regular HTML elements and ActionView tag helpers, and presents them through the same `LanguageService` interface that `vscode-html-languageservice` consumers expect — making it a drop-in replacement. ### What it does The core idea is that `parseHTMLDocument` uses the Herb parser instead of a plain HTML scanner. This means a template like `<%= tag.div data: { controller: "scroll" } %>` is understood as a `<div>` with a `data-controller="scroll"` attribute completions, diagnostics, and navigation all work as expected, just like they would for regular HTML. For completions inside ERB tag helpers, the language service detects the Ruby hash context and adapts accordingly. Inside a `data: {}` hash it suggests `action` instead of `data-action`, and for space-separated attributes like `data-controller="scroll search"` it provides per-token completions rather than replacing the entire value. Each node in the parsed document tracks where its attributes actually appear in the source text via `attributeSourceRanges`. This is important because ActionView tag helpers synthesize attribute names (e.g., `controller:` in Ruby becomes `data-controller` in HTML), so the language service needs to know where to point diagnostics and highlights in the original ERB source. When no Herb instance is provided the service falls back to the upstream `vscode-html-languageservice` parser, so it works as a regular HTML language service too. All types and functions from `vscode-html-languageservice` are re-exported so consumers only need a single import. ### Stimulus LSP This package was built with [Stimulus LSP](https://github.com/marcoroth/stimulus-lsp) as the first consumer in mind. Stimulus LSP currently uses `vscode-html-languageservice` directly, which means it has no understanding of ERB. Controller completions, diagnostics, and go-to-definition only work inside regular HTML `data-controller` attributes. By switching to `@herb-tools/language-service`, Stimulus LSP gains full support for ActionView tag helpers like `<%= tag.div data: { controller: "scroll", action: "click->scroll#go" } %>` with the same completions, diagnostics, and navigation that already work for plain HTML. ### Usage ```diff - import { getLanguageService } from 'vscode-html-languageservice' + import { Herb } from '@herb-tools/node-wasm' + import { getLanguageService } from '@herb-tools/language-service' + await Herb.load() const service = getLanguageService({ + herb: Herb, customDataProviders: [myDataProvider], }) ```
1 parent 875047f commit bd698f4

19 files changed

+1874
-12
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ The Herb ecosystem offers multiple tools that integrate seamlessly into editors,
5656
| [Herb Parser](https://herb-tools.dev/projects/parser) | Fast, portable, HTML-aware ERB parser written in C. |
5757
| [Herb Linter](https://herb-tools.dev/projects/linter) | Static analysis to enforce best practices and identify common mistakes. |
5858
| [Herb Formatter](https://herb-tools.dev/projects/formatter) | Automatic, consistent formatting for HTML+ERB files. *(experimental)* |
59+
| [Herb Language Service](https://herb-tools.dev/projects/language-service) | HTML+ERB language service with ActionView tag helper support. |
5960
| [Herb Language Server](https://herb-tools.dev/projects/language-server) | Rich editor integration for VS Code, Zed, Neovim, and more. |
6061
| [Herb Engine](https://herb-tools.dev/projects/engine) | HTML-aware ERB rendering engine, API-compatible with Erubi. |
6162
| [Herb Dev Tools](https://herb-tools.dev/projects/dev-tools) | In-browser dev tools for inspecting and debugging templates, shipped with ReActionView. |

javascript/packages/language-service/README.md

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
1-
# Herb Language Service <Badge type="info" text="coming soon" />
1+
# Herb Language Service
22

33
**Package:** [`@herb-tools/language-service`](https://www.npmjs.com/package/@herb-tools/language-service)
44

55
---
66

7-
HTML+ERB language service built on the Herb parser, providing a compatible API with [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice) but with HTML+ERB template understanding.
7+
HTML+ERB language service built on the Herb parser, providing a compatible API with [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice) but with full HTML+ERB template understanding, including ActionView tag helpers.
8+
9+
::: tip
10+
This package is intended for tooling developers building language servers, editor extensions, or other developer tools on top of Herb. If you're looking to use Herb in your editor, see the [Herb Language Server](/integrations/editors) instead.
11+
:::
812

913
## Installation
1014

@@ -28,19 +32,126 @@ bun add @herb-tools/language-service
2832

2933
## Features
3034

31-
- **Native HTML+ERB Support**: Built specifically for HTML+ERB templates with deep understanding of Rails ActionView helpers.
32-
- **Compatible API**: Drop-in replacement for [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice) with the same interface.
33-
- **Custom Data Providers**: Supports extensible HTML data providers for framework-specific attributes.
35+
- Drop-in replacement for [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice) with the same API
36+
- Parses HTML+ERB using the Herb parser instead of a plain HTML scanner
37+
- ActionView tag helpers like `<%= tag.div data: { controller: "scroll" } %>` are treated as `<div data-controller="scroll">` for completions and diagnostics
38+
- Extensible via `IHTMLDataProvider` for framework-specific attributes and values
39+
- Token list support for space-separated attributes (`class`, `data-controller`, etc.)
40+
- Falls back to the upstream HTML parser when Herb is not available
41+
42+
## Migrating from `vscode-html-languageservice`
43+
44+
```diff
45+
- import { getLanguageService } from "vscode-html-languageservice"
46+
+ import { Herb } from "@herb-tools/node-wasm"
47+
+ import { getLanguageService } from "@herb-tools/language-service"
48+
49+
+ await Herb.load()
50+
51+
const service = getLanguageService({
52+
+ herb: Herb,
53+
customDataProviders: [myDataProvider],
54+
})
55+
```
56+
57+
All types and functions from `vscode-html-languageservice` are re-exported, so no other import changes are needed.
3458

3559
## Usage
3660

37-
Replace your import to get enhanced HTML+ERB support with no code changes:
61+
Pass a Herb instance to get HTML+ERB support:
3862

39-
```diff
40-
- import { getLanguageService } from 'vscode-html-languageservice'
41-
+ import { getLanguageService } from '@herb-tools/language-service'
63+
```typescript
64+
import { Herb } from "@herb-tools/node-wasm"
65+
import { getLanguageService } from "@herb-tools/language-service"
66+
67+
await Herb.load()
68+
69+
const service = getLanguageService({
70+
herb: Herb,
71+
customDataProviders: [myDataProvider],
72+
})
73+
74+
const document = service.parseHTMLDocument(textDocument)
75+
const completions = service.doComplete(textDocument, position, document)
76+
```
77+
78+
### Without Herb (HTML-only)
79+
80+
When no Herb instance is provided, the service falls back to the upstream `vscode-html-languageservice` parser:
81+
82+
```typescript
83+
import { getLanguageService } from "@herb-tools/language-service"
84+
85+
const service = getLanguageService({
86+
customDataProviders: [myDataProvider],
87+
})
4288
```
4389

90+
### Custom Data Providers
91+
92+
Custom data providers let you add framework-specific tags, attributes, and values to the completion and hover engines. The interface is the same [`IHTMLDataProvider`](https://github.com/microsoft/vscode-html-languageservice/blob/main/src/htmlLanguageTypes.ts) from `vscode-html-languageservice`:
93+
94+
```typescript
95+
import { getLanguageService, type IHTMLDataProvider } from "@herb-tools/language-service"
96+
97+
const stimulusProvider: IHTMLDataProvider = {
98+
getId: () => "stimulus",
99+
isApplicable: () => true,
100+
101+
provideTags: () => [],
102+
103+
provideAttributes: (tag) => [
104+
{ name: "data-controller" },
105+
{ name: "data-action" },
106+
],
107+
108+
provideValues: (tag, attribute) => {
109+
if (attribute === "data-controller") {
110+
return [{ name: "scroll" }, { name: "search" }]
111+
}
112+
113+
return []
114+
},
115+
}
116+
117+
const service = getLanguageService({
118+
herb: Herb,
119+
customDataProviders: [stimulusProvider],
120+
tokenListAttributes: ["data-controller", "data-action"],
121+
})
122+
```
123+
124+
Multiple providers can be composed. The language service queries all applicable providers and merges their results.
125+
126+
### Token List Attributes
127+
128+
Some attributes contain space-separated token lists (e.g., `class="foo bar"` or `data-controller="scroll search"`). Pass `tokenListAttributes` so the language service can provide per-token completions and accurate per-token diagnostic ranges:
129+
130+
```typescript
131+
const service = getLanguageService({
132+
herb: Herb,
133+
tokenListAttributes: ["data-controller", "data-action"],
134+
})
135+
```
136+
137+
The defaults from `@herb-tools/core`'s `TOKEN_LIST_ATTRIBUTES` (including `class`) are always included.
138+
44139
## API Compatibility
45140

46-
This package provides the same API as [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice).
141+
This package provides the same `LanguageService` interface as [`vscode-html-languageservice`](https://github.com/microsoft/vscode-html-languageservice):
142+
143+
- `parseHTMLDocument(document)`
144+
- `doComplete(document, position, htmlDocument)`
145+
- `doHover(document, position, htmlDocument)`
146+
- `format(document, range, options)`
147+
- `findDocumentHighlights(document, position, htmlDocument)`
148+
- `findDocumentLinks(document, documentContext)`
149+
- `findDocumentSymbols(document, htmlDocument)`
150+
- `getFoldingRanges(document, context)`
151+
- `getSelectionRanges(document, positions)`
152+
- `doRename(document, position, newName, htmlDocument)`
153+
- `findMatchingTagPosition(document, position, htmlDocument)`
154+
- `findLinkedEditingRanges(document, position, htmlDocument)`
155+
- `createScanner(input, initialOffset)`
156+
- `setDataProviders(useDefault, providers)`
157+
- `setCompletionParticipants(participants)`
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
{
2+
"name": "@herb-tools/language-service",
3+
"version": "0.9.2",
4+
"description": "HTML+ERB language service built on the Herb parser, providing a compatible API with vscode-html-languageservice",
5+
"license": "MIT",
6+
"homepage": "https://herb-tools.dev",
7+
"bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/language-service%60:%20",
8+
"repository": {
9+
"type": "git",
10+
"url": "git+https://github.com/marcoroth/herb.git",
11+
"directory": "javascript/packages/language-service"
12+
},
13+
"main": "./dist/herb-language-service.cjs",
14+
"module": "./dist/herb-language-service.esm.js",
15+
"types": "./dist/types/index.d.ts",
16+
"scripts": {
17+
"build": "yarn clean && rollup -c",
18+
"dev": "rollup -c -w",
19+
"clean": "rimraf dist",
20+
"test": "vitest run",
21+
"prepublishOnly": "yarn clean && yarn build && yarn test"
22+
},
23+
"exports": {
24+
"./package.json": "./package.json",
25+
".": {
26+
"types": "./dist/types/index.d.ts",
27+
"import": "./dist/herb-language-service.esm.js",
28+
"require": "./dist/herb-language-service.cjs",
29+
"default": "./dist/herb-language-service.esm.js"
30+
}
31+
},
32+
"dependencies": {},
33+
"devDependencies": {
34+
"vscode-html-languageservice": "^5.1.0",
35+
"vscode-languageserver-textdocument": "^1.0.0"
36+
},
37+
"peerDependencies": {
38+
"@herb-tools/core": "0.9.2",
39+
"vscode-html-languageservice": "^5.1.0",
40+
"vscode-languageserver-textdocument": "^1.0.0"
41+
},
42+
"files": [
43+
"package.json",
44+
"README.md",
45+
"dist/",
46+
"src/"
47+
]
48+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@herb-tools/language-service",
3+
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
4+
"sourceRoot": "javascript/packages/language-service/src",
5+
"projectType": "library",
6+
"targets": {
7+
"build": {
8+
"executor": "nx:run-script",
9+
"options": {
10+
"script": "build"
11+
},
12+
"dependsOn": ["@herb-tools/core:build"]
13+
},
14+
"test": {
15+
"executor": "nx:run-script",
16+
"options": {
17+
"script": "test"
18+
}
19+
},
20+
"clean": {
21+
"executor": "nx:run-script",
22+
"options": {
23+
"script": "clean"
24+
}
25+
}
26+
},
27+
"tags": []
28+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import typescript from "@rollup/plugin-typescript"
2+
import { nodeResolve } from "@rollup/plugin-node-resolve"
3+
4+
const external = [
5+
"@herb-tools/core",
6+
"@herb-tools/node-wasm",
7+
"@herb-tools/browser",
8+
"vscode-html-languageservice",
9+
"vscode-languageserver-textdocument",
10+
]
11+
12+
export default [
13+
{
14+
input: "src/index.ts",
15+
output: {
16+
file: "dist/herb-language-service.esm.js",
17+
format: "esm",
18+
sourcemap: true,
19+
},
20+
external,
21+
plugins: [
22+
nodeResolve(),
23+
typescript({
24+
tsconfig: "./tsconfig.json",
25+
declaration: true,
26+
declarationDir: "./dist/types",
27+
rootDir: "src/",
28+
}),
29+
],
30+
},
31+
32+
{
33+
input: "src/index.ts",
34+
output: {
35+
file: "dist/herb-language-service.cjs",
36+
format: "cjs",
37+
sourcemap: true,
38+
},
39+
external,
40+
plugins: [
41+
nodeResolve(),
42+
typescript({
43+
tsconfig: "./tsconfig.json",
44+
rootDir: "src/",
45+
}),
46+
],
47+
},
48+
]

0 commit comments

Comments
 (0)