@x18n/lsp 0.0.2 → 0.0.3

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/dist/cli.d.mts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/cli.mjs ADDED
@@ -0,0 +1,8 @@
1
+ import { t as setupServer } from "./src.mjs";
2
+ import { ProposedFeatures, createConnection } from "vscode-languageserver/node";
3
+ //#region src/cli.ts
4
+ const connection = createConnection(ProposedFeatures.all);
5
+ setupServer(connection);
6
+ connection.listen();
7
+ //#endregion
8
+ export {};
@@ -0,0 +1,170 @@
1
+ import { OverlayHost, Project } from "@x18n/core";
2
+ import { NodeHost } from "@x18n/node";
3
+ import { Connection } from "vscode-languageserver";
4
+ //#region src/protocol.d.ts
5
+ /**
6
+ * The `x18n/*` custom protocol. Shapes are versioned with the JSON contract (`contractVersion` in `@x18n/core`).
7
+ * Positions and ranges use LSP conventions (zero-based lines, UTF-16 characters).
8
+ */
9
+ export interface Position {
10
+ readonly line: number;
11
+ readonly character: number;
12
+ }
13
+ export interface Range {
14
+ readonly start: Position;
15
+ readonly end: Position;
16
+ }
17
+ export interface Location {
18
+ readonly uri: string;
19
+ readonly range: Range;
20
+ }
21
+ export interface DocumentParams {
22
+ readonly uri: string;
23
+ }
24
+ export interface PositionParams extends DocumentParams {
25
+ readonly position: Position;
26
+ }
27
+ export interface ProjectInfo {
28
+ readonly name: string;
29
+ readonly root: string;
30
+ readonly sourceLocale: string;
31
+ readonly locales: readonly {
32
+ readonly code: string;
33
+ readonly displayName: string | undefined;
34
+ readonly flag: string | undefined;
35
+ }[];
36
+ readonly displayLocale: string;
37
+ readonly keys: number;
38
+ }
39
+ export interface KeyAtResult {
40
+ readonly project: string;
41
+ readonly key: string;
42
+ readonly type: "key" | "subtree";
43
+ readonly range: Range;
44
+ }
45
+ export interface TreeParams {
46
+ readonly project?: string | undefined;
47
+ /** Parent key; omitted for the roots. */
48
+ readonly parent?: string | undefined;
49
+ }
50
+ export interface TreeNode {
51
+ readonly key: string;
52
+ readonly label: string;
53
+ readonly leaf: boolean;
54
+ readonly translated: number;
55
+ readonly total: number;
56
+ /** Display-locale value for leaves. */
57
+ readonly value: string | undefined;
58
+ }
59
+ export interface LocaleProgress {
60
+ readonly locale: string;
61
+ readonly translated: number;
62
+ readonly total: number;
63
+ readonly missing: readonly string[];
64
+ readonly empty: readonly string[];
65
+ }
66
+ export interface ProgressResult {
67
+ readonly projects: readonly {
68
+ readonly name: string;
69
+ readonly locales: readonly LocaleProgress[];
70
+ }[];
71
+ }
72
+ export interface FileKey {
73
+ readonly key: string;
74
+ readonly range: Range;
75
+ readonly value: string | undefined;
76
+ readonly missing: boolean;
77
+ readonly dynamic: boolean;
78
+ }
79
+ export interface FileKeysResult {
80
+ readonly keys: readonly FileKey[];
81
+ readonly hardStrings: readonly {
82
+ readonly text: string;
83
+ readonly range: Range;
84
+ }[];
85
+ }
86
+ export interface UsageReportResult {
87
+ readonly unused: readonly {
88
+ readonly project: string;
89
+ readonly key: string;
90
+ readonly location: Location | undefined;
91
+ }[];
92
+ readonly missing: readonly {
93
+ readonly project: string;
94
+ readonly key: string;
95
+ readonly locations: readonly Location[];
96
+ }[];
97
+ }
98
+ export interface Decoration {
99
+ readonly range: Range;
100
+ readonly key: string;
101
+ readonly value: string | undefined;
102
+ readonly state: "ok" | "missing" | "dynamic";
103
+ }
104
+ export interface DecorationsResult {
105
+ readonly locale: string;
106
+ readonly decorations: readonly Decoration[];
107
+ }
108
+ export interface SetDisplayLocaleParams {
109
+ readonly locale: string | undefined;
110
+ }
111
+ export interface OpenUiParams {
112
+ readonly project?: string | undefined;
113
+ }
114
+ export interface OpenUiResult {
115
+ readonly url: string;
116
+ readonly token: string;
117
+ }
118
+ export interface TranslatePromptParams {
119
+ readonly project?: string | undefined;
120
+ readonly locale: string;
121
+ readonly keys?: readonly string[] | undefined;
122
+ }
123
+ export interface ChangedParams {
124
+ readonly project: string;
125
+ readonly revision: number;
126
+ readonly keys: readonly string[];
127
+ }
128
+ export interface Methods {
129
+ readonly projects: "x18n/projects";
130
+ readonly keyAt: "x18n/keyAt";
131
+ readonly tree: "x18n/tree";
132
+ readonly progress: "x18n/progress";
133
+ readonly fileKeys: "x18n/fileKeys";
134
+ readonly usageReport: "x18n/usageReport";
135
+ readonly decorations: "x18n/decorations";
136
+ readonly setDisplayLocale: "x18n/setDisplayLocale";
137
+ readonly openUi: "x18n/openUi";
138
+ readonly translatePrompt: "x18n/translatePrompt";
139
+ readonly changed: "x18n/changed";
140
+ }
141
+ export declare const methods: Methods;
142
+ /** Commands for `workspace/executeCommand`. */
143
+ export interface Commands {
144
+ readonly set: "x18n.set";
145
+ readonly delete: "x18n.delete";
146
+ readonly renamePrefix: "x18n.renamePrefix";
147
+ readonly extract: "x18n.extract";
148
+ /** `{ uri: <folder> }` -> `{ files: { path, content }[], notes, removable }`; nothing is written. */
149
+ readonly migrateI18nAlly: "x18n.migrateI18nAlly";
150
+ }
151
+ export declare const commands: Commands;
152
+ //#endregion
153
+ //#region src/server.d.ts
154
+ interface ServerProject {
155
+ readonly name: string;
156
+ /** Absolute root. */
157
+ readonly root: string;
158
+ readonly project: Project;
159
+ readonly overlay: OverlayHost;
160
+ }
161
+ interface ServerOptions {
162
+ /** Loads the projects of a workspace folder. Default: `@x18n/node` with an overlay host. */
163
+ readonly loadProjects?: ((folder: string, wrapHost: (host: NodeHost) => OverlayHost) => Promise<readonly Omit<ServerProject, "overlay">[]>) | undefined;
164
+ /** Starts the UI server for a project (`x18n/openUi`). */
165
+ readonly openUi?: ((project: ServerProject) => Promise<OpenUiResult>) | undefined;
166
+ }
167
+ /** Registers the x18n handlers on a connection. The caller owns transport and `listen()`. */
168
+ export declare function setupServer(connection: Connection, options?: ServerOptions): void;
169
+ //#endregion
170
+ export type { ServerOptions, ServerProject };
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ export { n as commands, r as methods, t as setupServer } from "./src.mjs";
package/dist/src.mjs ADDED
@@ -0,0 +1,870 @@
1
+ import { relative, resolve, sep } from "node:path";
2
+ import { fileURLToPath, pathToFileURL } from "node:url";
3
+ import { extractItems, findCandidates } from "@x18n/builtins/extractors";
4
+ import { createLineIndex, createOverlayHost, isX18nError, renderPrompt } from "@x18n/core";
5
+ import { loadWorkspace, migrateI18nAlly } from "@x18n/node";
6
+ import { CodeActionKind, CompletionItemKind, DiagnosticSeverity, DidChangeWatchedFilesNotification, FileChangeType, ResponseError, SymbolKind, TextDocumentSyncKind, TextDocuments } from "vscode-languageserver";
7
+ import { TextDocument } from "vscode-languageserver-textdocument";
8
+ //#region src/protocol.ts
9
+ const methods = {
10
+ projects: "x18n/projects",
11
+ keyAt: "x18n/keyAt",
12
+ tree: "x18n/tree",
13
+ progress: "x18n/progress",
14
+ fileKeys: "x18n/fileKeys",
15
+ usageReport: "x18n/usageReport",
16
+ decorations: "x18n/decorations",
17
+ setDisplayLocale: "x18n/setDisplayLocale",
18
+ openUi: "x18n/openUi",
19
+ translatePrompt: "x18n/translatePrompt",
20
+ changed: "x18n/changed"
21
+ };
22
+ const commands = {
23
+ set: "x18n.set",
24
+ delete: "x18n.delete",
25
+ renamePrefix: "x18n.renamePrefix",
26
+ extract: "x18n.extract",
27
+ migrateI18nAlly: "x18n.migrateI18nAlly"
28
+ };
29
+ //#endregion
30
+ //#region package.json
31
+ var version = "0.0.3";
32
+ //#endregion
33
+ //#region src/server.ts
34
+ const severities = {
35
+ error: DiagnosticSeverity.Error,
36
+ warning: DiagnosticSeverity.Warning,
37
+ info: DiagnosticSeverity.Information,
38
+ hint: DiagnosticSeverity.Hint
39
+ };
40
+ function truncate(text, max) {
41
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
42
+ }
43
+ function display(value) {
44
+ return value === void 0 ? void 0 : typeof value === "string" ? value : JSON.stringify(value);
45
+ }
46
+ const posix = (path) => sep === "\\" ? path.replace(/\\/g, "/") : path;
47
+ var Server = class {
48
+ connection;
49
+ options;
50
+ documents = new TextDocuments(TextDocument);
51
+ projects = [];
52
+ published = /* @__PURE__ */ new Map();
53
+ timers = /* @__PURE__ */ new Map();
54
+ settings = {
55
+ annotationMaxLength: 40,
56
+ displayLocale: void 0,
57
+ codeLens: false
58
+ };
59
+ ready = Promise.resolve();
60
+ folders = [];
61
+ watchRegistration = false;
62
+ constructor(connection, options) {
63
+ this.connection = connection;
64
+ this.options = options;
65
+ }
66
+ /** ---- Paths and positions ---- */
67
+ locate(uri) {
68
+ if (!uri.startsWith("file:")) return void 0;
69
+ const file = fileURLToPath(uri);
70
+ let best;
71
+ for (const entry of this.projects) {
72
+ const rel = relative(entry.root, file);
73
+ if (!rel.startsWith("..") && !resolve(entry.root, rel).startsWith("..") && (!best || entry.root.length > best.root.length)) best = entry;
74
+ }
75
+ return best ? {
76
+ entry: best,
77
+ path: posix(relative(best.root, file))
78
+ } : void 0;
79
+ }
80
+ uriOf(entry, path) {
81
+ return pathToFileURL(resolve(entry.root, path)).href;
82
+ }
83
+ text(entry, path) {
84
+ return this.documents.get(this.uriOf(entry, path))?.getText() ?? entry.project.text(path) ?? "";
85
+ }
86
+ range(entry, path, range) {
87
+ const index = createLineIndex(this.text(entry, path));
88
+ return {
89
+ start: index.position(range.start),
90
+ end: index.position(range.end)
91
+ };
92
+ }
93
+ offset(uri, position, entry, path) {
94
+ const document = this.documents.get(uri);
95
+ return document ? document.offsetAt(position) : createLineIndex(this.text(entry, path)).offset(position);
96
+ }
97
+ location(entry, path, range) {
98
+ return {
99
+ uri: this.uriOf(entry, path),
100
+ range: this.range(entry, path, range)
101
+ };
102
+ }
103
+ at(params) {
104
+ const found = this.locate(params.textDocument.uri);
105
+ if (!found) return void 0;
106
+ return {
107
+ ...found,
108
+ offset: this.offset(params.textDocument.uri, params.position, found.entry, found.path)
109
+ };
110
+ }
111
+ displayLocale(entry) {
112
+ const wanted = this.settings.displayLocale;
113
+ return wanted && entry.project.locales.includes(wanted) ? wanted : entry.project.sourceLocale;
114
+ }
115
+ resourcePath(entry, id) {
116
+ return entry.project.resources.find((r) => r.id === id)?.path ?? id;
117
+ }
118
+ /** ---- Lifecycle ---- */
119
+ async load() {
120
+ for (const folder of this.folders) {
121
+ const overlays = [];
122
+ const wrap = (host) => {
123
+ const overlay = createOverlayHost(host);
124
+ overlays.push(overlay);
125
+ return overlay;
126
+ };
127
+ (this.options.loadProjects ? await this.options.loadProjects(folder, wrap) : (await loadWorkspace({
128
+ cwd: folder,
129
+ wrapHost: wrap
130
+ }).catch((error) => {
131
+ this.connection.window.showWarningMessage(`x18n: ${error instanceof Error ? error.message : String(error)}`);
132
+ return { projects: [] };
133
+ })).projects).forEach((item, i) => {
134
+ const overlay = overlays[i];
135
+ if (overlay && !this.projects.some((p) => p.root === item.root)) this.projects.push({
136
+ name: item.name,
137
+ root: item.root,
138
+ project: item.project,
139
+ overlay
140
+ });
141
+ });
142
+ }
143
+ for (const entry of this.projects) entry.project.onChange((change) => {
144
+ this.connection.sendNotification(methods.changed, {
145
+ project: entry.name,
146
+ revision: change.revision,
147
+ keys: change.keys
148
+ });
149
+ this.publishDiagnostics();
150
+ });
151
+ }
152
+ async initialize(params) {
153
+ const folders = params.workspaceFolders?.map((folder) => fileURLToPath(folder.uri)) ?? (params.rootUri ? [fileURLToPath(params.rootUri)] : []);
154
+ this.folders = folders;
155
+ this.watchRegistration = params.capabilities.workspace?.didChangeWatchedFiles?.dynamicRegistration === true;
156
+ const settings = params.initializationOptions;
157
+ if (typeof settings?.displayLocale === "string") this.settings.displayLocale = settings.displayLocale;
158
+ if (typeof settings?.annotationMaxLength === "number") this.settings.annotationMaxLength = settings.annotationMaxLength;
159
+ if (typeof settings?.codeLens === "boolean") this.settings.codeLens = settings.codeLens;
160
+ return {
161
+ serverInfo: {
162
+ name: "x18n",
163
+ version
164
+ },
165
+ capabilities: {
166
+ textDocumentSync: TextDocumentSyncKind.Incremental,
167
+ definitionProvider: true,
168
+ implementationProvider: true,
169
+ referencesProvider: true,
170
+ renameProvider: { prepareProvider: true },
171
+ hoverProvider: true,
172
+ inlayHintProvider: true,
173
+ completionProvider: { triggerCharacters: [
174
+ ".",
175
+ "'",
176
+ "\"",
177
+ "`",
178
+ ":"
179
+ ] },
180
+ codeActionProvider: { codeActionKinds: [CodeActionKind.QuickFix, CodeActionKind.RefactorExtract] },
181
+ documentSymbolProvider: true,
182
+ workspaceSymbolProvider: true,
183
+ codeLensProvider: { resolveProvider: false },
184
+ executeCommandProvider: { commands: Object.values(commands) }
185
+ }
186
+ };
187
+ }
188
+ async initialized() {
189
+ const progress = await this.connection.window.createWorkDoneProgress().catch(() => void 0);
190
+ progress?.begin("x18n", 0, "Indexing keys and usages", false);
191
+ this.ready = this.load().finally(() => progress?.done());
192
+ await this.ready;
193
+ this.publishDiagnostics();
194
+ if (this.watchRegistration) this.connection.client.register(DidChangeWatchedFilesNotification.type, { watchers: [{ globPattern: "**/*" }] }).catch(() => void 0);
195
+ }
196
+ /** ---- Document sync ---- */
197
+ schedule(uri, run) {
198
+ clearTimeout(this.timers.get(uri));
199
+ this.timers.set(uri, setTimeout(() => {
200
+ this.timers.delete(uri);
201
+ run();
202
+ }, 100));
203
+ }
204
+ async changed(uri, text) {
205
+ await this.ready;
206
+ const found = this.locate(uri);
207
+ if (!found) return;
208
+ if (text === void 0) found.entry.overlay.clearOverlay(found.path);
209
+ else found.entry.overlay.setOverlay(found.path, text);
210
+ await found.entry.project.update([{
211
+ type: "change",
212
+ path: found.path
213
+ }]);
214
+ }
215
+ async watched(changes) {
216
+ await this.ready;
217
+ const byProject = /* @__PURE__ */ new Map();
218
+ for (const change of changes) {
219
+ const found = this.locate(change.uri);
220
+ if (!found || found.path.startsWith("node_modules/") || found.path.includes("/node_modules/") || this.documents.get(change.uri)) continue;
221
+ const list = byProject.get(found.entry) ?? [];
222
+ byProject.set(found.entry, list);
223
+ list.push({
224
+ type: change.type === FileChangeType.Created ? "create" : change.type === FileChangeType.Deleted ? "delete" : "change",
225
+ path: found.path
226
+ });
227
+ }
228
+ for (const [entry, events] of byProject) await entry.project.update(events);
229
+ }
230
+ publishDiagnostics() {
231
+ for (const entry of this.projects) {
232
+ const byFile = /* @__PURE__ */ new Map();
233
+ for (const d of entry.project.analyze().diagnostics) {
234
+ if (!d.location) continue;
235
+ const uri = this.uriOf(entry, d.location.path);
236
+ const list = byFile.get(uri) ?? [];
237
+ byFile.set(uri, list);
238
+ list.push({
239
+ range: this.range(entry, d.location.path, d.location.range),
240
+ severity: severities[d.severity],
241
+ code: d.code,
242
+ source: "x18n",
243
+ message: d.message,
244
+ data: {
245
+ key: d.key,
246
+ locale: d.locale
247
+ }
248
+ });
249
+ }
250
+ const previous = this.published.get(entry.root) ?? /* @__PURE__ */ new Set();
251
+ for (const uri of previous) if (!byFile.has(uri)) this.connection.sendDiagnostics({
252
+ uri,
253
+ diagnostics: []
254
+ });
255
+ for (const [uri, diagnostics] of byFile) this.connection.sendDiagnostics({
256
+ uri,
257
+ diagnostics
258
+ });
259
+ this.published.set(entry.root, new Set(byFile.keys()));
260
+ }
261
+ }
262
+ /** ---- Symbols ---- */
263
+ entryLocation(entry, node) {
264
+ return this.location(entry, node.path, node.range.key);
265
+ }
266
+ async definition(params) {
267
+ await this.ready;
268
+ const at = this.at(params);
269
+ const target = at && at.entry.project.keyAt(at.path, at.offset);
270
+ if (!at || !target) return null;
271
+ const nodes = at.entry.project.locations(target.key);
272
+ const source = nodes.find((node) => node.locale === at.entry.project.sourceLocale) ?? nodes[0];
273
+ return source ? [this.entryLocation(at.entry, source)] : null;
274
+ }
275
+ async implementation(params) {
276
+ await this.ready;
277
+ const at = this.at(params);
278
+ const target = at && at.entry.project.keyAt(at.path, at.offset);
279
+ if (!at || !target) return null;
280
+ return at.entry.project.locations(target.key).map((node) => this.entryLocation(at.entry, node));
281
+ }
282
+ async references(params) {
283
+ await this.ready;
284
+ const at = this.at(params);
285
+ const target = at && at.entry.project.keyAt(at.path, at.offset);
286
+ if (!at || !target) return null;
287
+ const { project } = at.entry;
288
+ const usages = (target.type === "subtree" ? project.usages({ prefix: target.key }) : project.usages({ key: target.key })).map((m) => this.location(at.entry, m.usage.path, m.usage.range));
289
+ const entries = params.context.includeDeclaration || target.source === "usage" ? project.locations(target.key).map((node) => this.entryLocation(at.entry, node)) : [];
290
+ return [...usages, ...entries];
291
+ }
292
+ async prepareRename(params) {
293
+ await this.ready;
294
+ const at = this.at(params);
295
+ const target = at && at.entry.project.keyAt(at.path, at.offset);
296
+ if (!at || !target) return null;
297
+ return {
298
+ range: this.range(at.entry, at.path, target.range),
299
+ placeholder: target.key
300
+ };
301
+ }
302
+ async rename(params) {
303
+ await this.ready;
304
+ const at = this.at(params);
305
+ const target = at && at.entry.project.keyAt(at.path, at.offset);
306
+ if (!at || !target) return null;
307
+ const { project } = at.entry;
308
+ const to = project.parseKey(params.newName.trim());
309
+ const op = target.type === "subtree" ? {
310
+ type: "rename-prefix",
311
+ from: target.key,
312
+ to
313
+ } : {
314
+ type: "rename",
315
+ from: target.key,
316
+ to
317
+ };
318
+ const plan = this.plan(project, [op]);
319
+ if (plan.warnings.length) this.connection.window.showWarningMessage(`x18n: ${plan.warnings.join(" ")}`);
320
+ return this.workspaceEdit(at.entry, plan);
321
+ }
322
+ plan(project, ops) {
323
+ try {
324
+ return project.plan(ops);
325
+ } catch (error) {
326
+ throw new ResponseError(1, isX18nError(error) ? `${error.message}. ${error.hint}` : String(error));
327
+ }
328
+ }
329
+ workspaceEdit(entry, plan) {
330
+ const documentChanges = [];
331
+ const toEdit = (change) => {
332
+ const uri = this.uriOf(entry, change.path);
333
+ if (change.type === "create") {
334
+ documentChanges.push({
335
+ kind: "create",
336
+ uri,
337
+ options: { ignoreIfExists: true }
338
+ });
339
+ documentChanges.push({
340
+ textDocument: {
341
+ uri,
342
+ version: null
343
+ },
344
+ edits: [{
345
+ range: {
346
+ start: {
347
+ line: 0,
348
+ character: 0
349
+ },
350
+ end: {
351
+ line: 0,
352
+ character: 0
353
+ }
354
+ },
355
+ newText: change.content
356
+ }]
357
+ });
358
+ } else if (change.type === "delete") documentChanges.push({
359
+ kind: "delete",
360
+ uri
361
+ });
362
+ else documentChanges.push({
363
+ textDocument: {
364
+ uri,
365
+ version: this.documents.get(uri)?.version ?? null
366
+ },
367
+ edits: change.edits.map((edit) => ({
368
+ range: this.range(entry, change.path, edit.range),
369
+ newText: edit.text
370
+ }))
371
+ });
372
+ };
373
+ plan.changes.forEach(toEdit);
374
+ return { documentChanges };
375
+ }
376
+ /** ---- Editor features ---- */
377
+ async hover(params) {
378
+ await this.ready;
379
+ const at = this.at(params);
380
+ const target = at && at.entry.project.keyAt(at.path, at.offset);
381
+ if (!at || !target) return null;
382
+ const { project } = at.entry;
383
+ const lines = [`**x18n** \`${target.key}\``, ""];
384
+ if (target.type === "key") {
385
+ lines.push("| | |", "| --- | --- |");
386
+ for (const locale of project.locales) {
387
+ const value = display(project.lookup(target.key, locale)?.value);
388
+ lines.push(`| ${project.localeInfo(locale).flag ?? ""} ${locale} | ${value === void 0 ? "_missing_" : value === "" ? "_empty_" : value.replace(/\|/g, "\\|").replace(/\n/g, " ")} |`);
389
+ }
390
+ }
391
+ const usages = target.type === "subtree" ? project.usages({ prefix: target.key }) : project.usages({ key: target.key });
392
+ const args = encodeURIComponent(JSON.stringify([{
393
+ project: at.entry.name,
394
+ key: target.key
395
+ }]));
396
+ lines.push("", `${usages.length} usage${usages.length === 1 ? "" : "s"} · [Open in UI](command:x18n.openKey?${args}) · [Copy translate prompt](command:x18n.copyTranslatePrompt?${args})`);
397
+ return {
398
+ contents: {
399
+ kind: "markdown",
400
+ value: lines.join("\n")
401
+ },
402
+ range: this.range(at.entry, at.path, target.range)
403
+ };
404
+ }
405
+ async inlayHints(params) {
406
+ await this.ready;
407
+ const found = this.locate(params.textDocument.uri);
408
+ if (!found) return [];
409
+ const { entry, path } = found;
410
+ const locale = this.displayLocale(entry);
411
+ const index = createLineIndex(this.text(entry, path));
412
+ const start = index.offset(params.range.start);
413
+ const end = index.offset(params.range.end);
414
+ const hints = [];
415
+ for (const { usage } of entry.project.usages({ path })) {
416
+ if (usage.key === void 0 || usage.dynamic !== false || usage.range.start < start || usage.range.end > end) continue;
417
+ const value = display(entry.project.lookup(usage.key, locale)?.value);
418
+ hints.push({
419
+ position: index.position(usage.range.end + 1),
420
+ label: value === void 0 ? " ⚠ missing" : ` ${truncate(value, this.settings.annotationMaxLength)}`,
421
+ paddingLeft: true
422
+ });
423
+ }
424
+ return hints;
425
+ }
426
+ async completion(params) {
427
+ await this.ready;
428
+ const at = this.at(params);
429
+ if (!at) return [];
430
+ const { project } = at.entry;
431
+ const slot = project.keySlots(at.path).find((range) => at.offset >= range.start && at.offset <= range.end);
432
+ if (!slot) return [];
433
+ const usage = project.usages({ path: at.path }).map((m) => m.usage).find((u) => u.range.start === slot.start);
434
+ const scopeNs = usage?.scopeNamespace ?? project.config.namespace.default;
435
+ const prefix = usage?.scopePrefix ? `${usage.scopePrefix}.` : "";
436
+ const range = this.range(at.entry, at.path, slot);
437
+ return project.keys().flatMap((key) => {
438
+ let insert = key;
439
+ if (project.namespaced && key.startsWith(`${scopeNs}:`)) insert = key.slice(scopeNs.length + 1);
440
+ if (prefix) {
441
+ if (!insert.startsWith(prefix)) return [];
442
+ insert = insert.slice(prefix.length);
443
+ }
444
+ const source = display(project.lookup(key, project.sourceLocale)?.value);
445
+ return [{
446
+ label: insert,
447
+ kind: CompletionItemKind.Text,
448
+ ...source === void 0 ? {} : { detail: source },
449
+ textEdit: {
450
+ range,
451
+ newText: insert
452
+ },
453
+ filterText: insert
454
+ }];
455
+ });
456
+ }
457
+ async codeActions(params) {
458
+ await this.ready;
459
+ const found = this.locate(params.textDocument.uri);
460
+ if (!found) return [];
461
+ const { entry, path } = found;
462
+ const index = createLineIndex(this.text(entry, path));
463
+ const start = index.offset(params.range.start);
464
+ const end = index.offset(params.range.end);
465
+ const actions = [];
466
+ for (const d of entry.project.analyze().diagnostics) {
467
+ if (!d.location || d.location.path !== path || d.location.range.end < start || d.location.range.start > end) continue;
468
+ for (const fix of d.fixes) try {
469
+ actions.push({
470
+ title: fix.title,
471
+ kind: CodeActionKind.QuickFix,
472
+ edit: this.workspaceEdit(entry, entry.project.plan(fix.ops))
473
+ });
474
+ } catch {}
475
+ }
476
+ return [...actions, ...this.extractActions(entry, path, start, end)];
477
+ }
478
+ extractActions(entry, path, start, end) {
479
+ const { project } = entry;
480
+ if (project.nodes(path).length > 0) return [];
481
+ const candidates = findCandidates(project, { files: [path] });
482
+ const inRange = candidates.filter((c) => c.range.start <= end && c.range.end >= start);
483
+ const actions = [];
484
+ const edit = (list) => {
485
+ try {
486
+ return this.workspaceEdit(entry, project.plan([{
487
+ type: "extract",
488
+ items: extractItems(project, list)
489
+ }]));
490
+ } catch {
491
+ return;
492
+ }
493
+ };
494
+ const text = this.text(entry, path);
495
+ const index = createLineIndex(text);
496
+ for (const candidate of inRange) {
497
+ const extracted = edit([candidate]);
498
+ if (extracted) actions.push({
499
+ title: `Extract "${truncate(candidate.text, 30)}" to ${candidate.key}`,
500
+ kind: CodeActionKind.RefactorExtract,
501
+ edit: extracted
502
+ });
503
+ const line = index.position(candidate.range.start).line;
504
+ const indent = /^\s*/.exec(index.line(line))?.[0] ?? "";
505
+ const comment = /-(?:text|attribute)$/.test(candidate.kind) && !candidate.kind.startsWith("js") ? "<!-- x18n-ignore -->" : "// x18n-ignore";
506
+ actions.push({
507
+ title: `Ignore "${truncate(candidate.text, 30)}"`,
508
+ kind: CodeActionKind.QuickFix,
509
+ edit: { changes: { [this.uriOf(entry, path)]: [{
510
+ range: {
511
+ start: {
512
+ line,
513
+ character: 0
514
+ },
515
+ end: {
516
+ line,
517
+ character: 0
518
+ }
519
+ },
520
+ newText: `${indent}${comment}\n`
521
+ }] } }
522
+ });
523
+ }
524
+ if (inRange.length > 0 && candidates.length > 1) {
525
+ const all = edit(candidates);
526
+ if (all) actions.push({
527
+ title: `Extract all ${candidates.length} hard strings in this file`,
528
+ kind: CodeActionKind.RefactorExtract,
529
+ edit: all
530
+ });
531
+ }
532
+ return actions;
533
+ }
534
+ async documentSymbols(params) {
535
+ await this.ready;
536
+ const found = this.locate(params.textDocument.uri);
537
+ if (!found) return [];
538
+ const { entry, path } = found;
539
+ const roots = [];
540
+ const stack = [];
541
+ for (const node of entry.project.nodes(path)) {
542
+ while (stack.length && !node.key.startsWith(`${stack.at(-1).key}.`)) stack.pop();
543
+ const parentKey = stack.at(-1)?.key;
544
+ const symbol = {
545
+ name: parentKey ? node.key.slice(parentKey.length + 1) : node.key,
546
+ detail: node.leaf ? display(entry.project.message(node.key, node.locale)?.value) ?? "" : "",
547
+ kind: node.leaf ? SymbolKind.String : SymbolKind.Namespace,
548
+ range: this.range(entry, path, {
549
+ start: node.range.key.start,
550
+ end: Math.max(node.range.value.end, node.range.key.end)
551
+ }),
552
+ selectionRange: this.range(entry, path, node.range.key),
553
+ children: []
554
+ };
555
+ if (stack.length) stack.at(-1).symbol.children.push(symbol);
556
+ else roots.push(symbol);
557
+ if (!node.leaf) stack.push({
558
+ key: node.key,
559
+ symbol
560
+ });
561
+ }
562
+ return roots;
563
+ }
564
+ async workspaceSymbols(params) {
565
+ await this.ready;
566
+ const query = params.query.toLowerCase();
567
+ const out = [];
568
+ for (const entry of this.projects) for (const key of entry.project.keys()) {
569
+ const value = display(entry.project.message(key, entry.project.sourceLocale)?.value) ?? "";
570
+ if (query && !key.toLowerCase().includes(query) && !value.toLowerCase().includes(query)) continue;
571
+ const node = entry.project.locations(key)[0];
572
+ if (node) out.push({
573
+ name: key,
574
+ kind: SymbolKind.String,
575
+ containerName: value,
576
+ location: this.entryLocation(entry, node)
577
+ });
578
+ if (out.length >= 500) return out;
579
+ }
580
+ return out;
581
+ }
582
+ async codeLenses(params) {
583
+ await this.ready;
584
+ const found = this.locate(params.textDocument.uri);
585
+ if (!this.settings.codeLens || !found) return [];
586
+ const { entry, path } = found;
587
+ return entry.project.nodes(path).filter((node) => node.leaf).map((node) => {
588
+ const info = entry.project.get(node.key);
589
+ const used = info?.usages.length ?? 0;
590
+ const missing = info?.missing ?? [];
591
+ const title = `used ${used} time${used === 1 ? "" : "s"}${missing.length ? ` · missing in ${missing.join(", ")}` : ""}`;
592
+ return {
593
+ range: this.range(entry, path, node.range.key),
594
+ command: {
595
+ title,
596
+ command: ""
597
+ }
598
+ };
599
+ });
600
+ }
601
+ async executeCommand(params) {
602
+ await this.ready;
603
+ const arg = params.arguments?.[0];
604
+ if (typeof arg !== "object" || arg === null) return null;
605
+ const input = arg;
606
+ if (params.command === commands.extract && typeof input.uri === "string") return this.extractFile(input.uri);
607
+ if (params.command === commands.migrateI18nAlly && typeof input.uri === "string") return migrateI18nAlly(fileURLToPath(input.uri));
608
+ const entry = this.projects.find((p) => p.name === input.project) ?? this.projects[0];
609
+ if (!entry || typeof input.key !== "string") return null;
610
+ const key = entry.project.parseKey(input.key);
611
+ let op;
612
+ if (params.command === commands.set && typeof input.value === "string") op = {
613
+ type: "set",
614
+ key,
615
+ locale: input.locale ?? entry.project.sourceLocale,
616
+ value: input.value
617
+ };
618
+ else if (params.command === commands.delete) op = {
619
+ type: "delete",
620
+ key,
621
+ locales: input.locale ? [input.locale] : "all"
622
+ };
623
+ else if (params.command === commands.renamePrefix && typeof input.to === "string") op = {
624
+ type: "rename-prefix",
625
+ from: key,
626
+ to: entry.project.parseKey(input.to)
627
+ };
628
+ if (!op) return null;
629
+ const edit = this.workspaceEdit(entry, this.plan(entry.project, [op]));
630
+ await this.connection.workspace.applyEdit(edit);
631
+ return edit;
632
+ }
633
+ async extractFile(uri) {
634
+ const found = this.locate(uri);
635
+ if (!found) return null;
636
+ const { entry, path } = found;
637
+ const candidates = findCandidates(entry.project, { files: [path] });
638
+ if (candidates.length === 0) return null;
639
+ const edit = this.workspaceEdit(entry, this.plan(entry.project, [{
640
+ type: "extract",
641
+ items: extractItems(entry.project, candidates)
642
+ }]));
643
+ await this.connection.workspace.applyEdit(edit);
644
+ return edit;
645
+ }
646
+ /** ---- Custom protocol ---- */
647
+ entryByName(name) {
648
+ return name === void 0 ? this.projects[0] : this.projects.find((p) => p.name === name);
649
+ }
650
+ projectInfos() {
651
+ return this.projects.map((entry) => ({
652
+ name: entry.name,
653
+ root: entry.root,
654
+ sourceLocale: entry.project.sourceLocale,
655
+ locales: entry.project.locales.map((code) => ({
656
+ code,
657
+ displayName: entry.project.localeInfo(code).displayName,
658
+ flag: entry.project.localeInfo(code).flag
659
+ })),
660
+ displayLocale: this.displayLocale(entry),
661
+ keys: entry.project.keys().length
662
+ }));
663
+ }
664
+ async keyAt(params) {
665
+ await this.ready;
666
+ const at = this.at({
667
+ textDocument: { uri: params.uri },
668
+ position: params.position
669
+ });
670
+ const target = at && at.entry.project.keyAt(at.path, at.offset);
671
+ if (!at || !target) return null;
672
+ return {
673
+ project: at.entry.name,
674
+ key: target.key,
675
+ type: target.type,
676
+ range: this.range(at.entry, at.path, target.range)
677
+ };
678
+ }
679
+ tree(params) {
680
+ const entry = this.entryByName(params.project);
681
+ if (!entry) return [];
682
+ const { project } = entry;
683
+ const locale = this.displayLocale(entry);
684
+ const parent = params.parent;
685
+ const children = /* @__PURE__ */ new Map();
686
+ for (const key of project.keys()) {
687
+ if (parent !== void 0 && !key.startsWith(project.namespaced && parent.endsWith(":") ? parent : `${parent}.`)) continue;
688
+ const rest = parent === void 0 ? key : key.slice(parent.length + (parent.endsWith(":") ? 0 : 1));
689
+ const segment = (parent === void 0 && project.namespaced ? rest.slice(0, rest.indexOf(":") + 1) : "") || rest.split(".")[0];
690
+ const child = parent === void 0 ? segment : `${parent}${parent.endsWith(":") ? "" : "."}${segment}`;
691
+ const list = children.get(child) ?? [];
692
+ children.set(child, list);
693
+ list.push(key);
694
+ }
695
+ const others = project.locales.filter((l) => l !== project.sourceLocale);
696
+ return [...children].map(([key, keys]) => {
697
+ const leaf = keys.length === 1 && keys[0] === key;
698
+ const filled = (k, l) => !["", void 0].includes(display(project.message(k, l)?.value));
699
+ const translated = keys.reduce((sum, k) => sum + others.filter((l) => filled(k, l)).length, 0);
700
+ return {
701
+ key,
702
+ label: key.slice((parent?.length ?? -1) + 1).replace(/:$/, "") || key,
703
+ leaf,
704
+ translated,
705
+ total: keys.length * others.length,
706
+ value: leaf ? display(project.message(key, locale)?.value) : void 0
707
+ };
708
+ });
709
+ }
710
+ progress() {
711
+ return { projects: this.projects.map((entry) => ({
712
+ name: entry.name,
713
+ locales: entry.project.analyze().coverage.map((c) => ({
714
+ locale: c.locale,
715
+ translated: c.translated,
716
+ total: c.total,
717
+ missing: c.missing,
718
+ empty: c.empty
719
+ }))
720
+ })) };
721
+ }
722
+ async fileKeys(params) {
723
+ await this.ready;
724
+ const found = this.locate(params.uri);
725
+ if (!found) return {
726
+ keys: [],
727
+ hardStrings: []
728
+ };
729
+ const { entry, path } = found;
730
+ const locale = this.displayLocale(entry);
731
+ return {
732
+ keys: entry.project.nodes(path).length > 0 ? entry.project.nodes(path).filter((n) => n.leaf).map((n) => ({
733
+ key: n.key,
734
+ range: this.range(entry, path, n.range.key),
735
+ value: display(entry.project.message(n.key, locale)?.value),
736
+ missing: entry.project.get(n.key).missing.length > 0,
737
+ dynamic: false
738
+ })) : entry.project.usages({ path }).flatMap(({ usage }) => usage.key === void 0 ? [] : [{
739
+ key: usage.key,
740
+ range: this.range(entry, path, usage.range),
741
+ value: display(entry.project.lookup(usage.key, locale)?.value),
742
+ missing: usage.dynamic === false && entry.project.lookup(usage.key, entry.project.sourceLocale) === void 0,
743
+ dynamic: usage.dynamic !== false
744
+ }]),
745
+ hardStrings: entry.project.hardStrings(path).map((h) => ({
746
+ text: h.text,
747
+ range: this.range(entry, path, h.range)
748
+ }))
749
+ };
750
+ }
751
+ usageReport() {
752
+ const unused = [];
753
+ const missing = [];
754
+ for (const entry of this.projects) {
755
+ const analysis = entry.project.analyze();
756
+ for (const key of analysis.unused) {
757
+ const node = entry.project.locations(key)[0];
758
+ unused.push({
759
+ project: entry.name,
760
+ key,
761
+ location: node ? this.entryLocation(entry, node) : void 0
762
+ });
763
+ }
764
+ const byKey = /* @__PURE__ */ new Map();
765
+ for (const usage of analysis.undefinedUsages) {
766
+ const list = byKey.get(usage.key) ?? [];
767
+ byKey.set(usage.key, list);
768
+ list.push(this.location(entry, usage.path, usage.range));
769
+ }
770
+ for (const [key, locations] of byKey) missing.push({
771
+ project: entry.name,
772
+ key,
773
+ locations
774
+ });
775
+ }
776
+ return {
777
+ unused,
778
+ missing
779
+ };
780
+ }
781
+ async decorations(params) {
782
+ await this.ready;
783
+ const found = this.locate(params.uri);
784
+ if (!found || found.entry.project.nodes(found.path).length > 0) return {
785
+ locale: this.settings.displayLocale ?? "",
786
+ decorations: []
787
+ };
788
+ const { entry, path } = found;
789
+ const locale = this.displayLocale(entry);
790
+ return {
791
+ locale,
792
+ decorations: entry.project.usages({ path }).flatMap(({ usage }) => {
793
+ if (usage.key === void 0) return [];
794
+ const value = display(entry.project.lookup(usage.key, locale)?.value);
795
+ const state = usage.dynamic !== false ? "dynamic" : value === void 0 ? "missing" : "ok";
796
+ return [{
797
+ range: this.range(entry, path, usage.range),
798
+ key: usage.key,
799
+ value,
800
+ state
801
+ }];
802
+ })
803
+ };
804
+ }
805
+ };
806
+ /** Registers the x18n handlers on a connection. The caller owns transport and `listen()`. */
807
+ function setupServer(connection, options = {}) {
808
+ let ui;
809
+ const openUi = options.openUi ?? (async (entry) => (await (ui ??= import("./ui.mjs").then((m) => m.createUiServers(connection)))).open(entry));
810
+ connection.onShutdown(async () => {
811
+ await (await ui)?.close();
812
+ });
813
+ const server = new Server(connection, options);
814
+ server.documents.listen(connection);
815
+ connection.onInitialize((params) => server.initialize(params));
816
+ connection.onInitialized(() => void server.initialized());
817
+ server.documents.onDidChangeContent(({ document }) => server.schedule(document.uri, () => server.changed(document.uri, document.getText())));
818
+ server.documents.onDidClose(({ document }) => server.schedule(document.uri, () => server.changed(document.uri, void 0)));
819
+ connection.onDidChangeWatchedFiles((params) => void server.watched(params.changes));
820
+ connection.onDefinition((params) => server.definition(params));
821
+ connection.onImplementation((params) => server.implementation(params));
822
+ connection.onReferences((params) => server.references(params));
823
+ connection.onPrepareRename((params) => server.prepareRename(params));
824
+ connection.onRenameRequest((params) => server.rename(params));
825
+ connection.onHover((params) => server.hover(params));
826
+ connection.languages.inlayHint.on((params) => server.inlayHints(params));
827
+ connection.onCompletion((params) => server.completion(params));
828
+ connection.onCodeAction((params) => server.codeActions(params));
829
+ connection.onDocumentSymbol((params) => server.documentSymbols(params));
830
+ connection.onWorkspaceSymbol((params) => server.workspaceSymbols(params));
831
+ connection.onCodeLens((params) => server.codeLenses(params));
832
+ connection.onExecuteCommand((params) => server.executeCommand(params));
833
+ connection.onRequest(methods.projects, async () => {
834
+ await server.ready;
835
+ return server.projectInfos();
836
+ });
837
+ connection.onRequest(methods.keyAt, (params) => server.keyAt(params));
838
+ connection.onRequest(methods.tree, async (params) => {
839
+ await server.ready;
840
+ return server.tree(params ?? {});
841
+ });
842
+ connection.onRequest(methods.progress, async () => {
843
+ await server.ready;
844
+ return server.progress();
845
+ });
846
+ connection.onRequest(methods.fileKeys, (params) => server.fileKeys(params));
847
+ connection.onRequest(methods.usageReport, async () => {
848
+ await server.ready;
849
+ return server.usageReport();
850
+ });
851
+ connection.onRequest(methods.decorations, (params) => server.decorations(params));
852
+ connection.onNotification(methods.setDisplayLocale, (params) => {
853
+ server.settings.displayLocale = params.locale;
854
+ connection.languages.inlayHint.refresh().catch(() => void 0);
855
+ });
856
+ connection.onRequest(methods.openUi, async (params) => {
857
+ await server.ready;
858
+ const entry = server.entryByName(params?.project);
859
+ if (!entry) throw new ResponseError(1, `No project "${params?.project ?? ""}" in this server.`);
860
+ return openUi(entry);
861
+ });
862
+ connection.onRequest(methods.translatePrompt, async (params) => {
863
+ await server.ready;
864
+ const entry = server.entryByName(params.project);
865
+ if (!entry) throw new ResponseError(1, `Unknown project "${params.project}".`);
866
+ return renderPrompt(entry.project.prompt("translate", params.locale, { keys: params.keys }));
867
+ });
868
+ }
869
+ //#endregion
870
+ export { commands as n, methods as r, setupServer as t };
package/dist/ui.mjs ADDED
@@ -0,0 +1,51 @@
1
+ import { join } from "node:path";
2
+ import { pathToFileURL } from "node:url";
3
+ import { createX18nDevframe } from "@x18n/ui";
4
+ import { createDevServer } from "devframe/adapters/dev";
5
+ import { getTempAuthCodeInfo } from "devframe/node/auth";
6
+ //#region src/ui.ts
7
+ /**
8
+ * One devframe dev server per project, on loopback, sharing the server's `Project`.
9
+ * The token is devframe's one-time code: the webview passes it as the magic link
10
+ * fragment (`#devframe_otp=<token>`), which the UI exchanges for a session.
11
+ */
12
+ function createUiServers(connection) {
13
+ const servers = /* @__PURE__ */ new Map();
14
+ return {
15
+ async open(entry) {
16
+ let server = servers.get(entry);
17
+ if (!server) {
18
+ server = createDevServer(createX18nDevframe([entry], { openInEditor: async ({ path, line, column }) => {
19
+ const position = {
20
+ line: line - 1,
21
+ character: column - 1
22
+ };
23
+ await connection.window.showDocument({
24
+ uri: pathToFileURL(join(entry.root, path)).href,
25
+ takeFocus: true,
26
+ selection: {
27
+ start: position,
28
+ end: position
29
+ }
30
+ });
31
+ } }), {
32
+ host: "127.0.0.1",
33
+ port: 0,
34
+ auth: true,
35
+ openBrowser: false
36
+ });
37
+ servers.set(entry, server);
38
+ }
39
+ return {
40
+ url: `${(await server).origin}/`,
41
+ token: getTempAuthCodeInfo().code
42
+ };
43
+ },
44
+ async close() {
45
+ await Promise.all([...servers.values()].map(async (server) => (await server).close()));
46
+ servers.clear();
47
+ }
48
+ };
49
+ }
50
+ //#endregion
51
+ export { createUiServers };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@x18n/lsp",
3
3
  "type": "module",
4
- "version": "0.0.2",
4
+ "version": "0.0.3",
5
5
  "description": "x18n language server for any LSP editor",
6
6
  "author": "Anthony Fu <anthonyfu117@hotmail.com>",
7
7
  "license": "MIT",
@@ -29,10 +29,10 @@
29
29
  "node": ">=22"
30
30
  },
31
31
  "dependencies": {
32
- "@x18n/builtins": "0.0.2",
33
- "@x18n/core": "0.0.2",
34
- "@x18n/node": "0.0.2",
35
- "@x18n/ui": "0.0.2",
32
+ "@x18n/builtins": "0.0.3",
33
+ "@x18n/core": "0.0.3",
34
+ "@x18n/node": "0.0.3",
35
+ "@x18n/ui": "0.0.3",
36
36
  "devframe": "^1.2.5",
37
37
  "vscode-languageserver": "^10.1.2",
38
38
  "vscode-languageserver-textdocument": "^1.0.15"