@herb-tools/language-server 0.3.0 → 0.4.0

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/README.md +81 -8
  3. package/bin/herb-language-server +3 -0
  4. package/dist/cli.js +27 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/diagnostics.js +17 -50
  8. package/dist/diagnostics.js.map +1 -1
  9. package/dist/formatting_service.js +126 -0
  10. package/dist/formatting_service.js.map +1 -0
  11. package/dist/herb-language-server.js +15260 -6068
  12. package/dist/herb-language-server.js.map +1 -1
  13. package/dist/index.cjs +29736 -0
  14. package/dist/index.cjs.map +1 -0
  15. package/dist/index.js +27 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/linter_service.js +44 -0
  18. package/dist/linter_service.js.map +1 -0
  19. package/dist/parser_service.js +47 -0
  20. package/dist/parser_service.js.map +1 -0
  21. package/dist/project.js +2 -1
  22. package/dist/project.js.map +1 -1
  23. package/dist/server.js +72 -61
  24. package/dist/server.js.map +1 -1
  25. package/dist/service.js +16 -4
  26. package/dist/service.js.map +1 -1
  27. package/dist/settings.js +8 -1
  28. package/dist/settings.js.map +1 -1
  29. package/dist/types/cli.d.ts +4 -0
  30. package/dist/types/config.d.ts +9 -1
  31. package/dist/types/diagnostics.d.ts +9 -7
  32. package/dist/types/formatting_service.d.ts +19 -0
  33. package/dist/types/herb-language-server.d.ts +2 -0
  34. package/dist/types/index.d.ts +10 -0
  35. package/dist/types/linter_service.d.ts +14 -0
  36. package/dist/types/parser_service.d.ts +10 -0
  37. package/dist/types/project.d.ts +2 -0
  38. package/dist/types/server.d.ts +7 -1
  39. package/dist/types/service.d.ts +6 -0
  40. package/dist/types/settings.d.ts +11 -0
  41. package/package.json +28 -19
  42. package/src/cli.ts +23 -0
  43. package/src/config.ts +109 -0
  44. package/src/diagnostics.ts +59 -0
  45. package/src/document_service.ts +35 -0
  46. package/src/formatting_service.ts +151 -0
  47. package/src/herb-language-server.ts +6 -0
  48. package/src/index.ts +10 -0
  49. package/src/linter_service.ts +63 -0
  50. package/src/parser_service.ts +59 -0
  51. package/src/project.ts +22 -0
  52. package/src/server.ts +111 -0
  53. package/src/service.ts +66 -0
  54. package/src/settings.ts +82 -0
  55. package/src/utils.ts +11 -0
  56. package/dist/herb-language-server +0 -20526
package/package.json CHANGED
@@ -1,46 +1,55 @@
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.4.0",
5
5
  "author": "Marco Roth",
6
6
  "license": "MIT",
7
7
  "engines": {
8
8
  "node": "*"
9
9
  },
10
+ "homepage": "https://herb-tools.dev",
10
11
  "bugs": "https://github.com/marcoroth/herb/issues/new?title=Package%20%60@herb-tools/language-server%60:%20",
11
12
  "repository": {
12
13
  "type": "git",
13
14
  "url": "https://github.com/marcoroth/herb.git",
14
15
  "directory": "javascript/packages/language-server"
15
16
  },
16
- "homepage": "https://herb-tools.dev",
17
17
  "bin": {
18
- "herb-language-server": "./dist/herb-language-server"
18
+ "herb-language-server": "./bin/herb-language-server"
19
+ },
20
+ "main": "./dist/index.cjs",
21
+ "module": "./dist/index.js",
22
+ "require": "./dist/index.cjs",
23
+ "types": "./dist/types/index.d.ts",
24
+ "exports": {
25
+ "./package.json": "./package.json",
26
+ ".": {
27
+ "types": "./dist/types/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "require": "./dist/index.cjs",
30
+ "default": "./dist/index.js"
31
+ }
19
32
  },
20
33
  "scripts": {
21
34
  "clean": "rimraf dist",
22
- "prebuild": "yarn run clean",
23
- "build": "tsc -b && rollup -c rollup.config.mjs",
24
- "postbuild": "node scripts/executable.mjs",
25
- "watch": "tsc -b -w",
26
- "test": "echo 'TODO'",
35
+ "build": "yarn clean && tsc -b && rollup -c",
36
+ "dev": "tsc -b -w",
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/formatter": "0.4.0",
49
+ "@herb-tools/linter": "0.4.0",
50
+ "@herb-tools/node-wasm": "0.4.0",
34
51
  "dedent": "^1.6.0",
35
- "typescript": "^5.8.3",
36
52
  "vscode-languageserver": "^9.0.1",
37
53
  "vscode-languageserver-textdocument": "^1.0.12"
38
- },
39
- "devDependencies": {
40
- "@rollup/plugin-commonjs": "^28.0.6",
41
- "@rollup/plugin-typescript": "^12.1.3",
42
- "@rollup/plugin-node-resolve": "^16.0.1",
43
- "@rollup/plugin-json": "^6.1.0",
44
- "rimraf": "^6.0.1"
45
54
  }
46
55
  }
package/src/cli.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { Server } from "./server"
2
+
3
+ export class CLI {
4
+ private usage = `
5
+ Usage: herb-language-server [options]
6
+
7
+ Options:
8
+ --stdio use stdio
9
+ --node-ipc use node-ipc
10
+ --socket=<port> use socket
11
+ `
12
+
13
+ run() {
14
+ if (process.argv.length <= 2) {
15
+ console.error(`Error: Connection input stream is not set. Set command line parameters: '--node-ipc', '--stdio' or '--socket=<port>'`)
16
+ console.error(this.usage)
17
+ process.exit(1)
18
+ }
19
+
20
+ const server = new Server()
21
+ server.listen()
22
+ }
23
+ }
package/src/config.ts ADDED
@@ -0,0 +1,109 @@
1
+ export type HerbConfigOptions = {
2
+ formatter?: {
3
+ enabled?: boolean
4
+ include?: string[]
5
+ exclude?: string[]
6
+ indentWidth?: number
7
+ maxLineLength?: number
8
+ }
9
+ }
10
+
11
+ export type HerbLSPConfig = {
12
+ version: string
13
+ createdAt: string
14
+ updatedAt: string
15
+ options: HerbConfigOptions
16
+ }
17
+
18
+ import path from "path"
19
+ import { version } from "../package.json"
20
+ import { promises as fs } from "fs"
21
+
22
+ export class Config {
23
+ static configPath = ".herb-lsp/config.json"
24
+
25
+ public readonly path: string
26
+ public config: HerbLSPConfig
27
+
28
+ constructor(projectPath: string, config: HerbLSPConfig) {
29
+ this.path = Config.configPathFromProjectPath(projectPath)
30
+ this.config = config
31
+ }
32
+
33
+ get version(): string {
34
+ return this.config.version
35
+ }
36
+
37
+ get createdAt(): Date {
38
+ return new Date(this.config.createdAt)
39
+ }
40
+
41
+ get updatedAt(): Date {
42
+ return new Date(this.config.updatedAt)
43
+ }
44
+
45
+ get options(): HerbConfigOptions {
46
+ return this.config.options
47
+ }
48
+
49
+ public toJSON() {
50
+ return JSON.stringify(this.config, null, " ")
51
+ }
52
+
53
+ private updateTimestamp() {
54
+ this.config.updatedAt = new Date().toISOString()
55
+ }
56
+
57
+ private updateVersion() {
58
+ this.config.version = version
59
+ }
60
+
61
+ async write() {
62
+ this.updateVersion()
63
+ this.updateTimestamp()
64
+
65
+ const folder = path.dirname(this.path)
66
+
67
+ fs.stat(folder)
68
+ .then(() => {})
69
+ .catch(async () => await fs.mkdir(folder))
70
+ .finally(async () => await fs.writeFile(this.path, this.toJSON()))
71
+ }
72
+
73
+ async read() {
74
+ return await fs.readFile(this.path, "utf8")
75
+ }
76
+
77
+ static configPathFromProjectPath(projectPath: string) {
78
+ return path.join(projectPath, this.configPath)
79
+ }
80
+
81
+ static async fromPathOrNew(projectPath: string) {
82
+ try {
83
+ return await this.fromPath(projectPath)
84
+ } catch (error: any) {
85
+ return Config.newConfig(projectPath)
86
+ }
87
+ }
88
+
89
+ static async fromPath(projectPath: string) {
90
+ const configPath = Config.configPathFromProjectPath(projectPath)
91
+
92
+ try {
93
+ const config = JSON.parse(await fs.readFile(configPath, "utf8"))
94
+
95
+ return new Config(projectPath, config)
96
+ } catch (error: any) {
97
+ throw new Error(`Error reading config file at: ${configPath}. Error: ${error.message}`)
98
+ }
99
+ }
100
+
101
+ static newConfig(projectPath: string): Config {
102
+ return new Config(projectPath, {
103
+ version,
104
+ createdAt: new Date().toISOString(),
105
+ updatedAt: new Date().toISOString(),
106
+ options: {}
107
+ })
108
+ }
109
+ }
@@ -0,0 +1,59 @@
1
+ import { TextDocument } from "vscode-languageserver-textdocument"
2
+ import { Connection, Diagnostic } from "vscode-languageserver/node"
3
+
4
+ import { ParserService } from "./parser_service"
5
+ import { LinterService } from "./linter_service"
6
+ import { DocumentService } from "./document_service"
7
+
8
+ export class Diagnostics {
9
+ private readonly connection: Connection
10
+ private readonly documentService: DocumentService
11
+ private readonly parserService: ParserService
12
+ private readonly linterService: LinterService
13
+ private diagnostics: Map<TextDocument, Diagnostic[]> = new Map()
14
+
15
+ constructor(
16
+ connection: Connection,
17
+ documentService: DocumentService,
18
+ parserService: ParserService,
19
+ linterService: LinterService,
20
+ ) {
21
+ this.connection = connection
22
+ this.documentService = documentService
23
+ this.parserService = parserService
24
+ this.linterService = linterService
25
+ }
26
+
27
+ async validate(textDocument: TextDocument) {
28
+ const parseResult = this.parserService.parseDocument(textDocument)
29
+ const lintResult = await this.linterService.lintDocument(parseResult.document, textDocument)
30
+
31
+ const allDiagnostics = [
32
+ ...parseResult.diagnostics,
33
+ ...lintResult.diagnostics,
34
+ ]
35
+
36
+ this.diagnostics.set(textDocument, allDiagnostics)
37
+ this.sendDiagnosticsFor(textDocument)
38
+ }
39
+
40
+ async refreshDocument(document: TextDocument) {
41
+ await this.validate(document)
42
+ }
43
+
44
+ async refreshAllDocuments() {
45
+ const documents = this.documentService.getAll()
46
+ await Promise.all(documents.map(document => this.refreshDocument(document)))
47
+ }
48
+
49
+ private sendDiagnosticsFor(textDocument: TextDocument) {
50
+ const diagnostics = this.diagnostics.get(textDocument) || []
51
+
52
+ this.connection.sendDiagnostics({
53
+ uri: textDocument.uri,
54
+ diagnostics,
55
+ })
56
+
57
+ this.diagnostics.delete(textDocument)
58
+ }
59
+ }
@@ -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
+ }
@@ -0,0 +1,151 @@
1
+ import { Connection, TextDocuments, DocumentFormattingParams, TextEdit, Range, Position } from "vscode-languageserver/node"
2
+ import { TextDocument } from "vscode-languageserver-textdocument"
3
+ import { Formatter, defaultFormatOptions } from "@herb-tools/formatter"
4
+ import { Project } from "./project"
5
+ import { Settings } from "./settings"
6
+ import { Config } from "./config"
7
+ import { glob } from "glob"
8
+
9
+ export class FormattingService {
10
+ private connection: Connection
11
+ private documents: TextDocuments<TextDocument>
12
+ private project: Project
13
+ private settings: Settings
14
+ private config?: Config
15
+
16
+ constructor(connection: Connection, documents: TextDocuments<TextDocument>, project: Project, settings: Settings) {
17
+ this.connection = connection
18
+ this.documents = documents
19
+ this.project = project
20
+ this.settings = settings
21
+ }
22
+
23
+ async initialize() {
24
+ try {
25
+ this.config = await Config.fromPathOrNew(this.project.projectPath)
26
+ this.connection.console.log("Herb formatter initialized successfully")
27
+ } catch (error) {
28
+ this.connection.console.error(`Failed to initialize Herb formatter: ${error}`)
29
+ }
30
+ }
31
+
32
+ async refreshConfig() {
33
+ this.config = await Config.fromPathOrNew(this.project.projectPath)
34
+ }
35
+
36
+ private async shouldFormatFile(filePath: string): Promise<boolean> {
37
+ if (!this.config?.options.formatter) {
38
+ return true
39
+ }
40
+
41
+ const formatter = this.config.options.formatter
42
+
43
+ // Check if formatting is disabled in project config
44
+ if (formatter.enabled === false) {
45
+ return false
46
+ }
47
+
48
+ // Check exclude patterns first
49
+ if (formatter.exclude) {
50
+ for (const pattern of formatter.exclude) {
51
+ try {
52
+ const matches = await new Promise<string[]>((resolve, reject) => {
53
+ glob(pattern, { cwd: this.project.projectPath }).then(resolve).catch(reject)
54
+ })
55
+
56
+ if (Array.isArray(matches) && matches.some((match: string) => filePath.includes(match) || filePath.endsWith(match))) {
57
+ return false
58
+ }
59
+ } catch (error) {
60
+ continue
61
+ }
62
+ }
63
+ }
64
+
65
+ if (formatter.include && formatter.include.length > 0) {
66
+ for (const pattern of formatter.include) {
67
+ try {
68
+ const matches = await new Promise<string[]>((resolve, reject) => {
69
+ glob(pattern, { cwd: this.project.projectPath }).then(resolve).catch(reject)
70
+ })
71
+ if (Array.isArray(matches) && matches.some((match: string) => filePath.includes(match) || filePath.endsWith(match))) {
72
+ return true
73
+ }
74
+ } catch (error) {
75
+ continue
76
+ }
77
+ }
78
+
79
+ return false
80
+ }
81
+
82
+ return true
83
+ }
84
+
85
+ private async getFormatterOptions(uri: string) {
86
+ // Get VS Code settings
87
+ const settings = await this.settings.getDocumentSettings(uri)
88
+
89
+ // Get project config options
90
+ const projectFormatter = this.config?.options.formatter || {}
91
+
92
+ // Merge options with precedence: project config > VS Code settings > defaults
93
+ return {
94
+ indentWidth: projectFormatter.indentWidth ?? settings.formatter?.indentWidth ?? defaultFormatOptions.indentWidth,
95
+ maxLineLength: projectFormatter.maxLineLength ?? settings.formatter?.maxLineLength ?? defaultFormatOptions.maxLineLength
96
+ }
97
+ }
98
+
99
+ private async performFormatting(params: DocumentFormattingParams): Promise<TextEdit[]> {
100
+ const document = this.documents.get(params.textDocument.uri)
101
+
102
+ if (!document) {
103
+ return []
104
+ }
105
+
106
+ try {
107
+ const options = await this.getFormatterOptions(params.textDocument.uri)
108
+ const formatter = new Formatter(this.project.herbBackend, options)
109
+
110
+ const text = document.getText()
111
+ const newText = formatter.format(text)
112
+
113
+ if (newText === text) {
114
+ return []
115
+ }
116
+
117
+ const range: Range = {
118
+ start: Position.create(0, 0),
119
+ end: Position.create(document.lineCount, 0)
120
+ }
121
+
122
+ return [{ range, newText }]
123
+ } catch (error) {
124
+ this.connection.console.error(`Formatting failed: ${error}`)
125
+
126
+ return []
127
+ }
128
+ }
129
+
130
+ async formatDocument(params: DocumentFormattingParams): Promise<TextEdit[]> {
131
+ // Check VS Code settings first
132
+ const settings = await this.settings.getDocumentSettings(params.textDocument.uri)
133
+
134
+ if (settings.formatter?.enabled === false) {
135
+ return []
136
+ }
137
+
138
+ // Check project config and file patterns
139
+ const filePath = params.textDocument.uri.replace(/^file:\/\//, '')
140
+
141
+ if (!(await this.shouldFormatFile(filePath))) {
142
+ return []
143
+ }
144
+
145
+ return this.performFormatting(params)
146
+ }
147
+
148
+ async formatDocumentIgnoreConfig(params: DocumentFormattingParams): Promise<TextEdit[]> {
149
+ return this.performFormatting(params)
150
+ }
151
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { CLI } from "./cli"
4
+
5
+ const cli = new CLI()
6
+ cli.run()
package/src/index.ts ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./server"
2
+ export * from "./service"
3
+ export * from "./config"
4
+ export * from "./diagnostics"
5
+ export * from "./document_service"
6
+ export * from "./formatting_service"
7
+ export * from "./project"
8
+ export * from "./settings"
9
+ export * from "./utils"
10
+ export * from "./cli"
@@ -0,0 +1,63 @@
1
+ import { Diagnostic, DiagnosticSeverity, Range, Position, CodeDescription } from "vscode-languageserver/node"
2
+ import { TextDocument } from "vscode-languageserver-textdocument"
3
+ import { Linter } from "@herb-tools/linter"
4
+
5
+ import { Settings } from "./settings"
6
+
7
+ import type { DocumentNode } from "@herb-tools/node-wasm"
8
+
9
+ export interface LintServiceResult {
10
+ diagnostics: Diagnostic[]
11
+ }
12
+
13
+ export class LinterService {
14
+ private readonly settings: Settings
15
+ private readonly source = "Herb Linter "
16
+ private linter: Linter
17
+
18
+ constructor(settings: Settings) {
19
+ this.settings = settings
20
+ this.linter = new Linter()
21
+ }
22
+
23
+ async lintDocument(document: DocumentNode, textDocument: TextDocument): Promise<LintServiceResult> {
24
+ const settings = await this.settings.getDocumentSettings(textDocument.uri)
25
+ const linterEnabled = settings.linter?.enabled ?? true
26
+
27
+ if (!linterEnabled) {
28
+ return { diagnostics: [] }
29
+ }
30
+
31
+ const lintResult = this.linter.lint(document)
32
+ const diagnostics: Diagnostic[] = []
33
+
34
+ lintResult.offenses.forEach(offense => {
35
+ const severity = offense.severity === "error"
36
+ ? DiagnosticSeverity.Error
37
+ : DiagnosticSeverity.Warning
38
+
39
+ const range = Range.create(
40
+ Position.create(offense.location.start.line - 1, offense.location.start.column),
41
+ Position.create(offense.location.end.line - 1, offense.location.end.column),
42
+ )
43
+
44
+ const codeDescription: CodeDescription = {
45
+ href: `https://herb-tools.dev/linter/rules/${offense.rule}`
46
+ }
47
+
48
+ const diagnostic: Diagnostic = {
49
+ source: this.source,
50
+ severity,
51
+ range,
52
+ message: offense.message,
53
+ code: offense.rule,
54
+ data: { rule: offense.rule },
55
+ codeDescription
56
+ }
57
+
58
+ diagnostics.push(diagnostic)
59
+ })
60
+
61
+ return { diagnostics }
62
+ }
63
+ }
@@ -0,0 +1,59 @@
1
+ import { 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 type { Node, HerbError, DocumentNode } from "@herb-tools/node-wasm"
6
+
7
+ class ErrorVisitor extends Visitor {
8
+ private readonly source = "Herb Parser "
9
+ public diagnostics: Diagnostic[] = []
10
+
11
+ visitChildNodes(node: Node) {
12
+ super.visitChildNodes(node)
13
+
14
+ node.errors.forEach(error => this.addDiagnosticForError(error, node))
15
+ }
16
+
17
+ private addDiagnosticForError(error: HerbError, node: Node): void {
18
+ const diagnostic: Diagnostic = {
19
+ source: this.source,
20
+ severity: DiagnosticSeverity.Error,
21
+ range: this.rangeFromHerbError(error),
22
+ message: error.message,
23
+ code: error.type,
24
+ data: {
25
+ error: error.toJSON(),
26
+ node: node.toJSON()
27
+ }
28
+ }
29
+
30
+ this.diagnostics.push(diagnostic)
31
+ }
32
+
33
+ private rangeFromHerbError(error: HerbError): Range {
34
+ return Range.create(
35
+ Position.create(error.location.start.line - 1, error.location.start.column),
36
+ Position.create(error.location.end.line - 1, error.location.end.column),
37
+ )
38
+ }
39
+ }
40
+
41
+ export interface ParseServiceResult {
42
+ document: DocumentNode
43
+ diagnostics: Diagnostic[]
44
+ }
45
+
46
+ export class ParserService {
47
+ parseDocument(textDocument: TextDocument): ParseServiceResult {
48
+ const content = textDocument.getText()
49
+ const result = Herb.parse(content)
50
+
51
+ const errorVisitor = new ErrorVisitor()
52
+ result.visit(errorVisitor)
53
+
54
+ return {
55
+ document: result.value,
56
+ diagnostics: errorVisitor.diagnostics
57
+ }
58
+ }
59
+ }
package/src/project.ts ADDED
@@ -0,0 +1,22 @@
1
+ import { Herb, HerbBackend } from "@herb-tools/node-wasm"
2
+ import { Connection } from "vscode-languageserver/node"
3
+
4
+ export class Project {
5
+ connection: Connection
6
+ projectPath: string
7
+ herbBackend: HerbBackend
8
+
9
+ constructor(connection: Connection, projectPath: string) {
10
+ this.projectPath = projectPath
11
+ this.connection = connection
12
+ this.herbBackend = Herb
13
+ }
14
+
15
+ async initialize() {
16
+ await this.herbBackend.load()
17
+ }
18
+
19
+ async refresh() {
20
+ // TODO
21
+ }
22
+ }