@herb-tools/language-server 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@herb-tools/language-server",
3
- "description": "Powerful and seamless HTML-aware ERB parsing and tooling.",
4
- "version": "0.3.0",
3
+ "description": "Herb HTML+ERB Language Tools and Language Server Protocol integration.",
4
+ "version": "0.3.1",
5
5
  "author": "Marco Roth",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -13,24 +13,39 @@
13
13
  "url": "https://github.com/marcoroth/herb.git",
14
14
  "directory": "javascript/packages/language-server"
15
15
  },
16
+ "main": "./dist/herb-language-server.cjs",
17
+ "module": "./dist/server.js",
18
+ "types": "./dist/types/server.d.ts",
19
+ "exports": {
20
+ "./package.json": "./package.json",
21
+ ".": {
22
+ "types": "./dist/types/server.d.ts",
23
+ "import": "./dist/server.js",
24
+ "require": "./dist/herb-language-server.cjs",
25
+ "default": "./dist/server.js"
26
+ }
27
+ },
16
28
  "homepage": "https://herb-tools.dev",
17
29
  "bin": {
18
- "herb-language-server": "./dist/herb-language-server"
30
+ "herb-language-server": "./bin/herb-language-server"
19
31
  },
20
32
  "scripts": {
21
33
  "clean": "rimraf dist",
22
34
  "prebuild": "yarn run clean",
23
35
  "build": "tsc -b && rollup -c rollup.config.mjs",
24
- "postbuild": "node scripts/executable.mjs",
25
36
  "watch": "tsc -b -w",
26
- "test": "echo 'TODO'",
37
+ "test": "echo 'TODO: add tests'",
27
38
  "prepublishOnly": "yarn clean && yarn build && yarn test"
28
39
  },
29
40
  "files": [
30
- "dist"
41
+ "package.json",
42
+ "README.md",
43
+ "src/",
44
+ "bin/",
45
+ "dist/"
31
46
  ],
32
47
  "dependencies": {
33
- "@herb-tools/node-wasm": "0.3.0",
48
+ "@herb-tools/node-wasm": "0.3.1",
34
49
  "dedent": "^1.6.0",
35
50
  "typescript": "^5.8.3",
36
51
  "vscode-languageserver": "^9.0.1",
package/src/config.ts ADDED
@@ -0,0 +1,101 @@
1
+ export type HerbConfigOptions = {}
2
+
3
+ export type HerbLSPConfig = {
4
+ version: string
5
+ createdAt: string
6
+ updatedAt: string
7
+ options: HerbConfigOptions
8
+ }
9
+
10
+ import path from "path"
11
+ import { version } from "../package.json"
12
+ import { promises as fs } from "fs"
13
+
14
+ export class Config {
15
+ static configPath = ".herb-lsp/config.json"
16
+
17
+ public readonly path: string
18
+ public config: HerbLSPConfig
19
+
20
+ constructor(projectPath: string, config: HerbLSPConfig) {
21
+ this.path = Config.configPathFromProjectPath(projectPath)
22
+ this.config = config
23
+ }
24
+
25
+ get version(): string {
26
+ return this.config.version
27
+ }
28
+
29
+ get createdAt(): Date {
30
+ return new Date(this.config.createdAt)
31
+ }
32
+
33
+ get updatedAt(): Date {
34
+ return new Date(this.config.updatedAt)
35
+ }
36
+
37
+ get options(): HerbConfigOptions {
38
+ return this.config.options
39
+ }
40
+
41
+ public toJSON() {
42
+ return JSON.stringify(this.config, null, " ")
43
+ }
44
+
45
+ private updateTimestamp() {
46
+ this.config.updatedAt = new Date().toISOString()
47
+ }
48
+
49
+ private updateVersion() {
50
+ this.config.version = version
51
+ }
52
+
53
+ async write() {
54
+ this.updateVersion()
55
+ this.updateTimestamp()
56
+
57
+ const folder = path.dirname(this.path)
58
+
59
+ fs.stat(folder)
60
+ .then(() => {})
61
+ .catch(async () => await fs.mkdir(folder))
62
+ .finally(async () => await fs.writeFile(this.path, this.toJSON()))
63
+ }
64
+
65
+ async read() {
66
+ return await fs.readFile(this.path, "utf8")
67
+ }
68
+
69
+ static configPathFromProjectPath(projectPath: string) {
70
+ return path.join(projectPath, this.configPath)
71
+ }
72
+
73
+ static async fromPathOrNew(projectPath: string) {
74
+ try {
75
+ return await this.fromPath(projectPath)
76
+ } catch (error: any) {
77
+ return Config.newConfig(projectPath)
78
+ }
79
+ }
80
+
81
+ static async fromPath(projectPath: string) {
82
+ const configPath = Config.configPathFromProjectPath(projectPath)
83
+
84
+ try {
85
+ const config = JSON.parse(await fs.readFile(configPath, "utf8"))
86
+
87
+ return new Config(projectPath, config)
88
+ } catch (error: any) {
89
+ throw new Error(`Error reading config file at: ${configPath}. Error: ${error.message}`)
90
+ }
91
+ }
92
+
93
+ static newConfig(projectPath: string): Config {
94
+ return new Config(projectPath, {
95
+ version,
96
+ createdAt: new Date().toISOString(),
97
+ updatedAt: new Date().toISOString(),
98
+ options: {}
99
+ })
100
+ }
101
+ }
@@ -0,0 +1,116 @@
1
+ import { Connection, Diagnostic, DiagnosticSeverity, Range, Position } from "vscode-languageserver/node"
2
+ import { TextDocument } from "vscode-languageserver-textdocument"
3
+ import { Herb, Visitor } from "@herb-tools/node-wasm"
4
+
5
+ import { DocumentService } from "./document_service"
6
+
7
+ import type { Node, HerbError } from "@herb-tools/node-wasm"
8
+
9
+ class ErrorVisitor extends Visitor {
10
+ private diagnostics: Diagnostics
11
+ private textDocument: TextDocument
12
+
13
+ constructor(diagnostics: Diagnostics, textDocument: TextDocument) {
14
+ super()
15
+ this.diagnostics = diagnostics
16
+ this.textDocument = textDocument
17
+ }
18
+
19
+ visitChildNodes(node: Node) {
20
+ super.visitChildNodes(node)
21
+
22
+ node.errors.forEach(error => this.publishDiagnosticForError(error, node))
23
+ }
24
+
25
+ private publishDiagnosticForError(error: HerbError, node: Node): void {
26
+ this.diagnostics.pushDiagnostic(
27
+ error.message,
28
+ error.type,
29
+ this.rangeFromHerbError(error),
30
+ this.textDocument,
31
+ {
32
+ error: error.toJSON(),
33
+ node: node.toJSON()
34
+ },
35
+ DiagnosticSeverity.Error
36
+ )
37
+ }
38
+
39
+ private rangeFromHerbError(error: HerbError): Range {
40
+ return Range.create(
41
+ Position.create(error.location.start.line - 1, error.location.start.column),
42
+ Position.create(error.location.end.line - 1, error.location.end.column),
43
+ )
44
+ }
45
+ }
46
+
47
+ export class Diagnostics {
48
+ private readonly connection: Connection
49
+ private readonly documentService: DocumentService
50
+ private readonly diagnosticsSource = "Herb LSP "
51
+ private diagnostics: Map<TextDocument, Diagnostic[]> = new Map()
52
+
53
+ constructor(
54
+ connection: Connection,
55
+ documentService: DocumentService,
56
+ ) {
57
+ this.connection = connection
58
+ this.documentService = documentService
59
+ }
60
+
61
+ validate(textDocument: TextDocument) {
62
+ const content = textDocument.getText()
63
+ const result = Herb.parse(content)
64
+ const visitor = new ErrorVisitor(this, textDocument)
65
+
66
+ result.visit(visitor)
67
+
68
+ this.sendDiagnosticsFor(textDocument)
69
+ }
70
+
71
+ refreshDocument(document: TextDocument) {
72
+ this.validate(document)
73
+ }
74
+
75
+ refreshAllDocuments() {
76
+ this.documentService.getAll().forEach((document) => {
77
+ this.refreshDocument(document)
78
+ })
79
+ }
80
+
81
+ pushDiagnostic(
82
+ message: string,
83
+ code: string,
84
+ range: Range,
85
+ textDocument: TextDocument,
86
+ data = {},
87
+ severity: DiagnosticSeverity = DiagnosticSeverity.Error,
88
+ ) {
89
+ const diagnostic: Diagnostic = {
90
+ source: this.diagnosticsSource,
91
+ severity,
92
+ range,
93
+ message,
94
+ code,
95
+ data,
96
+ }
97
+
98
+ const diagnostics = this.diagnostics.get(textDocument) || []
99
+ diagnostics.push(diagnostic)
100
+
101
+ this.diagnostics.set(textDocument, diagnostics)
102
+
103
+ return diagnostic
104
+ }
105
+
106
+ private sendDiagnosticsFor(textDocument: TextDocument) {
107
+ const diagnostics = this.diagnostics.get(textDocument) || []
108
+
109
+ this.connection.sendDiagnostics({
110
+ uri: textDocument.uri,
111
+ diagnostics,
112
+ })
113
+
114
+ this.diagnostics.delete(textDocument)
115
+ }
116
+ }
@@ -0,0 +1,35 @@
1
+ import { Connection, TextDocuments } from "vscode-languageserver/node"
2
+ import { TextDocument } from "vscode-languageserver-textdocument"
3
+
4
+ export class DocumentService {
5
+ public documents: TextDocuments<TextDocument>
6
+ document?: TextDocument
7
+
8
+ constructor(connection: Connection) {
9
+ this.documents = new TextDocuments(TextDocument)
10
+
11
+ // Make the text document manager listen on the connection
12
+ // for open, change and close text document events
13
+ this.documents.listen(connection)
14
+ }
15
+
16
+ get(uri: string) {
17
+ return this.documents.get(uri)
18
+ }
19
+
20
+ getAll() {
21
+ return this.documents.all()
22
+ }
23
+
24
+ get onDidChangeContent() {
25
+ return this.documents.onDidChangeContent
26
+ }
27
+
28
+ get onDidOpen() {
29
+ return this.documents.onDidOpen
30
+ }
31
+
32
+ get onDidClose() {
33
+ return this.documents.onDidClose
34
+ }
35
+ }
package/src/project.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { Herb } from "@herb-tools/node-wasm"
2
+ import { Connection } from "vscode-languageserver/node"
3
+
4
+ export class Project {
5
+ connection: Connection
6
+ projectPath: string
7
+
8
+ constructor(connection: Connection, projectPath: string) {
9
+ this.projectPath = projectPath
10
+ this.connection = connection
11
+ }
12
+
13
+ async initialize() {
14
+ await Herb.load()
15
+ }
16
+
17
+ async refresh() {
18
+ // TODO
19
+ }
20
+ }
package/src/server.ts ADDED
@@ -0,0 +1,93 @@
1
+ import {
2
+ createConnection,
3
+ ProposedFeatures,
4
+ InitializeParams,
5
+ DidChangeConfigurationNotification,
6
+ DidChangeWatchedFilesNotification,
7
+ TextDocumentSyncKind,
8
+ InitializeResult,
9
+ } from "vscode-languageserver/node"
10
+
11
+ import { Service } from "./service"
12
+ import { HerbSettings } from "./settings"
13
+
14
+ let service: Service
15
+ const connection = createConnection(ProposedFeatures.all)
16
+
17
+ connection.onInitialize(async (params: InitializeParams) => {
18
+ service = new Service(connection, params)
19
+ await service.init()
20
+
21
+ const result: InitializeResult = {
22
+ capabilities: {
23
+ textDocumentSync: TextDocumentSyncKind.Incremental,
24
+ },
25
+ }
26
+
27
+ if (service.settings.hasWorkspaceFolderCapability) {
28
+ result.capabilities.workspace = {
29
+ workspaceFolders: {
30
+ supported: true,
31
+ },
32
+ }
33
+ }
34
+
35
+ return result
36
+ })
37
+
38
+ connection.onInitialized(() => {
39
+ if (service.settings.hasConfigurationCapability) {
40
+ // Register for all configuration changes.
41
+ connection.client.register(DidChangeConfigurationNotification.type, undefined)
42
+ }
43
+
44
+ if (service.settings.hasWorkspaceFolderCapability) {
45
+ connection.workspace.onDidChangeWorkspaceFolders((_event) => {
46
+ connection.console.log("Workspace folder change event received.")
47
+ })
48
+ }
49
+
50
+ connection.client.register(DidChangeWatchedFilesNotification.type, {
51
+ watchers: [
52
+ { globPattern: `**/**/*.html.erb` },
53
+ { globPattern: `**/**/.herb-lsp/config.json` },
54
+ ],
55
+ })
56
+ })
57
+
58
+ connection.onDidChangeConfiguration((change) => {
59
+ if (service.settings.hasConfigurationCapability) {
60
+ // Reset all cached document settings
61
+ service.settings.documentSettings.clear()
62
+ } else {
63
+ service.settings.globalSettings = (
64
+ (change.settings.languageServerHerb || service.settings.defaultSettings)
65
+ ) as HerbSettings
66
+ }
67
+
68
+ service.refresh()
69
+ })
70
+
71
+ connection.onDidOpenTextDocument((params) => {
72
+ console.error(params)
73
+ const document = service.documentService.get(params.textDocument.uri)
74
+
75
+ if (document) {
76
+ service.diagnostics.refreshDocument(document)
77
+ }
78
+ })
79
+
80
+ connection.onDidChangeWatchedFiles((params) => {
81
+ params.changes.forEach(async (event) => {
82
+ if (event.uri.endsWith("/.herb-lsp/config.json")) {
83
+ await service.refreshConfig()
84
+
85
+ service.documentService.getAll().forEach((document) => {
86
+ service.diagnostics.refreshDocument(document)
87
+ })
88
+ }
89
+ })
90
+ })
91
+
92
+ // Listen on the connection
93
+ connection.listen()
package/src/service.ts ADDED
@@ -0,0 +1,51 @@
1
+ import { Connection, InitializeParams } from "vscode-languageserver/node"
2
+
3
+ import { Settings } from "./settings"
4
+ import { DocumentService } from "./document_service"
5
+ import { Diagnostics } from "./diagnostics"
6
+ import { Config } from "./config"
7
+ import { Project } from "./project"
8
+
9
+ export class Service {
10
+ connection: Connection
11
+ settings: Settings
12
+ diagnostics: Diagnostics
13
+ documentService: DocumentService
14
+ project: Project
15
+ config?: Config
16
+
17
+ constructor(connection: Connection, params: InitializeParams) {
18
+ this.connection = connection
19
+ this.settings = new Settings(params, this.connection)
20
+ this.documentService = new DocumentService(this.connection)
21
+ this.project = new Project(connection, this.settings.projectPath.replace("file://", ""))
22
+ this.diagnostics = new Diagnostics(this.connection, this.documentService)
23
+ }
24
+
25
+ async init() {
26
+ await this.project.initialize()
27
+
28
+ this.config = await Config.fromPathOrNew(this.project.projectPath)
29
+
30
+ // Only keep settings for open documents
31
+ this.documentService.onDidClose((change) => {
32
+ this.settings.documentSettings.delete(change.document.uri)
33
+ })
34
+
35
+ // The content of a text document has changed. This event is emitted
36
+ // when the text document first opened or when its content has changed.
37
+ this.documentService.onDidChangeContent((change) => {
38
+ this.diagnostics.refreshDocument(change.document)
39
+ })
40
+ }
41
+
42
+ async refresh() {
43
+ await this.project.refresh()
44
+
45
+ this.diagnostics.refreshAllDocuments()
46
+ }
47
+
48
+ async refreshConfig() {
49
+ this.config = await Config.fromPathOrNew(this.project.projectPath)
50
+ }
51
+ }
@@ -0,0 +1,63 @@
1
+ import { ClientCapabilities, Connection, InitializeParams } from "vscode-languageserver/node"
2
+
3
+ export interface HerbSettings {}
4
+
5
+ export class Settings {
6
+ // The global settings, used when the `workspace/configuration` request is not supported by the client.
7
+ // Please note that this is not the case when using this server with the client provided in this example
8
+ // but could happen with other clients.
9
+ defaultSettings: HerbSettings = {}
10
+ globalSettings: HerbSettings = this.defaultSettings
11
+ documentSettings: Map<string, Thenable<HerbSettings>> = new Map()
12
+
13
+ hasConfigurationCapability = false
14
+ hasWorkspaceFolderCapability = false
15
+ hasDiagnosticRelatedInformationCapability = false
16
+
17
+ params: InitializeParams
18
+ capabilities: ClientCapabilities
19
+ connection: Connection
20
+
21
+ constructor(params: InitializeParams, connection: Connection) {
22
+ this.params = params
23
+ this.capabilities = params.capabilities
24
+ this.connection = connection
25
+
26
+ // Does the client support the `workspace/configuration` request?
27
+ // If not, we fall back using global settings.
28
+ this.hasConfigurationCapability = !!(this.capabilities.workspace && !!this.capabilities.workspace.configuration)
29
+
30
+ this.hasWorkspaceFolderCapability = !!(
31
+ this.capabilities.workspace && !!this.capabilities.workspace.workspaceFolders
32
+ )
33
+
34
+ this.hasDiagnosticRelatedInformationCapability = !!(
35
+ this.capabilities.textDocument &&
36
+ this.capabilities.textDocument.publishDiagnostics &&
37
+ this.capabilities.textDocument.publishDiagnostics.relatedInformation
38
+ )
39
+ }
40
+
41
+ get projectPath(): string {
42
+ return this.params.workspaceFolders?.at(0)?.uri || ""
43
+ }
44
+
45
+ getDocumentSettings(resource: string): Thenable<HerbSettings> {
46
+ if (!this.hasConfigurationCapability) {
47
+ return Promise.resolve(this.globalSettings)
48
+ }
49
+
50
+ let result = this.documentSettings.get(resource)
51
+
52
+ if (!result) {
53
+ result = this.connection.workspace.getConfiguration({
54
+ scopeUri: resource,
55
+ section: "languageServerHerb",
56
+ })
57
+
58
+ this.documentSettings.set(resource, result)
59
+ }
60
+
61
+ return result
62
+ }
63
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,11 @@
1
+ export function camelize(value: string) {
2
+ return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase())
3
+ }
4
+
5
+ export function dasherize(value: string) {
6
+ return value.replace(/([A-Z])/g, (_, char) => `-${char.toLowerCase()}`)
7
+ }
8
+
9
+ export function capitalize(value: string) {
10
+ return value.charAt(0).toUpperCase() + value.slice(1)
11
+ }