├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── CD.yaml │ └── CI.yaml ├── bin └── server.js ├── .gitignore ├── doc └── refactoring.gif ├── .vscode ├── extensions.json └── settings.json ├── .editorconfig ├── src ├── types.ts ├── cli.ts ├── smoke.test.ts └── server.ts ├── CITATION.cff ├── .npmignore ├── biome.json ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: nikeee 2 | -------------------------------------------------------------------------------- /bin/server.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import "../lib/cli.js"; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | lib/* 2 | *.js.map 3 | node_modules/ 4 | *.log 5 | .settings/ 6 | *.db* 7 | dist 8 | -------------------------------------------------------------------------------- /doc/refactoring.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nikeee/dot-language-server/HEAD/doc/refactoring.gif -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "dbaeumer.vscode-eslint" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.singleQuote": false, 3 | "prettier.trailingComma": "es5", 4 | "prettier.printWidth": 160, 5 | "prettier.semi": true, 6 | "prettier.bracketSpacing": true, 7 | "prettier.arrowParens": "avoid" 8 | } 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | 8 | [*.{ts,js,json,js}] 9 | indent_style = tab 10 | tab_width = 4 11 | indent_size = 4 12 | 13 | [{package,tsconfig}.json] 14 | indent_style = space 15 | indent_size = 2 16 | 17 | [*.md] 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // The settings interface describe the server relevant settings part 2 | export interface Settings { 3 | dotLanguageServer: DotLanguageServerSettings; 4 | } 5 | 6 | // These are the example settings we defined in the client's package.json file 7 | export interface DotLanguageServerSettings { 8 | maxNumberOfProblems: number; 9 | } 10 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | groups: 8 | patch-dependencies: 9 | update-types: 10 | - "patch" 11 | 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "monthly" 16 | -------------------------------------------------------------------------------- /CITATION.cff: -------------------------------------------------------------------------------- 1 | cff-version: 1.2.0 2 | title: DOT Language Server 3 | message: If you use this software, please cite it using the metadata from this file. 4 | type: software 5 | authors: 6 | - given-names: Niklas 7 | family-names: Mollenhauer 8 | repository-code: 'https://github.com/nikeee/dot-language-server' 9 | abstract: A language Server (LSP) for the DOT language/Graphviz. 10 | keywords: 11 | - dot 12 | - graphviz 13 | - language-server-protocol 14 | license: MIT 15 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /.github 2 | /doc 3 | biome.json 4 | 5 | CITATION.cff 6 | # No log files/DBs 7 | *.log 8 | *.db* 9 | 10 | *.ts 11 | !*.d.ts 12 | *.js.map 13 | 14 | # No ts files are shipped, so we don't need any config 15 | tsconfig.json 16 | 17 | # Make sure js files always get included 18 | !*.js 19 | 20 | # No CI/Editor-Specific files 21 | .editorconfig 22 | .vscode/ 23 | .travis.yml 24 | 25 | # No raw ts source + tests 26 | src/ 27 | tests/ 28 | 29 | # License is stated/linked to in package.json 30 | LICENSE 31 | *.md 32 | 33 | 34 | lib/tester.* 35 | /dist 36 | 37 | *.test.js 38 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.0.6/schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "indentWidth": 4, 6 | "indentStyle": "tab", 7 | "lineWidth": 100 8 | }, 9 | "vcs": { 10 | "enabled": true, 11 | "clientKind": "git", 12 | "defaultBranch": "master", 13 | "root": "." 14 | }, 15 | "linter": { 16 | "enabled": true, 17 | "rules": { 18 | "recommended": true, 19 | "suspicious": { 20 | "noConstEnum": "off" 21 | } 22 | } 23 | }, 24 | "javascript": { 25 | "formatter": { 26 | "arrowParentheses": "asNeeded" 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | 10 | "noUnusedLocals": false, // TODO: Fix 11 | "noImplicitReturns": true, 12 | "noUnusedParameters": true, 13 | "noFallthroughCasesInSwitch": true, 14 | 15 | "skipLibCheck": true, 16 | "skipDefaultLibCheck": true, 17 | "resolveJsonModule": true, 18 | 19 | "rootDir": "src", 20 | "outDir": "lib", 21 | "declaration": false, 22 | "sourceMap": false, 23 | "removeComments": true, 24 | }, 25 | "include": [ 26 | "src/**/*.ts" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Niklas Mollenhauer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/CD.yaml: -------------------------------------------------------------------------------- 1 | name: CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - "!*" 7 | tags: 8 | - "v*" 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-slim 13 | 14 | permissions: 15 | contents: read 16 | id-token: write 17 | 18 | steps: 19 | - uses: actions/checkout@v6 20 | with: 21 | show-progress: false 22 | 23 | - uses: actions/setup-node@v6 24 | with: 25 | node-version: 24 26 | cache: npm 27 | registry-url: https://registry.npmjs.org 28 | 29 | - run: npm ci 30 | - run: npm run ci 31 | - run: npm run build 32 | - run: npm test 33 | env: 34 | CI: true 35 | - run: npm publish --provenance --access public 36 | 37 | - uses: actions/upload-artifact@v5 38 | with: 39 | name: linux-x64 40 | path: dist/linux-x64/dot-language-server 41 | 42 | - uses: actions/upload-artifact@v5 43 | with: 44 | name: linux-arm64 45 | path: dist/linux-arm64/dot-language-server 46 | 47 | - uses: actions/upload-artifact@v5 48 | with: 49 | name: windows-x64 50 | path: dist/windows-x64/dot-language-server.exe 51 | 52 | - uses: actions/upload-artifact@v5 53 | with: 54 | name: macos-arm64 55 | path: dist/darwin-arm64/dot-language-server 56 | -------------------------------------------------------------------------------- /.github/workflows/CI.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-slim 8 | 9 | strategy: 10 | matrix: 11 | node-version: [22.x, 24.x, 25.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v6 15 | with: 16 | show-progress: false 17 | 18 | - uses: actions/setup-node@v6 19 | with: 20 | node-version: ${{ matrix.node-version }} 21 | cache: npm 22 | 23 | - run: npm ci 24 | - run: npm run ci 25 | - run: npm run build 26 | - run: npm test 27 | env: 28 | CI: true 29 | 30 | artifacts: 31 | runs-on: ubuntu-slim 32 | needs: build 33 | 34 | steps: 35 | - uses: actions/checkout@v6 36 | with: 37 | show-progress: false 38 | 39 | - uses: actions/setup-node@v6 40 | with: 41 | node-version: 25.x 42 | cache: npm 43 | 44 | - run: npm ci 45 | - run: npm run build 46 | 47 | - uses: actions/upload-artifact@v5 48 | with: 49 | name: linux-x64 50 | path: dist/linux-x64/dot-language-server 51 | 52 | - uses: actions/upload-artifact@v5 53 | with: 54 | name: linux-arm64 55 | path: dist/linux-arm64/dot-language-server 56 | 57 | - uses: actions/upload-artifact@v5 58 | with: 59 | name: windows-x64 60 | path: dist/windows-x64/dot-language-server.exe 61 | 62 | - uses: actions/upload-artifact@v5 63 | with: 64 | name: darwin-arm64 65 | path: dist/darwin-arm64/dot-language-server 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dot-language-server", 3 | "version": "3.0.2", 4 | "type": "module", 5 | "description": "Language server for the DOT language", 6 | "author": "Niklas Mollenhauer", 7 | "license": "MIT", 8 | "scripts": { 9 | "clean": "rimraf lib dist", 10 | "build:linux-x64": "bun build --compile --minify src/cli.ts --target=bun-linux-x64-modern --outfile=dist/linux-x64/dot-language-server", 11 | "build:linux-arm64": "bun build --compile --minify src/cli.ts --target=bun-linux-arm64-modern --outfile=dist/linux-arm64/dot-language-server", 12 | "build:windows-x64": "bun build --compile --minify src/cli.ts --target=bun-windows-x64-modern --outfile=dist/windows-x64/dot-language-server.exe", 13 | "build:darwin-arm64": "bun build --compile --minify src/cli.ts --target=bun-darwin-arm64-modern --outfile=dist/darwin-arm64/dot-language-server", 14 | "build": "tsc && node --run build:linux-x64 && node --run build:linux-arm64 && node --run build:windows-x64 && node --run build:darwin-arm64", 15 | "ci": "biome ci ./src", 16 | "format": "biome format --write ./src", 17 | "lint": "biome lint ./src", 18 | "lint:fix": "biome lint --apply ./src", 19 | "test": "tsc --noEmit", 20 | "smoketest": "node --run build && node --test", 21 | "prepublishOnly": "node --run clean && node --run build" 22 | }, 23 | "bin": { 24 | "dot-language-server": "bin/server.js" 25 | }, 26 | "keywords": [ 27 | "graphviz", 28 | "dot", 29 | "language", 30 | "server", 31 | "protocol", 32 | "gv", 33 | "lsp" 34 | ], 35 | "repository": { 36 | "type": "git", 37 | "url": "https://github.com/nikeee/dot-language-server.git" 38 | }, 39 | "dependencies": { 40 | "dot-language-support": "^4.0.2", 41 | "vscode-languageserver": "^9.0.1", 42 | "vscode-languageserver-textdocument": "^1.0.12" 43 | }, 44 | "devDependencies": { 45 | "@biomejs/biome": "^2.3.8", 46 | "@types/node": "^24.10.1", 47 | "bun": "^1.3.3", 48 | "rimraf": "^6.1.2", 49 | "typescript": "^5.9.3" 50 | }, 51 | "engines": { 52 | "node": ">=22" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { parseArgs } from "node:util"; 2 | 3 | import * as lsp from "vscode-languageserver/node.js"; 4 | import packageJson from "../package.json" with { type: "json" }; 5 | import { runServer } from "./server.js"; 6 | 7 | type ArgsBase = { stdio?: true; nodeIpc?: true; socket?: number; pipe?: string }; 8 | type StdIOArgs = { stdio: true }; 9 | type NodeIPCArgs = { nodeIpc: true }; 10 | type SocketArgs = { socket: number }; 11 | type PipeArgs = { pipe: string }; 12 | 13 | type Args = (StdIOArgs | NodeIPCArgs | SocketArgs | PipeArgs) & ArgsBase; 14 | 15 | // The cli is defined like this because VSCode uses this parameters on its servers 16 | const { values } = parseArgs({ 17 | options: { 18 | stdio: { 19 | type: "boolean", 20 | }, 21 | "node-ipc": { 22 | type: "boolean", 23 | }, 24 | socket: { 25 | type: "string", 26 | }, 27 | pipe: { 28 | type: "string", 29 | }, 30 | version: { 31 | type: "boolean", 32 | short: "v", 33 | }, 34 | help: { 35 | type: "boolean", 36 | short: "h", 37 | }, 38 | }, 39 | strict: false, 40 | allowPositionals: true, 41 | }); 42 | 43 | if (values.version) { 44 | console.log(packageJson.version); 45 | process.exit(0); 46 | } 47 | 48 | if (values.help) { 49 | console.log(` 50 | Options: 51 | --stdio Use stdio 52 | --node-ipc Use node-ipc 53 | --socket Use socket 54 | --pipe Use pipe 55 | -v, --version Show version number 56 | -h, --help Show help 57 | `); 58 | process.exit(0); 59 | } 60 | 61 | const argv = { 62 | stdio: values.stdio ? true : undefined, 63 | nodeIpc: values["node-ipc"] ? true : undefined, 64 | socket: values.socket ? Number(values.socket) : undefined, 65 | pipe: values.pipe ? String(values.pipe) : undefined, 66 | } as unknown as Args; 67 | 68 | const setArgs = [argv.stdio, argv.socket, argv.nodeIpc, argv.pipe]; 69 | 70 | if (setArgs.every(a => !a)) { 71 | console.error( 72 | "Connection type required (stdio, node-ipc, socket, pipe). Refer to --help for more details.", 73 | ); 74 | process.exit(1); 75 | } 76 | 77 | if (setArgs.filter(a => !!a).length !== 1) { 78 | console.error( 79 | "You can only set exactly one connection type (stdio, node-ipc, socket, pipe). Refer to --help for more details.", 80 | ); 81 | process.exit(1); 82 | } 83 | 84 | // We only use parseArgs for partial validation and providing help. the lsp.createConnection() handles the CLI params internally 85 | const connection = lsp.createConnection(); 86 | runServer(connection); 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dot-language-server [![CI](https://github.com/nikeee/dot-language-server/actions/workflows/CD.yaml/badge.svg)](https://github.com/nikeee/dot-language-server/actions/workflows/CD.yaml) ![Dependency Status](https://img.shields.io/librariesio/release/npm/dot-language-server) ![npm downloads](https://img.shields.io/npm/dm/dot-language-server) 2 | 3 | A language Server for the DOT language/Graphviz. 4 | 5 | ## Prerequisites 6 | - Node.js `>=22` 7 | - `npm` 8 | 9 | ## Installation 10 | 11 | ```Shell 12 | npm i -g dot-language-server 13 | ``` 14 | 15 | If you want to request or implement new features, head over to [dot-language-support](https://github.com/nikeee/dot-language-support). 16 | 17 | ## Features 18 | #### Refactorings 19 | ![Refactorings Demo in Sublime Text](https://raw.githubusercontent.com/nikeee/dot-language-server/master/doc/refactoring.gif) 20 | 21 | ## Usage 22 | 23 | ### Vim 24 | 25 | #### coc.nvim 26 | 27 | ```json 28 | { 29 | "languageserver": { 30 | "dot": { 31 | "command": "dot-language-server", 32 | "args": ["--stdio"], 33 | "filetypes": ["dot"] 34 | } 35 | } 36 | } 37 | ``` 38 | 39 | ### vim-lsp 40 | 41 | ```vim 42 | if executable('dot-language-server') 43 | augroup lsp 44 | autocmd! 45 | autocmd User lsp_setup call lsp#register_server({ 46 | \ 'name': 'dot', 47 | \ 'cmd': {server_info->['dot-language-server', '--stdio']}, 48 | \ 'whitelist': ['dot'], 49 | \ }) 50 | augroup END 51 | endif 52 | ``` 53 | 54 | ### [Neovim](https://neovim.io) 55 | 56 | ```lua 57 | vim.api.nvim_create_autocmd({ "BufEnter" }, { 58 | pattern = { "*.dot" }, 59 | callback = function() 60 | vim.lsp.start({ 61 | name = "dot", 62 | cmd = { "dot-language-server", "--stdio" } 63 | }) 64 | end, 65 | }) 66 | ``` 67 | 68 | ### Visual Studio Code 69 | 70 | TODO: There's an Extension for that. 71 | 72 | ### Sublime Text 73 | 74 | 1. Install [LSP support](https://github.com/tomv564/LSP) via `Install Package` -> [`LSP`](https://packagecontrol.io/packages/LSP) 75 | 2. Go to `Preferences: LSP Settings` 76 | 3. Add this to clients: 77 | 78 | ```JSON 79 | { 80 | "clients": { 81 | "dot-language-server": { 82 | "command": ["dot-language-server", "--stdio"], 83 | "enabled": true, 84 | "languageId": "dot", 85 | "scopes": ["source.dot"], 86 | "syntaxes": ["Packages/Graphviz/DOT.sublime-syntax"] 87 | } 88 | } 89 | } 90 | ``` 91 | **Note for Windows Users**: You have to append `.cmd` to the first entry in the `command` array (or, if possible, enable shell execution). 92 | 93 | ### Emacs 94 | For Emacs users, you need to use `lsp-mode` which supports the DOT Language Server out of the box. 95 | 96 | ...and you're done! 97 | -------------------------------------------------------------------------------- /src/smoke.test.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // Caution: This file was vibe-coded. It is only used to ensure that upgrading the LSP librarys doesnt break anything 4 | 5 | import { strict as assert } from "node:assert"; 6 | import { spawn } from "node:child_process"; 7 | import { join } from "node:path"; 8 | import { after, describe, test } from "node:test"; 9 | 10 | interface LSPMessage { 11 | jsonrpc: "2.0"; 12 | id?: number | string; 13 | method?: string; 14 | params?: unknown; 15 | result?: unknown; 16 | error?: { 17 | code: number; 18 | message: string; 19 | data?: unknown; 20 | }; 21 | } 22 | 23 | class LSPSmokeTest { 24 | private serverProcess; 25 | private messageId = 0; 26 | private pendingRequests = new Map< 27 | number | string, 28 | { 29 | resolve: (value: unknown) => void; 30 | reject: (error: Error) => void; 31 | timeout: NodeJS.Timeout; 32 | } 33 | >(); 34 | private buffer = ""; 35 | 36 | constructor(serverPath: string) { 37 | this.serverProcess = spawn(serverPath, ["--stdio"], { 38 | cwd: process.cwd(), 39 | stdio: ["pipe", "pipe", "pipe"], 40 | }); 41 | 42 | this.serverProcess.stderr?.on("data", (data: Buffer) => { 43 | console.error(`[Server stderr] ${data.toString()}`); 44 | }); 45 | 46 | this.serverProcess.stdout?.on("data", (data: Buffer) => { 47 | this.handleServerOutput(data); 48 | }); 49 | 50 | this.serverProcess.on("error", error => { 51 | console.error(`Failed to start server: ${error.message}`); 52 | }); 53 | 54 | this.serverProcess.on("exit", (code, signal) => { 55 | if (code !== null && code !== 0) { 56 | console.error(`Server exited with code ${code}`); 57 | } 58 | if (signal) { 59 | console.error(`Server was killed with signal ${signal}`); 60 | } 61 | }); 62 | } 63 | 64 | private handleServerOutput(data: Buffer): void { 65 | this.buffer += data.toString(); 66 | 67 | // LSP messages use format: "Content-Length: \r\n\r\n" 68 | while (this.buffer.length > 0) { 69 | const headerMatch = this.buffer.match(/Content-Length: (\d+)\r?\n\r?\n/); 70 | if (!headerMatch) { 71 | // Don't have a complete header yet 72 | break; 73 | } 74 | 75 | const contentLength = parseInt(headerMatch[1], 10); 76 | const headerEnd = headerMatch[0].length; 77 | const messageStart = headerEnd; 78 | const messageEnd = messageStart + contentLength; 79 | 80 | if (this.buffer.length < messageEnd) { 81 | // Don't have the complete message yet 82 | break; 83 | } 84 | 85 | const jsonStr = this.buffer.substring(messageStart, messageEnd); 86 | this.buffer = this.buffer.substring(messageEnd); 87 | 88 | try { 89 | const message: LSPMessage = JSON.parse(jsonStr); 90 | this.handleMessage(message); 91 | } catch (error) { 92 | console.error( 93 | `Failed to parse message: ${error instanceof Error ? error.message : String(error)}`, 94 | ); 95 | console.error(`Message: ${jsonStr}`); 96 | } 97 | } 98 | } 99 | 100 | private handleMessage(message: LSPMessage): void { 101 | if (message.id !== undefined && this.pendingRequests.has(message.id)) { 102 | // biome-ignore lint/style/noNonNullAssertion: :shrug: 103 | const pending = this.pendingRequests.get(message.id)!; 104 | clearTimeout(pending.timeout); 105 | 106 | if (message.error) { 107 | pending.reject( 108 | new Error( 109 | `Server error: ${message.error.message} (code: ${message.error.code})`, 110 | ), 111 | ); 112 | } else { 113 | pending.resolve(message.result); 114 | } 115 | 116 | this.pendingRequests.delete(message.id); 117 | } 118 | } 119 | 120 | private sendMessage(message: Omit): Promise { 121 | const id = ++this.messageId; 122 | const fullMessage: LSPMessage = { 123 | jsonrpc: "2.0", 124 | id, 125 | ...message, 126 | }; 127 | 128 | return new Promise((resolve, reject) => { 129 | const timeout = setTimeout(() => { 130 | this.pendingRequests.delete(id); 131 | reject(new Error(`Request ${id} (${message.method}) timed out after 5 seconds`)); 132 | }, 5000); 133 | 134 | this.pendingRequests.set(id, { resolve, reject, timeout }); 135 | 136 | const jsonStr = JSON.stringify(fullMessage); 137 | const contentLength = Buffer.byteLength(jsonStr, "utf8"); 138 | const messageStr = `Content-Length: ${contentLength}\r\n\r\n${jsonStr}`; 139 | 140 | this.serverProcess.stdin?.write(messageStr, "utf8", error => { 141 | if (error) { 142 | clearTimeout(timeout); 143 | this.pendingRequests.delete(id); 144 | reject(error); 145 | } 146 | }); 147 | }); 148 | } 149 | 150 | async testInitialize(): Promise<{ capabilities?: unknown }> { 151 | const result = (await this.sendMessage({ 152 | method: "initialize", 153 | params: { 154 | processId: process.pid, 155 | clientInfo: { 156 | name: "smoketest", 157 | version: "1.0.0", 158 | }, 159 | locale: "en", 160 | rootPath: null, 161 | rootUri: null, 162 | capabilities: {}, 163 | workspaceFolders: null, 164 | }, 165 | })) as { capabilities?: unknown }; 166 | 167 | return result; 168 | } 169 | 170 | async testInitialized(): Promise { 171 | // This is a notification, so no response expected 172 | // Send it directly without waiting for a response 173 | const notification: LSPMessage = { 174 | jsonrpc: "2.0", 175 | method: "initialized", 176 | }; 177 | const jsonStr = JSON.stringify(notification); 178 | const contentLength = Buffer.byteLength(jsonStr, "utf8"); 179 | const messageStr = `Content-Length: ${contentLength}\r\n\r\n${jsonStr}`; 180 | 181 | this.serverProcess.stdin?.write(messageStr, "utf8"); 182 | } 183 | 184 | async shutdown(): Promise { 185 | try { 186 | await this.sendMessage({ 187 | method: "shutdown", 188 | }); 189 | // Send exit notification 190 | const exitMessage: LSPMessage = { 191 | jsonrpc: "2.0", 192 | method: "exit", 193 | }; 194 | const jsonStr = JSON.stringify(exitMessage); 195 | const contentLength = Buffer.byteLength(jsonStr, "utf8"); 196 | const messageStr = `Content-Length: ${contentLength}\r\n\r\n${jsonStr}`; 197 | this.serverProcess.stdin?.write(messageStr, "utf8"); 198 | 199 | // Give the server a moment to exit gracefully 200 | await new Promise(resolve => setTimeout(resolve, 500)); 201 | 202 | if (!this.serverProcess.killed && this.serverProcess.exitCode === null) { 203 | this.serverProcess.kill(); 204 | } 205 | } catch (error) { 206 | this.serverProcess.kill(); 207 | throw error; 208 | } 209 | } 210 | } 211 | 212 | const serverPath = join(process.cwd(), "dist", "linux-x64", "dot-language-server"); 213 | let lspTest: LSPSmokeTest; 214 | 215 | describe("LSP smoketest", () => { 216 | test("server starts", async () => { 217 | lspTest = new LSPSmokeTest(serverPath); 218 | // Wait a bit for the server to start 219 | await new Promise(resolve => setTimeout(resolve, 500)); 220 | }); 221 | 222 | test("initialize request", async () => { 223 | const result = await lspTest.testInitialize(); 224 | 225 | assert.ok(result, "Initialize should return a result"); 226 | assert.ok(typeof result === "object", "Result should be an object"); 227 | assert.ok("capabilities" in result, "Result should have capabilities"); 228 | }); 229 | 230 | test("initialized notification", async () => { 231 | await lspTest.testInitialized(); 232 | // Notification doesn't return a response, so we just verify it doesn't throw 233 | }); 234 | 235 | test("shutdown", async () => { 236 | await lspTest.shutdown(); 237 | }); 238 | 239 | after(async () => { 240 | if (lspTest) { 241 | try { 242 | await lspTest.shutdown(); 243 | } catch { 244 | // Ignore errors during cleanup 245 | } 246 | } 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { createService, type SourceFile } from "dot-language-support"; 2 | import * as rpc from "vscode-jsonrpc"; 3 | import * as lsp from "vscode-languageserver"; 4 | import { TextDocumentSyncKind } from "vscode-languageserver"; 5 | import { TextDocument } from "vscode-languageserver-textdocument"; 6 | 7 | import type { DotLanguageServerSettings, Settings } from "./types.js"; 8 | 9 | const defaultSettings: DotLanguageServerSettings = { maxNumberOfProblems: 100 }; 10 | 11 | export function runServer(connection: lsp.Connection) { 12 | if (!connection) throw "connection is missing"; 13 | 14 | const languageService = createService(); 15 | 16 | // Create a simple text document manager. 17 | // The text document manager supports full document sync only 18 | const documents = new lsp.TextDocuments(TextDocument); 19 | const astOfFile = new Map(); 20 | 21 | // Make the documents listen for changes on the connection 22 | documents.listen(connection); 23 | 24 | // biome-ignore lint/correctness/noUnusedVariables: :shrug: 25 | let shouldSendDiagnosticRelatedInformation = false; 26 | 27 | // After the server has started the client sends an initialize request. The server receives 28 | // in the passed params the rootPath of the workspace plus the client capabilities. 29 | connection.onInitialize((_params): lsp.InitializeResult => { 30 | const a = _params.capabilities?.textDocument?.publishDiagnostics?.relatedInformation; 31 | shouldSendDiagnosticRelatedInformation = !!a; 32 | 33 | return { 34 | capabilities: { 35 | textDocumentSync: TextDocumentSyncKind.Full, 36 | completionProvider: { 37 | triggerCharacters: ["="], 38 | resolveProvider: false, // TODO: Maybe support this 39 | }, 40 | hoverProvider: true, 41 | referencesProvider: true, 42 | definitionProvider: true, 43 | renameProvider: true, 44 | codeActionProvider: true, 45 | executeCommandProvider: { 46 | commands: languageService.getAvailableCommands(), 47 | }, 48 | colorProvider: true, 49 | // documentFormattingProvider: true, 50 | }, 51 | }; 52 | }); 53 | 54 | function rebuildAll() { 55 | for (const uri of astOfFile.keys()) updateAst(uri); 56 | } 57 | 58 | function updateAst(uri: string, doc?: TextDocument): SourceFile | undefined { 59 | const d = doc === undefined ? documents.get(uri) : doc; 60 | if (!d) { 61 | return undefined; 62 | } 63 | 64 | const ast = languageService.parseDocument(d); 65 | astOfFile.set(uri, ast); 66 | return ast; 67 | } 68 | 69 | function ensureAst(uri: string, doc?: TextDocument): SourceFile | undefined { 70 | let ast = astOfFile.get(uri); 71 | if (ast === undefined) ast = updateAst(uri, doc); 72 | return ast; 73 | } 74 | 75 | connection.onHover(req => { 76 | const uri = req.textDocument.uri; 77 | const doc = documents.get(uri); 78 | const ast = ensureAst(uri, doc); 79 | return doc && ast ? languageService.hover(doc, ast, req.position) : invalidRequest(); 80 | }); 81 | 82 | connection.onReferences(req => { 83 | const uri = req.textDocument.uri; 84 | const doc = documents.get(uri); 85 | const ast = ensureAst(uri, doc); 86 | return doc && ast 87 | ? languageService.findReferences(doc, ast, req.position, req.context) 88 | : invalidRequest(); 89 | }); 90 | 91 | connection.onDefinition(req => { 92 | const uri = req.textDocument.uri; 93 | const doc = documents.get(uri); 94 | const ast = ensureAst(uri, doc); 95 | return doc && ast 96 | ? languageService.findDefinition(doc, ast, req.position) 97 | : invalidRequest(); 98 | }); 99 | 100 | connection.onDocumentColor(req => { 101 | const uri = req.textDocument.uri; 102 | const doc = documents.get(uri); 103 | const ast = ensureAst(uri, doc); 104 | return doc && ast ? languageService.getDocumentColors(doc, ast) : invalidRequest(); 105 | }); 106 | 107 | connection.onColorPresentation(req => { 108 | const uri = req.textDocument.uri; 109 | const doc = documents.get(uri); 110 | const ast = ensureAst(uri, doc); 111 | return doc && ast 112 | ? languageService.getColorRepresentations(doc, ast, req.color, req.range) 113 | : invalidRequest(); 114 | }); 115 | 116 | /** 117 | * Event that gathers possible code actions 118 | */ 119 | connection.onCodeAction(req => { 120 | const uri = req.textDocument.uri; 121 | const doc = documents.get(uri); 122 | const ast = ensureAst(uri, doc); 123 | if (doc && ast) { 124 | const r = languageService.getCodeActions(doc, ast, req.range, req.context); 125 | 126 | // Put the URI of the current document to the end 127 | // We need the uri to get the AST later 128 | if (r) { 129 | for (const command of r) { 130 | if (command.arguments) command.arguments.push(uri); 131 | else command.arguments = [uri]; 132 | } 133 | } 134 | 135 | return r; 136 | } 137 | return invalidRequest(); 138 | }); 139 | 140 | /** 141 | * Gets called when a code action is actually invoked. 142 | */ 143 | connection.onExecuteCommand(req => { 144 | const args = req.arguments; 145 | if (!args || args.length < 1) return; 146 | 147 | // Remove the URI and retrieve AST 148 | const uri = args.pop(); 149 | const doc = documents.get(uri); 150 | const ast = ensureAst(uri, doc); 151 | if (doc && ast) { 152 | req.arguments = args.length === 0 ? undefined : args; 153 | const edit = languageService.executeCommand(doc, ast, req); 154 | 155 | if (edit) connection.workspace.applyEdit(edit); 156 | } 157 | }); 158 | 159 | /** 160 | * Invoked when user renames something. 161 | * TODO: Symbol Provider, so we can only rename symbols? 162 | */ 163 | connection.onRenameRequest(req => { 164 | const uri = req.textDocument.uri; 165 | const doc = documents.get(uri); 166 | const ast = ensureAst(uri, doc); 167 | if (doc && ast) { 168 | const r = languageService.renameSymbol(doc, ast, req.position, req.newName); 169 | return r ? r : invalidRequest(); 170 | } 171 | return invalidRequest(); 172 | }); 173 | 174 | /** 175 | * Update the current AST and send diagnostics. 176 | */ 177 | documents.onDidChangeContent(change => { 178 | const doc = change.document; 179 | const ast = updateAst(doc.uri, doc); 180 | if (ast === undefined) throw "This cannot happen"; 181 | return validateDocument(doc, ast); 182 | }); 183 | 184 | // biome-ignore lint/correctness/noUnusedVariables: :shrug: 185 | let currentSettings: DotLanguageServerSettings = { ...defaultSettings }; 186 | 187 | // The settings have changed. Is send on server activation as well. 188 | connection.onDidChangeConfiguration(change => { 189 | const newSettings = (change.settings as Settings).dotLanguageServer; 190 | if (newSettings) currentSettings = newSettings; 191 | 192 | rebuildAll(); 193 | validateAll(); 194 | }); 195 | 196 | function validateAll() { 197 | for (const uri of astOfFile.keys()) { 198 | const doc = documents.get(uri); 199 | if (doc) { 200 | const ast = ensureAst(uri, doc); 201 | if (ast) validateDocument(doc, ast); 202 | } 203 | } 204 | } 205 | function validateDocument(doc: TextDocument, sf: SourceFile): void { 206 | const diagnostics = languageService.validateDocument(doc, sf); 207 | connection.sendDiagnostics({ uri: doc.uri, diagnostics }); 208 | } 209 | 210 | connection.onDidChangeWatchedFiles(_change => { 211 | // Monitored files have change in VSCode 212 | connection.console.log("We received an file change event"); 213 | }); 214 | 215 | /** 216 | * Invoked when the user types and the editor requests a list of items to display for completion. 217 | */ 218 | connection.onCompletion(req => { 219 | const uri = req.textDocument.uri; 220 | const doc = documents.get(uri); 221 | const ast = ensureAst(uri, doc); 222 | return doc && ast 223 | ? // biome-ignore lint/suspicious/noExplicitAny: :shrug: 224 | (languageService.getCompletions(doc, ast, req.position) as any) 225 | : invalidRequest(); 226 | }); 227 | 228 | documents.onDidOpen(params => updateAst(params.document.uri, params.document)); 229 | 230 | const invalidRequest = () => 231 | new rpc.ResponseError(rpc.ErrorCodes.InvalidRequest, "Invalid request"); 232 | 233 | connection.listen(); 234 | } 235 | --------------------------------------------------------------------------------