Skip to content

Commit 8e820e7

Browse files
authored
feat(lsp): add Vue Language Server support (#12)
* feat(lsp): add Vue Language Server support - Add VueServer definition in packages/lsp/src/server.ts with Full Hybrid Mode - Implements npm-based auto-install of Vue LSP dependencies to ~/.cache/dora/vue-lsp/ - Dual-server coordination: @vue/language-server + companion TypeScript LS - Export VueServer from packages/lsp/src/index.ts - Add VueServer to LSP_SERVERS array for plugin integration Closes #11 * feat(lsp): enhance Vue LSP with full hybrid mode and tests - Enhance VueServer with hybridMode initialization options - Add VUE_RUNTIME_DEPS with structured version management - Add getVueExpectedVersion() for version tracking - Improve setupVueDependencies() to return dependency paths - Add comprehensive unit tests for VueServer: - Basic server properties (id, extensions, root, spawn) - Root detection for package.json and lock files - Deno project exclusion (deno.json and deno.jsonc) - Monorepo nested package detection - Add vue-project test fixture with App.vue, Calculator.vue, math.ts - Add integration tests for Vue LSP server operations: - Server connection and status - Diagnostics for Vue files - Hover, workspace symbols, document symbols - Cross-file TypeScript utility support Closes #11 * fix(lsp): improve Vue LSP error handling and add missing tests Error handling improvements: - Check for ENOENT specifically when accessing executables - Check for ENOENT specifically when reading version file - Log unexpected errors instead of silently swallowing them - Isolate version file write failure (non-fatal) - Verify each required path individually with specific error messages Additional unit tests: - Add 'no package.json fallback' test for VueServer.root - Add 'deep nesting root detection' test for VueServer.root These changes follow the established patterns from KotlinServer and DartServer for consistent error handling across LSP implementations.
1 parent fc81f6b commit 8e820e7

File tree

9 files changed

+745
-0
lines changed

9 files changed

+745
-0
lines changed

packages/lsp/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,4 +405,5 @@ export {
405405
PyrightServer,
406406
RustAnalyzerServer,
407407
TypescriptServer,
408+
VueServer,
408409
} from './server'

packages/lsp/src/server.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,11 +849,267 @@ export const DartServer: LSPServerInfo = {
849849
},
850850
}
851851

852+
// =============================================================================
853+
// Vue Language Server
854+
// =============================================================================
855+
856+
/**
857+
* Vue Language Server runtime dependency configuration
858+
* Uses @vue/language-server with Full Hybrid Mode and companion TypeScript server
859+
*
860+
* Architecture (matching Python reference):
861+
* - Vue LS handles .vue files with hybridMode: true
862+
* - Companion TypeScript LS with @vue/typescript-plugin for cross-file references
863+
*/
864+
const VUE_RUNTIME_DEPS = {
865+
vueLanguageServer: {
866+
package: '@vue/language-server',
867+
version: '2.2.0',
868+
},
869+
vueTypeScriptPlugin: {
870+
package: '@vue/typescript-plugin',
871+
version: '2.2.0',
872+
},
873+
typescript: {
874+
package: 'typescript',
875+
version: '5.7.2',
876+
},
877+
typeScriptLanguageServer: {
878+
package: 'typescript-language-server',
879+
version: '4.3.3',
880+
},
881+
}
882+
883+
/**
884+
* Get the Vue LSP resources directory
885+
*/
886+
function getVueResourcesDir(): string {
887+
return path.join(os.homedir(), '.cache', 'dora', 'vue-lsp')
888+
}
889+
890+
/**
891+
* Get combined version string for version marker file
892+
*/
893+
function getVueExpectedVersion(): string {
894+
return [
895+
VUE_RUNTIME_DEPS.vueLanguageServer.version,
896+
VUE_RUNTIME_DEPS.vueTypeScriptPlugin.version,
897+
VUE_RUNTIME_DEPS.typescript.version,
898+
VUE_RUNTIME_DEPS.typeScriptLanguageServer.version,
899+
].join('_')
900+
}
901+
902+
/**
903+
* Setup Vue runtime dependencies using npm
904+
* Installs to ~/.cache/dora/vue-lsp/ if not already present or version mismatch
905+
*
906+
* @returns Object with paths to executables and tsdk, or undefined on failure
907+
*/
908+
async function setupVueDependencies(): Promise<{
909+
vueServerPath: string
910+
tsServerPath: string
911+
tsdkPath: string
912+
vuePluginPath: string
913+
} | undefined> {
914+
const resourcesDir = getVueResourcesDir()
915+
const isWindows = process.platform === 'win32'
916+
const ext = isWindows ? '.cmd' : ''
917+
918+
const vueServerPath = path.join(resourcesDir, 'node_modules', '.bin', `vue-language-server${ext}`)
919+
const tsServerPath = path.join(resourcesDir, 'node_modules', '.bin', `typescript-language-server${ext}`)
920+
const tsdkPath = path.join(resourcesDir, 'node_modules', 'typescript', 'lib')
921+
const vuePluginPath = path.join(resourcesDir, 'node_modules', '@vue', 'typescript-plugin')
922+
923+
const versionFile = path.join(resourcesDir, '.installed_version')
924+
const expectedVersion = getVueExpectedVersion()
925+
926+
// Check if installation is needed
927+
let needsInstall = false
928+
929+
try {
930+
await fs.access(vueServerPath)
931+
await fs.access(tsServerPath)
932+
933+
try {
934+
const installedVersion = await fs.readFile(versionFile, 'utf-8')
935+
if (installedVersion.trim() !== expectedVersion) {
936+
console.warn(`[vue] Version mismatch: installed=${installedVersion.trim()}, expected=${expectedVersion}`)
937+
needsInstall = true
938+
}
939+
}
940+
catch (err) {
941+
const isNotFound = err instanceof Error
942+
&& 'code' in err
943+
&& (err as NodeJS.ErrnoException).code === 'ENOENT'
944+
945+
if (isNotFound) {
946+
// Version file doesn't exist, needs install
947+
needsInstall = true
948+
}
949+
else {
950+
// Unexpected error reading version file - log it but proceed with reinstall
951+
console.warn(`[vue] Unexpected error reading version file:`, err instanceof Error ? err.message : err)
952+
needsInstall = true
953+
}
954+
}
955+
}
956+
catch (err) {
957+
const isNotFound = err instanceof Error
958+
&& 'code' in err
959+
&& (err as NodeJS.ErrnoException).code === 'ENOENT'
960+
961+
if (isNotFound) {
962+
// Executables not found, needs install
963+
needsInstall = true
964+
}
965+
else {
966+
// Unexpected error accessing executables
967+
console.error(`[vue] Cannot access Vue LSP executables:`, err instanceof Error ? err.message : err)
968+
return undefined
969+
}
970+
}
971+
972+
if (needsInstall) {
973+
console.warn('[vue] Installing Vue Language Server dependencies...')
974+
975+
try {
976+
await fs.mkdir(resourcesDir, { recursive: true })
977+
978+
// Install all packages with specific versions
979+
const packages = [
980+
`${VUE_RUNTIME_DEPS.vueLanguageServer.package}@${VUE_RUNTIME_DEPS.vueLanguageServer.version}`,
981+
`${VUE_RUNTIME_DEPS.vueTypeScriptPlugin.package}@${VUE_RUNTIME_DEPS.vueTypeScriptPlugin.version}`,
982+
`${VUE_RUNTIME_DEPS.typescript.package}@${VUE_RUNTIME_DEPS.typescript.version}`,
983+
`${VUE_RUNTIME_DEPS.typeScriptLanguageServer.package}@${VUE_RUNTIME_DEPS.typeScriptLanguageServer.version}`,
984+
]
985+
986+
const proc = Bun.spawn(['npm', 'install', '--prefix', resourcesDir, ...packages], {
987+
cwd: resourcesDir,
988+
stdout: 'pipe',
989+
stderr: 'pipe',
990+
})
991+
992+
const exitCode = await proc.exited
993+
if (exitCode !== 0) {
994+
const stderr = await new Response(proc.stderr).text()
995+
console.error(`[vue] npm install failed with exit code ${exitCode}: ${stderr}`)
996+
return undefined
997+
}
998+
999+
// Write version marker (non-fatal if this fails)
1000+
try {
1001+
await fs.writeFile(versionFile, expectedVersion)
1002+
}
1003+
catch (writeErr) {
1004+
console.warn(`[vue] Failed to write version marker file:`, writeErr instanceof Error ? writeErr.message : writeErr, '- Dependencies will be reinstalled on next run')
1005+
}
1006+
console.warn('[vue] Vue Language Server dependencies installed successfully')
1007+
}
1008+
catch (err) {
1009+
console.error('[vue] Failed to install dependencies:', err)
1010+
return undefined
1011+
}
1012+
}
1013+
1014+
// Verify all paths exist
1015+
const requiredPaths = [
1016+
{ path: vueServerPath, name: 'vue-language-server' },
1017+
{ path: tsServerPath, name: 'typescript-language-server' },
1018+
{ path: tsdkPath, name: 'TypeScript SDK' },
1019+
{ path: vuePluginPath, name: '@vue/typescript-plugin' },
1020+
]
1021+
1022+
for (const { path: filePath, name } of requiredPaths) {
1023+
try {
1024+
await fs.access(filePath)
1025+
}
1026+
catch (err) {
1027+
console.error(`[vue] Required file not found after installation: ${name} at ${filePath}:`, err instanceof Error ? err.message : err)
1028+
return undefined
1029+
}
1030+
}
1031+
1032+
return { vueServerPath, tsServerPath, tsdkPath, vuePluginPath }
1033+
}
1034+
1035+
/**
1036+
* Vue Language Server
1037+
* Uses @vue/language-server with Full Hybrid Mode
1038+
*
1039+
* Architecture (matching Python reference vue_language_server.py):
1040+
* - Vue LS runs with hybridMode: true
1041+
* - In hybrid mode, Vue LS delegates TypeScript operations to companion server
1042+
* - The companion TypeScript server uses @vue/typescript-plugin for Vue awareness
1043+
*
1044+
* Initialization options:
1045+
* - vue.hybridMode: true - Enable hybrid mode for Vue LS
1046+
* - typescript.tsdk: path to TypeScript lib directory
1047+
*/
1048+
export const VueServer: LSPServerInfo = {
1049+
id: 'vue',
1050+
extensions: ['.vue'],
1051+
root: nearestRoot(
1052+
[
1053+
'package.json',
1054+
'package-lock.json',
1055+
'bun.lockb',
1056+
'bun.lock',
1057+
'pnpm-lock.yaml',
1058+
'yarn.lock',
1059+
],
1060+
['deno.json', 'deno.jsonc'], // Exclude Deno projects
1061+
),
1062+
async spawn(root) {
1063+
// Check for node/npm availability
1064+
const node = Bun.which('node')
1065+
const npm = Bun.which('npm')
1066+
1067+
if (!node || !npm) {
1068+
console.warn('[vue] Node.js and npm are required for Vue Language Server')
1069+
return undefined
1070+
}
1071+
1072+
// Setup dependencies (npm install if needed)
1073+
const deps = await setupVueDependencies()
1074+
if (!deps) {
1075+
console.warn('[vue] Failed to setup Vue LSP dependencies. Check previous logs for details.')
1076+
return undefined
1077+
}
1078+
1079+
const { vueServerPath, tsdkPath } = deps
1080+
1081+
try {
1082+
const proc = spawn(vueServerPath, ['--stdio'], {
1083+
cwd: root,
1084+
})
1085+
1086+
attachLSPProcessHandlers(proc, 'vue')
1087+
1088+
return {
1089+
process: proc,
1090+
initialization: {
1091+
vue: {
1092+
hybridMode: true,
1093+
},
1094+
typescript: {
1095+
tsdk: tsdkPath,
1096+
},
1097+
},
1098+
}
1099+
}
1100+
catch (err) {
1101+
console.error('[vue] Failed to spawn Vue Language Server:', err)
1102+
return undefined
1103+
}
1104+
},
1105+
}
1106+
8521107
/**
8531108
* All available LSP servers
8541109
*/
8551110
export const LSP_SERVERS: LSPServerInfo[] = [
8561111
DenoServer, // Deno first, higher priority for Deno projects
1112+
VueServer, // Vue before TypeScript for .vue files
8571113
TypescriptServer,
8581114
OxlintServer,
8591115
PyrightServer,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "test-vue-app",
3+
"type": "module",
4+
"version": "1.0.0",
5+
"private": true,
6+
"description": "Test Vue project for LSP integration tests",
7+
"scripts": {
8+
"dev": "vite",
9+
"build": "vue-tsc && vite build"
10+
},
11+
"dependencies": {
12+
"vue": "^3.5.0"
13+
},
14+
"devDependencies": {
15+
"@vitejs/plugin-vue": "^5.0.0",
16+
"typescript": "^5.7.0",
17+
"vite": "^6.0.0",
18+
"vue-tsc": "^2.2.0"
19+
}
20+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
import Calculator from './components/Calculator.vue'
4+
5+
const title = ref('Vue LSP Test App')
6+
const count = ref(0)
7+
8+
const doubledCount = computed(() => count.value * 2)
9+
10+
function increment() {
11+
count.value++
12+
}
13+
</script>
14+
15+
<template>
16+
<div class="app">
17+
<h1>{{ title }}</h1>
18+
<p>Count: {{ count }}</p>
19+
<p>Doubled: {{ doubledCount }}</p>
20+
<button @click="increment">
21+
Increment
22+
</button>
23+
<Calculator />
24+
</div>
25+
</template>
26+
27+
<style scoped>
28+
.app {
29+
padding: 20px;
30+
}
31+
</style>
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<script setup lang="ts">
2+
import { ref, computed } from 'vue'
3+
import { add, subtract, multiply } from '../utils/math'
4+
5+
const a = ref(0)
6+
const b = ref(0)
7+
const operation = ref<'add' | 'subtract' | 'multiply'>('add')
8+
9+
const result = computed(() => {
10+
switch (operation.value) {
11+
case 'add':
12+
return add(a.value, b.value)
13+
case 'subtract':
14+
return subtract(a.value, b.value)
15+
case 'multiply':
16+
return multiply(a.value, b.value)
17+
default:
18+
return 0
19+
}
20+
})
21+
</script>
22+
23+
<template>
24+
<div class="calculator">
25+
<h2>Calculator</h2>
26+
<input v-model.number="a" type="number" placeholder="First number">
27+
<select v-model="operation">
28+
<option value="add">+</option>
29+
<option value="subtract">-</option>
30+
<option value="multiply">*</option>
31+
</select>
32+
<input v-model.number="b" type="number" placeholder="Second number">
33+
<p>Result: {{ result }}</p>
34+
</div>
35+
</template>
36+
37+
<style scoped>
38+
.calculator {
39+
margin-top: 20px;
40+
padding: 10px;
41+
border: 1px solid #ccc;
42+
}
43+
</style>

0 commit comments

Comments
 (0)