zed-ets-language-server 2.3.4 → 3.1.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.
package/index.js CHANGED
@@ -2,13 +2,70 @@
2
2
 
3
3
  import { spawn } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
5
7
  import { logger } from './lib/logger.js';
6
8
  import { parse } from './lib/data-parser.js';
7
- import { listHelperPaths } from './lib/lib-expander.js'
9
+ import { listHelperPaths } from './lib/lib-expander.js';
10
+ import { resolveHmsSdkPath, resolveOhosSdkPath } from './lib/sdk-discovery.js';
8
11
 
9
12
  // ETS language server path, passed by Rust extension process through environment variable
10
13
  const etsLangServerPath = process.env.ETS_LANG_SERVER;
11
14
 
15
+ // The extension installs ohos-typescript next to @arkts/language-server, so a
16
+ // usable tsdk can be derived from the server path when settings don't name one:
17
+ // <work dir>/node_modules/@arkts/language-server/bin/ets-language-server.js
18
+ // <work dir>/node_modules/ohos-typescript/lib
19
+ // @arkts/language-server v1.3+ refuses to initialize unless ets.sdkPath points at
20
+ // a directory containing ets/build-tools/ets-loader/tsconfig.json (v1.2 accepted
21
+ // any value). Provide a minimal skeleton so the server starts without a real SDK;
22
+ // ArkUI typings degrade but TypeScript-level features keep working.
23
+ function ensurePlaceholderSdk() {
24
+ const sdkDir = path.join(os.tmpdir(), 'zed-ets-empty-ohos-sdk');
25
+ const etsLoaderDir = path.join(sdkDir, 'ets', 'build-tools', 'ets-loader');
26
+ try {
27
+ fs.mkdirSync(path.join(etsLoaderDir, 'declarations'), { recursive: true });
28
+ fs.mkdirSync(path.join(sdkDir, 'ets', 'component'), { recursive: true });
29
+ const tsconfigPath = path.join(etsLoaderDir, 'tsconfig.json');
30
+ if (!fs.existsSync(tsconfigPath)) {
31
+ fs.writeFileSync(tsconfigPath, '{}\n');
32
+ }
33
+ } catch (error) {
34
+ logger.error(`Failed to prepare placeholder SDK dir ${sdkDir}: ${error.message}`);
35
+ }
36
+ return sdkDir;
37
+ }
38
+
39
+ function detectTsdk() {
40
+ if (!etsLangServerPath) return undefined;
41
+ // The extension installs ohos-typescript next to @arkts/language-server:
42
+ // <work dir>/node_modules/ohos-typescript/lib
43
+ // <work dir>/node_modules/@arkts/language-server/bin/ets-language-server.js
44
+ // Walk the ancestors of the server path and accept either layout so the
45
+ // detection also works for servers installed at other depths.
46
+ let dir = path.dirname(path.resolve(etsLangServerPath));
47
+ for (let depth = 0; depth < 8; depth++) {
48
+ for (const candidate of [
49
+ path.join(dir, 'ohos-typescript', 'lib'),
50
+ path.join(dir, 'node_modules', 'ohos-typescript', 'lib'),
51
+ ]) {
52
+ if (fs.existsSync(path.join(candidate, 'typescript.js'))) return candidate;
53
+ }
54
+ const parent = path.dirname(dir);
55
+ if (parent === dir) break;
56
+ dir = parent;
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ // A usable tsdk must contain lib/typescript.js. The native TypeScript 7 line
62
+ // ships no compiler API there, and settings inherited from another machine may
63
+ // point at a half-installed directory; forwarding such a tsdk makes the server
64
+ // hang inside `initialize` without ever answering.
65
+ function isValidTsdk(dir) {
66
+ return Boolean(dir) && fs.existsSync(path.join(dir, 'typescript.js'));
67
+ }
68
+
12
69
  async function main() {
13
70
  logger.section('🚀 ETS Language Server Wrapper');
14
71
 
@@ -39,29 +96,104 @@ async function main() {
39
96
  logger.info(`Language server process exited, exit code: ${code}, signal: ${signal}`);
40
97
  });
41
98
 
99
+ // Requests injected by this wrapper (not sent by the editor); their responses
100
+ // must not be forwarded to the editor, which never issued them.
101
+ const wrapperRequestIds = new Set();
102
+
42
103
  // Set up forwarding of serverProcess IPC messages to process.stdout
43
104
  serverProcess.on('message', (message) => {
105
+ if (message?.id !== undefined && wrapperRequestIds.delete(message.id)) {
106
+ logger.info(`Swallowed response to wrapper-injected request ${message.id}: ${JSON.stringify(message.result ?? message.error)}`);
107
+ return;
108
+ }
44
109
  // Convert IPC message to standard LSP format and send to stdout
45
110
  const messageStr = JSON.stringify(message);
46
111
  const headers = `Content-Length: ${Buffer.byteLength(messageStr)}\r\n\r\n`;
47
112
  process.stdout.write(headers + messageStr);
48
113
  });
49
114
 
50
- // Set up forwarding of process.stdin to serverProcess IPC
51
- process.stdin.setEncoding('utf8');
115
+ // Set up forwarding of process.stdin to serverProcess IPC.
116
+ // No setEncoding here: the parser needs raw bytes because LSP Content-Length
117
+ // counts bytes, not characters.
52
118
  process.stdin.on('data', (data) => parse(data, async (message) => {
53
119
  // This special ets request is required in document: https://github.com/ohosvscode/arkTS/tree/next/packages/language-server
54
120
  // When this goes wrong, ETS UI decorators and functions will be type of any
55
121
  if (message.method === 'initialize') {
56
- const { initializationOptions } = message.params;
122
+ message.params = message.params ?? {};
123
+ const initializationOptions = message.params.initializationOptions ?? {};
124
+ message.params.initializationOptions = initializationOptions;
125
+
126
+ // Zed only passes initializationOptions when the user configured
127
+ // lsp.arkts-language-server.initialization_options in settings. Fall back to
128
+ // env vars, then auto-detection, so the server starts out of the box.
129
+ if (!initializationOptions.tsdk) {
130
+ initializationOptions.tsdk = process.env.ZED_ETS_TSDK || process.env.TSDK || detectTsdk();
131
+ logger.info(`No tsdk in initializationOptions; falling back to: ${initializationOptions.tsdk}`);
132
+ }
133
+ // A native TypeScript installation may not contain the JS compiler API.
134
+ if (!isValidTsdk(initializationOptions.tsdk)) {
135
+ const fallbackTsdk = detectTsdk();
136
+ if (fallbackTsdk) {
137
+ logger.error(`tsdk ${initializationOptions.tsdk} has no lib/typescript.js; falling back to ${fallbackTsdk}`);
138
+ initializationOptions.tsdk = fallbackTsdk;
139
+ }
140
+ }
141
+
142
+ let hasRealOhosSdk = false;
143
+ try {
144
+ const resolvedSdk = resolveOhosSdkPath({
145
+ configuredPath: initializationOptions.ohosSdkPath,
146
+ env: process.env,
147
+ });
148
+ if (resolvedSdk) {
149
+ initializationOptions.ohosSdkPath = resolvedSdk.path;
150
+ hasRealOhosSdk = true;
151
+ logger.info(`Using HarmonyOS SDK from ${resolvedSdk.source}: ${resolvedSdk.path}`);
152
+ }
153
+ } catch (error) {
154
+ logger.error(error.message);
155
+ initializationOptions.ohosSdkPath = undefined;
156
+ }
57
157
 
58
- const ohos = await listHelperPaths(initializationOptions.tsdk, initializationOptions.ohosSdkPath);
158
+ // The server cannot finish `initialize` without a tsdk (it fails loading
159
+ // TypeScript and Zed reports "Failed to start language server").
160
+ if (!initializationOptions.tsdk) {
161
+ logger.error(`No tsdk in LSP settings, env (ZED_ETS_TSDK/TSDK), or next to ${etsLangServerPath}; forwarding initialize as-is, the server will likely fail to start.`);
162
+ serverProcess.send(message);
163
+ return;
164
+ }
59
165
 
60
- // Send both `ohos` and `ets` keys to stay compatible with
61
- // @arkts/language-server v1.2.x (uses `ohos`) and v1.3.x+ (uses `ets`).
166
+ if (!initializationOptions.ohosSdkPath) {
167
+ initializationOptions.ohosSdkPath = ensurePlaceholderSdk();
168
+ logger.error('No valid HarmonyOS SDK was found in LSP settings, environment variables, or standard DevEco Studio locations; using a placeholder SDK skeleton. ArkUI and @kit types will be unavailable until lsp.arkts-language-server.initialization_options.ohosSdkPath is set in Zed settings.');
169
+ }
170
+
171
+ try {
172
+ const resolvedHmsSdk = resolveHmsSdkPath({
173
+ configuredPath: initializationOptions.hmsSdkPath,
174
+ ohosSdkPath: hasRealOhosSdk ? initializationOptions.ohosSdkPath : undefined,
175
+ env: process.env,
176
+ });
177
+ if (resolvedHmsSdk) {
178
+ initializationOptions.hmsSdkPath = resolvedHmsSdk.path;
179
+ logger.info(`Using HMS SDK from ${resolvedHmsSdk.source}: ${resolvedHmsSdk.path}`);
180
+ }
181
+ } catch (error) {
182
+ logger.error(error.message);
183
+ initializationOptions.hmsSdkPath = undefined;
184
+ }
185
+
186
+ const ohos = await listHelperPaths(
187
+ initializationOptions.tsdk,
188
+ initializationOptions.ohosSdkPath,
189
+ initializationOptions.hmsSdkPath,
190
+ );
191
+
192
+ // Current servers read `ets`; retain the `ohos` alias for clients that
193
+ // still inspect the older configuration key.
62
194
  const etsSpecialRequest = {
63
195
  jsonrpc: '2.0',
64
- id: Date.now(),
196
+ id: `zed-ets-wrapper-${Date.now()}`,
65
197
  method: 'ets/waitForEtsConfigurationChangedRequested',
66
198
  params: {
67
199
  typescript: {
@@ -81,7 +213,8 @@ async function main() {
81
213
 
82
214
  logger.info(JSON.stringify(generalInitRequest));
83
215
  logger.info(JSON.stringify(etsSpecialRequest));
84
-
216
+
217
+ wrapperRequestIds.add(etsSpecialRequest.id);
85
218
  serverProcess.send(generalInitRequest);
86
219
  serverProcess.send(etsSpecialRequest);
87
220
  return;
@@ -1,38 +1,43 @@
1
1
  import { logger } from './logger.js';
2
2
 
3
- let stdinBuffer = '';
3
+ let stdinBuffer = Buffer.alloc(0);
4
4
 
5
5
  export function parse(data, callback) {
6
- stdinBuffer += data.toString();
6
+ // LSP Content-Length is measured in bytes, not characters, so all parsing
7
+ // must operate on a Buffer. Convert string input to a Buffer if necessary
8
+ // (happens when stdin.setEncoding('utf8') is used, which yields strings).
9
+ const dataBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8');
10
+ stdinBuffer = Buffer.concat([stdinBuffer, dataBuffer]);
7
11
 
8
12
  while (true) {
9
- // Find Content-Length header
10
- const lengthMatch = stdinBuffer.match(/Content-Length: (\d+)\r\n/);
11
- if (!lengthMatch) break;
12
-
13
- const contentLength = Number.parseInt(lengthMatch[1]);
14
13
  const headerEnd = stdinBuffer.indexOf('\r\n\r\n');
15
-
16
14
  if (headerEnd === -1) break;
17
15
 
16
+ const headerPart = stdinBuffer.subarray(0, headerEnd).toString('utf8');
17
+ const lengthMatch = headerPart.match(/Content-Length: (\d+)/);
18
+ if (!lengthMatch) break;
19
+
20
+ const contentLength = Number.parseInt(lengthMatch[1]);
18
21
  const messageStart = headerEnd + 4;
19
22
  const messageEnd = messageStart + contentLength;
20
23
 
24
+ // Content-Length counts bytes, so compare against the buffer's byte length.
21
25
  if (stdinBuffer.length < messageEnd) break;
22
26
 
23
- // Extract message
24
- const messageJson = stdinBuffer.substring(messageStart, messageEnd);
25
- stdinBuffer = stdinBuffer.substring(messageEnd);
27
+ const messageJson = stdinBuffer.subarray(messageStart, messageEnd).toString('utf8');
28
+ stdinBuffer = stdinBuffer.subarray(messageEnd);
26
29
 
27
30
  try {
28
31
  const message = JSON.parse(messageJson);
29
32
  callback(message);
30
33
  } catch (error) {
31
34
  logger.error(`Error parsing message: ${error.message} ${error.stack} ${messageJson}`);
35
+ // Clear buffer on parse error to prevent corruption from leftover data
36
+ stdinBuffer = Buffer.alloc(0);
32
37
  }
33
38
  }
34
39
  }
35
40
 
36
41
  export function clearBuffer() {
37
- stdinBuffer = '';
42
+ stdinBuffer = Buffer.alloc(0);
38
43
  }
@@ -73,22 +73,30 @@ export async function listLibs(dirPath) {
73
73
  return await getFilesByPattern(dirPath, /d\.ts$/i);
74
74
  }
75
75
 
76
- export async function listHelperPaths(tsDir, harmonyDir) {
76
+ export async function listHelperPaths(tsDir, harmonyDir, hmsDir) {
77
77
  const etsComponentPath = path.join(harmonyDir, '/ets/component');
78
78
  const etsLoaderConfigPath = path.join(harmonyDir, '/ets/build-tools/ets-loader/tsconfig.json');
79
79
  const etsLoaderPath = path.join(harmonyDir, '/ets/build-tools/ets-loader');
80
80
  const etsLoaderLibs = await listLibs(path.join(etsLoaderPath, '/declarations'));
81
81
 
82
+ const modulePaths = ["./api/*", "./kits/*", "./arkts/*"];
83
+ if (hmsDir) {
84
+ modulePaths.push(path.join(hmsDir, 'ets', 'api', '*'));
85
+ modulePaths.push(path.join(hmsDir, 'ets', 'kits', '*'));
86
+ }
87
+
82
88
  return {
83
89
  sdkPath: harmonyDir,
90
+ hmsPath: hmsDir,
91
+ hmsSdkPath: hmsDir,
84
92
  etsComponentPath,
85
93
  etsLoaderConfigPath,
86
94
  etsLoaderPath,
87
95
  baseUrl: path.join(harmonyDir, '/ets'),
88
96
  lib: [...(await listLibs(tsDir)), ...(await listLibs(etsComponentPath)), ...etsLoaderLibs],
89
97
  "paths": {
90
- "*": ["./api/*", "./kits/*", "./arkts/*"],
98
+ "*": modulePaths,
91
99
  "@internal/full/*": ["./api/@internal/full/*"]
92
100
  },
93
101
  };
94
- }
102
+ }
@@ -0,0 +1,182 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const ENVIRONMENT_VARIABLES = [
6
+ 'ZED_ETS_OHOS_SDK_PATH',
7
+ 'OHOS_SDK_PATH',
8
+ 'HARMONYOS_SDK_HOME',
9
+ 'OPENHARMONY_SDK_HOME',
10
+ 'DEVECO_SDK_HOME',
11
+ ];
12
+
13
+ const REQUIRED_SDK_ENTRIES = [
14
+ { relativePath: path.join('ets', 'kits'), type: 'directory' },
15
+ { relativePath: path.join('ets', 'api'), type: 'directory' },
16
+ {
17
+ relativePath: path.join('ets', 'build-tools', 'ets-loader', 'tsconfig.json'),
18
+ type: 'file',
19
+ },
20
+ ];
21
+
22
+ const REQUIRED_HMS_ENTRIES = [
23
+ { relativePath: path.join('ets', 'kits'), type: 'directory' },
24
+ { relativePath: path.join('ets', 'api'), type: 'directory' },
25
+ ];
26
+
27
+ function hasRequiredEntries(candidate, entries) {
28
+ try {
29
+ return entries.every(({ relativePath, type }) => {
30
+ const entryPath = path.join(candidate, relativePath);
31
+ const stats = fs.statSync(entryPath);
32
+ const accessMode = type === 'directory'
33
+ ? fs.constants.R_OK | fs.constants.X_OK
34
+ : fs.constants.R_OK;
35
+ fs.accessSync(entryPath, accessMode);
36
+ return type === 'directory' ? stats.isDirectory() : stats.isFile();
37
+ });
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function isValidOhosSdkPath(candidate) {
44
+ return hasRequiredEntries(candidate, REQUIRED_SDK_ENTRIES);
45
+ }
46
+
47
+ function isValidHmsSdkPath(candidate) {
48
+ return hasRequiredEntries(candidate, REQUIRED_HMS_ENTRIES);
49
+ }
50
+
51
+ function expandHome(candidate, homeDirectory) {
52
+ return candidate.startsWith('~/')
53
+ ? path.join(homeDirectory, candidate.slice(2))
54
+ : candidate;
55
+ }
56
+
57
+ function normalizeCandidate(candidate, homeDirectory = os.homedir()) {
58
+ if (!candidate) return undefined;
59
+
60
+ const expanded = expandHome(candidate, homeDirectory);
61
+ const normalized = path.resolve(expanded);
62
+ const possibleRoots = [
63
+ normalized,
64
+ path.join(normalized, 'openharmony'),
65
+ path.join(normalized, 'default', 'openharmony'),
66
+ ];
67
+
68
+ try {
69
+ const versionDirectories = fs
70
+ .readdirSync(normalized, { withFileTypes: true })
71
+ .filter((entry) => entry.isDirectory() && entry.name !== 'default')
72
+ .map((entry) => entry.name)
73
+ .sort((left, right) => right.localeCompare(left, undefined, { numeric: true }));
74
+ for (const versionDirectory of versionDirectories) {
75
+ possibleRoots.push(path.join(normalized, versionDirectory, 'openharmony'));
76
+ possibleRoots.push(path.join(normalized, versionDirectory));
77
+ }
78
+ } catch {
79
+ // Missing or unreadable candidates are ignored during discovery.
80
+ }
81
+
82
+ return possibleRoots.find(isValidOhosSdkPath);
83
+ }
84
+
85
+ function defaultCandidates(platform, homeDirectory, env) {
86
+ const candidates = [];
87
+ const studioHome = env.DEVECO_STUDIO_HOME || env.DEVECO_HOME;
88
+ if (studioHome) {
89
+ candidates.push(path.join(studioHome, 'sdk'));
90
+ candidates.push(path.join(studioHome, 'Contents', 'sdk'));
91
+ }
92
+
93
+ if (platform === 'darwin') {
94
+ candidates.push(path.join(homeDirectory, 'Library', 'OpenHarmony', 'Sdk'));
95
+ candidates.push(path.join(homeDirectory, 'Library', 'Huawei', 'Sdk'));
96
+ candidates.push('/Applications/DevEco-Studio.app/Contents/sdk');
97
+ candidates.push(path.join(homeDirectory, 'Applications', 'DevEco-Studio.app', 'Contents', 'sdk'));
98
+ } else if (platform === 'win32') {
99
+ if (env.ProgramFiles) {
100
+ candidates.push(path.join(env.ProgramFiles, 'Huawei', 'DevEco Studio', 'sdk'));
101
+ }
102
+ if (env.LOCALAPPDATA) {
103
+ candidates.push(path.join(env.LOCALAPPDATA, 'Huawei', 'DevEcoStudio', 'sdk'));
104
+ }
105
+ } else {
106
+ candidates.push('/opt/DevEco-Studio/sdk');
107
+ candidates.push(path.join(homeDirectory, 'DevEco-Studio', 'sdk'));
108
+ }
109
+
110
+ return candidates;
111
+ }
112
+
113
+ export function resolveOhosSdkPath({
114
+ configuredPath,
115
+ env = process.env,
116
+ platform = process.platform,
117
+ homeDirectory = os.homedir(),
118
+ candidates,
119
+ } = {}) {
120
+ if (configuredPath) {
121
+ const resolved = normalizeCandidate(configuredPath, homeDirectory);
122
+ if (!resolved) {
123
+ throw new Error(
124
+ `Invalid HarmonyOS SDK path: ${configuredPath}. Expected ets/kits, ets/api, and ets/build-tools/ets-loader/tsconfig.json.`,
125
+ );
126
+ }
127
+ return { path: resolved, source: 'settings' };
128
+ }
129
+
130
+ for (const variableName of ENVIRONMENT_VARIABLES) {
131
+ const resolved = normalizeCandidate(env[variableName], homeDirectory);
132
+ if (resolved) {
133
+ return { path: resolved, source: variableName };
134
+ }
135
+ }
136
+
137
+ const searchCandidates = candidates ?? defaultCandidates(platform, homeDirectory, env);
138
+ for (const candidate of searchCandidates) {
139
+ const resolved = normalizeCandidate(candidate, homeDirectory);
140
+ if (resolved) {
141
+ return { path: resolved, source: 'auto-detected' };
142
+ }
143
+ }
144
+
145
+ return undefined;
146
+ }
147
+
148
+ export function resolveHmsSdkPath({
149
+ configuredPath,
150
+ ohosSdkPath,
151
+ env = process.env,
152
+ homeDirectory = os.homedir(),
153
+ } = {}) {
154
+ const candidates = configuredPath
155
+ ? [configuredPath, path.join(configuredPath, 'hms'), path.join(configuredPath, 'default', 'hms')]
156
+ : [
157
+ env.ZED_ETS_HMS_SDK_PATH,
158
+ env.HMS_SDK_PATH,
159
+ ohosSdkPath && path.join(path.dirname(ohosSdkPath), 'hms'),
160
+ ];
161
+
162
+ for (const candidate of candidates) {
163
+ if (!candidate) continue;
164
+ const normalized = path.resolve(expandHome(candidate, homeDirectory));
165
+ if (isValidHmsSdkPath(normalized)) {
166
+ return {
167
+ path: normalized,
168
+ source: configuredPath ? 'settings' : 'auto-detected',
169
+ };
170
+ }
171
+ }
172
+
173
+ if (configuredPath) {
174
+ throw new Error(
175
+ `Invalid HMS SDK path: ${configuredPath}. Expected ets/kits and ets/api.`,
176
+ );
177
+ }
178
+
179
+ return undefined;
180
+ }
181
+
182
+ export { isValidHmsSdkPath, isValidOhosSdkPath };