@formspec/language-server 0.1.0-alpha.19 → 0.1.0-alpha.21

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.
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=plugin-client.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-client.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/plugin-client.test.ts"],"names":[],"mappings":""}
package/dist/index.cjs CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
+ var __create = Object.create;
2
3
  var __defProp = Object.defineProperty;
3
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
5
  var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
5
7
  var __hasOwnProp = Object.prototype.hasOwnProperty;
6
8
  var __export = (target, all) => {
7
9
  for (var name in all)
@@ -15,6 +17,14 @@ var __copyProps = (to, from, except, desc) => {
15
17
  }
16
18
  return to;
17
19
  };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
18
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
29
 
20
30
  // src/index.ts
@@ -29,246 +39,90 @@ module.exports = __toCommonJS(index_exports);
29
39
 
30
40
  // src/server.ts
31
41
  var import_node2 = require("vscode-languageserver/node.js");
42
+ var import_vscode_languageserver_textdocument = require("vscode-languageserver-textdocument");
32
43
 
33
44
  // src/providers/completion.ts
34
- var import_core = require("@formspec/core");
45
+ var import_internal = require("@formspec/analysis/internal");
35
46
  var import_node = require("vscode-languageserver/node.js");
36
- var CONSTRAINT_DETAIL = {
37
- minimum: "Minimum numeric value (inclusive). Example: `@minimum 0`",
38
- maximum: "Maximum numeric value (inclusive). Example: `@maximum 100`",
39
- exclusiveMinimum: "Minimum numeric value (exclusive). Example: `@exclusiveMinimum 0`",
40
- exclusiveMaximum: "Maximum numeric value (exclusive). Example: `@exclusiveMaximum 100`",
41
- multipleOf: "Value must be a multiple of this number. Example: `@multipleOf 0.01`",
42
- minLength: "Minimum string length. Example: `@minLength 1`",
43
- maxLength: "Maximum string length. Example: `@maxLength 255`",
44
- minItems: "Minimum number of array items. Example: `@minItems 1`",
45
- maxItems: "Maximum number of array items. Example: `@maxItems 10`",
46
- uniqueItems: "Require all array items to be distinct. Example: `@uniqueItems`",
47
- pattern: "Regular expression pattern for string validation. Example: `@pattern ^[a-z]+$`",
48
- enumOptions: 'Inline JSON array of allowed enum values. Example: `@enumOptions ["a","b","c"]`',
49
- const: 'Require a constant JSON value. Example: `@const "USD"`'
50
- };
51
47
  function getCompletionItems(extensions) {
52
- const builtins = Object.keys(import_core.BUILTIN_CONSTRAINT_DEFINITIONS).map(
53
- (name) => ({
54
- label: `@${name}`,
55
- kind: import_node.CompletionItemKind.Keyword,
56
- detail: CONSTRAINT_DETAIL[name]
57
- })
48
+ return (0, import_internal.getConstraintTagDefinitions)(extensions).map((tag) => ({
49
+ label: `@${tag.canonicalName}`,
50
+ kind: import_node.CompletionItemKind.Keyword,
51
+ detail: tag.completionDetail
52
+ }));
53
+ }
54
+ function toCompletionItem(tag) {
55
+ return {
56
+ label: `@${tag.canonicalName}`,
57
+ kind: import_node.CompletionItemKind.Keyword,
58
+ detail: tag.completionDetail
59
+ };
60
+ }
61
+ function toTargetCompletionItems(tagName, targetCompletions) {
62
+ return targetCompletions.map((target) => ({
63
+ label: target,
64
+ kind: target === "singular" || target === "plural" ? import_node.CompletionItemKind.EnumMember : import_node.CompletionItemKind.Field,
65
+ detail: `Target for @${tagName}`
66
+ }));
67
+ }
68
+ function filterTagNameCompletionItems(prefix, availableTags) {
69
+ const normalizedPrefix = prefix.toLowerCase();
70
+ return availableTags.map(toCompletionItem).filter((item) => item.label.slice(1).toLowerCase().startsWith(normalizedPrefix));
71
+ }
72
+ function getCompletionItemsAtOffset(documentText, offset, extensions, semanticContext) {
73
+ if (semanticContext !== null && semanticContext !== void 0) {
74
+ if (semanticContext.kind === "target") {
75
+ return toTargetCompletionItems(
76
+ semanticContext.semantic.tagName,
77
+ semanticContext.semantic.targetCompletions
78
+ );
79
+ }
80
+ if (semanticContext.kind !== "tag-name") {
81
+ return [];
82
+ }
83
+ return filterTagNameCompletionItems(semanticContext.prefix, semanticContext.availableTags);
84
+ }
85
+ const resolvedContext = (0, import_internal.getSemanticCommentCompletionContextAtOffset)(
86
+ documentText,
87
+ offset,
88
+ extensions ? { extensions } : void 0
58
89
  );
59
- const customItems = extensions?.flatMap(
60
- (extension) => (extension.constraintTags ?? []).map((tag) => ({
61
- label: `@${tag.tagName}`,
62
- kind: import_node.CompletionItemKind.Keyword,
63
- detail: `Extension constraint tag from ${extension.extensionId}`
64
- }))
65
- ) ?? [];
66
- return [...builtins, ...customItems];
90
+ if (resolvedContext.kind === "target") {
91
+ return toTargetCompletionItems(
92
+ resolvedContext.semantic.tag.normalizedTagName,
93
+ resolvedContext.semantic.targetCompletions
94
+ );
95
+ }
96
+ if (resolvedContext.kind !== "tag-name") {
97
+ return [];
98
+ }
99
+ return filterTagNameCompletionItems(resolvedContext.prefix, resolvedContext.availableTags);
67
100
  }
68
101
 
69
102
  // src/providers/hover.ts
70
- var import_core2 = require("@formspec/core");
71
- var CONSTRAINT_HOVER_DOCS = {
72
- minimum: [
73
- "**@minimum** `<number>`",
74
- "",
75
- "Sets an inclusive lower bound on a numeric field.",
76
- "",
77
- "Maps to `minimum` in JSON Schema.",
78
- "",
79
- "**Example:**",
80
- "```typescript",
81
- "/** @minimum 0 */",
82
- "amount: number;",
83
- "```"
84
- ].join("\n"),
85
- maximum: [
86
- "**@maximum** `<number>`",
87
- "",
88
- "Sets an inclusive upper bound on a numeric field.",
89
- "",
90
- "Maps to `maximum` in JSON Schema.",
91
- "",
92
- "**Example:**",
93
- "```typescript",
94
- "/** @maximum 100 */",
95
- "percentage: number;",
96
- "```"
97
- ].join("\n"),
98
- exclusiveMinimum: [
99
- "**@exclusiveMinimum** `<number>`",
100
- "",
101
- "Sets an exclusive lower bound on a numeric field.",
102
- "",
103
- "Maps to `exclusiveMinimum` in JSON Schema.",
104
- "",
105
- "**Example:**",
106
- "```typescript",
107
- "/** @exclusiveMinimum 0 */",
108
- "positiveAmount: number;",
109
- "```"
110
- ].join("\n"),
111
- exclusiveMaximum: [
112
- "**@exclusiveMaximum** `<number>`",
113
- "",
114
- "Sets an exclusive upper bound on a numeric field.",
115
- "",
116
- "Maps to `exclusiveMaximum` in JSON Schema.",
117
- "",
118
- "**Example:**",
119
- "```typescript",
120
- "/** @exclusiveMaximum 1 */",
121
- "ratio: number;",
122
- "```"
123
- ].join("\n"),
124
- multipleOf: [
125
- "**@multipleOf** `<number>`",
126
- "",
127
- "Requires the numeric value to be a multiple of the given number.",
128
- "",
129
- "Maps to `multipleOf` in JSON Schema.",
130
- "",
131
- "**Example:**",
132
- "```typescript",
133
- "/** @multipleOf 0.01 */",
134
- "price: number;",
135
- "```"
136
- ].join("\n"),
137
- minLength: [
138
- "**@minLength** `<number>`",
139
- "",
140
- "Sets a minimum character length on a string field.",
141
- "",
142
- "Maps to `minLength` in JSON Schema.",
143
- "",
144
- "**Example:**",
145
- "```typescript",
146
- "/** @minLength 1 */",
147
- "name: string;",
148
- "```"
149
- ].join("\n"),
150
- maxLength: [
151
- "**@maxLength** `<number>`",
152
- "",
153
- "Sets a maximum character length on a string field.",
154
- "",
155
- "Maps to `maxLength` in JSON Schema.",
156
- "",
157
- "**Example:**",
158
- "```typescript",
159
- "/** @maxLength 255 */",
160
- "description: string;",
161
- "```"
162
- ].join("\n"),
163
- minItems: [
164
- "**@minItems** `<number>`",
165
- "",
166
- "Sets a minimum number of items in an array field.",
167
- "",
168
- "Maps to `minItems` in JSON Schema.",
169
- "",
170
- "**Example:**",
171
- "```typescript",
172
- "/** @minItems 1 */",
173
- "tags: string[];",
174
- "```"
175
- ].join("\n"),
176
- maxItems: [
177
- "**@maxItems** `<number>`",
178
- "",
179
- "Sets a maximum number of items in an array field.",
180
- "",
181
- "Maps to `maxItems` in JSON Schema.",
182
- "",
183
- "**Example:**",
184
- "```typescript",
185
- "/** @maxItems 10 */",
186
- "tags: string[];",
187
- "```"
188
- ].join("\n"),
189
- uniqueItems: [
190
- "**@uniqueItems**",
191
- "",
192
- "Requires all items in an array field to be distinct.",
193
- "",
194
- "Maps to `uniqueItems` in JSON Schema.",
195
- "",
196
- "**Example:**",
197
- "```typescript",
198
- "/** @uniqueItems */",
199
- "tags: string[];",
200
- "```"
201
- ].join("\n"),
202
- pattern: [
203
- "**@pattern** `<regex>`",
204
- "",
205
- "Sets a regular expression pattern that a string field must match.",
206
- "",
207
- "Maps to `pattern` in JSON Schema.",
208
- "",
209
- "**Example:**",
210
- "```typescript",
211
- "/** @pattern ^[a-z0-9]+$ */",
212
- "slug: string;",
213
- "```"
214
- ].join("\n"),
215
- enumOptions: [
216
- "**@enumOptions** `<json-array>`",
217
- "",
218
- "Specifies the allowed values for an enum field as an inline JSON array.",
219
- "",
220
- "Maps to `enum` in JSON Schema.",
221
- "",
222
- "**Example:**",
223
- "```typescript",
224
- '/** @enumOptions ["draft","sent","archived"] */',
225
- "status: string;",
226
- "```"
227
- ].join("\n"),
228
- const: [
229
- "**@const** `<json-literal>`",
230
- "",
231
- "Requires the field value to equal a single constant JSON value.",
232
- "",
233
- "Maps to `const` in JSON Schema.",
234
- "",
235
- "**Example:**",
236
- "```typescript",
237
- '/** @const "USD" */',
238
- "currency: string;",
239
- "```"
240
- ].join("\n")
241
- };
103
+ var import_internal2 = require("@formspec/analysis/internal");
242
104
  function getHoverForTag(tagName, extensions) {
243
105
  const raw = tagName.startsWith("@") ? tagName.slice(1) : tagName;
244
- const name = (0, import_core2.normalizeConstraintTagName)(raw);
245
- if (!(0, import_core2.isBuiltinConstraintName)(name)) {
246
- const registration = extensions?.flatMap(
247
- (extension) => (extension.constraintTags ?? []).map((tag) => ({
248
- extensionId: extension.extensionId,
249
- tag
250
- }))
251
- ).find(({ tag }) => tag.tagName === name);
252
- if (registration === void 0) {
253
- return null;
106
+ const definition = (0, import_internal2.getTagDefinition)((0, import_internal2.normalizeFormSpecTagName)(raw), extensions);
107
+ if (!definition) {
108
+ return null;
109
+ }
110
+ return {
111
+ contents: {
112
+ kind: "markdown",
113
+ value: definition.hoverMarkdown
254
114
  }
255
- return {
256
- contents: {
257
- kind: "markdown",
258
- value: [
259
- `**@${registration.tag.tagName}** \`<value>\``,
260
- "",
261
- `Extension-defined constraint tag from \`${registration.extensionId}\`.`,
262
- "",
263
- "Validated through the registered FormSpec extension surface."
264
- ].join("\n")
265
- }
266
- };
115
+ };
116
+ }
117
+ function getHoverAtOffset(documentText, offset, extensions, semanticHover) {
118
+ const hoverInfo = semanticHover ?? (0, import_internal2.getCommentHoverInfoAtOffset)(documentText, offset, extensions ? { extensions } : void 0);
119
+ if (hoverInfo === null) {
120
+ return null;
267
121
  }
268
122
  return {
269
123
  contents: {
270
124
  kind: "markdown",
271
- value: CONSTRAINT_HOVER_DOCS[name]
125
+ value: hoverInfo.markdown
272
126
  }
273
127
  };
274
128
  }
@@ -278,16 +132,185 @@ function getDefinition() {
278
132
  return null;
279
133
  }
280
134
 
135
+ // src/plugin-client.ts
136
+ var import_promises = __toESM(require("fs/promises"), 1);
137
+ var import_node_net = __toESM(require("net"), 1);
138
+ var import_node_path = __toESM(require("path"), 1);
139
+ var import_node_url = require("url");
140
+ var import_protocol = require("@formspec/analysis/protocol");
141
+ var DEFAULT_PLUGIN_QUERY_TIMEOUT_MS = 2e3;
142
+ function getManifestPath(workspaceRoot) {
143
+ return (0, import_protocol.getFormSpecManifestPath)(workspaceRoot);
144
+ }
145
+ function normalizeWorkspaceRoot(root) {
146
+ const resolved = import_node_path.default.resolve(root);
147
+ const parsed = import_node_path.default.parse(resolved);
148
+ let normalized = resolved;
149
+ while (normalized.length > parsed.root.length && normalized.endsWith(import_node_path.default.sep)) {
150
+ normalized = normalized.slice(0, -import_node_path.default.sep.length);
151
+ }
152
+ return normalized;
153
+ }
154
+ function getMatchingWorkspaceRoot(workspaceRoots, filePath) {
155
+ const normalizedFilePath = import_node_path.default.resolve(filePath);
156
+ const normalizedRoots = [...workspaceRoots].map(normalizeWorkspaceRoot).sort((left, right) => right.length - left.length);
157
+ return normalizedRoots.find(
158
+ (workspaceRoot) => normalizedFilePath === workspaceRoot || normalizedFilePath.startsWith(`${workspaceRoot}${import_node_path.default.sep}`)
159
+ ) ?? null;
160
+ }
161
+ async function readManifest(workspaceRoot) {
162
+ try {
163
+ const manifestText = await import_promises.default.readFile(getManifestPath(workspaceRoot), "utf8");
164
+ const manifest = JSON.parse(manifestText);
165
+ if (!(0, import_protocol.isFormSpecAnalysisManifest)(manifest)) {
166
+ return null;
167
+ }
168
+ return manifest;
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+ async function sendSemanticQuery(manifest, query, timeoutMs = DEFAULT_PLUGIN_QUERY_TIMEOUT_MS) {
174
+ return new Promise((resolve) => {
175
+ const socket = import_node_net.default.createConnection(manifest.endpoint.address);
176
+ let buffer = "";
177
+ let settled = false;
178
+ const finish = (response) => {
179
+ if (settled) {
180
+ return;
181
+ }
182
+ settled = true;
183
+ socket.removeAllListeners("data");
184
+ socket.destroy();
185
+ resolve(response);
186
+ };
187
+ socket.setTimeout(timeoutMs, () => {
188
+ finish(null);
189
+ });
190
+ socket.setEncoding("utf8");
191
+ socket.on("connect", () => {
192
+ socket.write(`${JSON.stringify(query)}
193
+ `);
194
+ });
195
+ socket.on("data", (chunk) => {
196
+ buffer += String(chunk);
197
+ const newlineIndex = buffer.indexOf("\n");
198
+ if (newlineIndex < 0) {
199
+ return;
200
+ }
201
+ const payload = buffer.slice(0, newlineIndex);
202
+ buffer = buffer.slice(newlineIndex + 1);
203
+ try {
204
+ const response = JSON.parse(payload);
205
+ finish((0, import_protocol.isFormSpecSemanticResponse)(response) ? response : null);
206
+ } catch {
207
+ finish(null);
208
+ }
209
+ });
210
+ socket.on("error", () => {
211
+ finish(null);
212
+ });
213
+ socket.on("close", () => {
214
+ finish(null);
215
+ });
216
+ });
217
+ }
218
+ function fileUriToPathOrNull(uri) {
219
+ try {
220
+ return (0, import_node_url.fileURLToPath)(uri);
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+ async function sendFileQuery(workspaceRoots, filePath, query, timeoutMs = DEFAULT_PLUGIN_QUERY_TIMEOUT_MS) {
226
+ const workspaceRoot = getMatchingWorkspaceRoot(workspaceRoots, filePath);
227
+ if (workspaceRoot === null) {
228
+ return null;
229
+ }
230
+ const manifest = await readManifest(workspaceRoot);
231
+ if (manifest === null) {
232
+ return null;
233
+ }
234
+ return sendSemanticQuery(manifest, query, timeoutMs);
235
+ }
236
+ async function getPluginCompletionContextForDocument(workspaceRoots, filePath, documentText, offset, timeoutMs = DEFAULT_PLUGIN_QUERY_TIMEOUT_MS) {
237
+ const response = await sendFileQuery(
238
+ workspaceRoots,
239
+ filePath,
240
+ {
241
+ protocolVersion: import_protocol.FORMSPEC_ANALYSIS_PROTOCOL_VERSION,
242
+ kind: "completion",
243
+ filePath,
244
+ offset
245
+ },
246
+ timeoutMs
247
+ );
248
+ if (response?.kind !== "completion") {
249
+ return null;
250
+ }
251
+ return response.sourceHash === (0, import_protocol.computeFormSpecTextHash)(documentText) ? response.context : null;
252
+ }
253
+ async function getPluginHoverForDocument(workspaceRoots, filePath, documentText, offset, timeoutMs = DEFAULT_PLUGIN_QUERY_TIMEOUT_MS) {
254
+ const response = await sendFileQuery(
255
+ workspaceRoots,
256
+ filePath,
257
+ {
258
+ protocolVersion: import_protocol.FORMSPEC_ANALYSIS_PROTOCOL_VERSION,
259
+ kind: "hover",
260
+ filePath,
261
+ offset
262
+ },
263
+ timeoutMs
264
+ );
265
+ if (response?.kind !== "hover") {
266
+ return null;
267
+ }
268
+ return response.sourceHash === (0, import_protocol.computeFormSpecTextHash)(documentText) ? response.hover : null;
269
+ }
270
+
281
271
  // src/server.ts
272
+ var PLUGIN_QUERY_TIMEOUT_ENV_VAR = "FORMSPEC_PLUGIN_QUERY_TIMEOUT_MS";
273
+ function dedupeWorkspaceRoots(workspaceRoots) {
274
+ return [...new Set(workspaceRoots)];
275
+ }
276
+ function resolvePluginQueryTimeoutMs(explicitTimeoutMs) {
277
+ if (explicitTimeoutMs !== void 0) {
278
+ return explicitTimeoutMs;
279
+ }
280
+ const rawValue = process.env[PLUGIN_QUERY_TIMEOUT_ENV_VAR];
281
+ if (rawValue === void 0) {
282
+ return void 0;
283
+ }
284
+ const parsed = Number.parseInt(rawValue, 10);
285
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : void 0;
286
+ }
287
+ function getWorkspaceRootsFromInitializeParams(params) {
288
+ const workspaceFolders = params.workspaceFolders?.map((workspaceFolder) => fileUriToPathOrNull(workspaceFolder.uri)).filter((workspaceRoot) => workspaceRoot !== null) ?? [];
289
+ const rootUri = params.rootUri === null || params.rootUri === void 0 ? null : fileUriToPathOrNull(params.rootUri);
290
+ const rootPath = params.rootPath ?? null;
291
+ return dedupeWorkspaceRoots([
292
+ ...workspaceFolders,
293
+ ...rootUri === null ? [] : [rootUri],
294
+ ...rootPath === null ? [] : [rootPath]
295
+ ]);
296
+ }
282
297
  function createServer(options = {}) {
283
298
  const connection = (0, import_node2.createConnection)(import_node2.ProposedFeatures.all);
284
- connection.onInitialize(() => {
299
+ const documents = new import_node2.TextDocuments(import_vscode_languageserver_textdocument.TextDocument);
300
+ let workspaceRoots = [...options.workspaceRoots ?? []];
301
+ const pluginQueryTimeoutMs = resolvePluginQueryTimeoutMs(options.pluginQueryTimeoutMs);
302
+ documents.listen(connection);
303
+ connection.onInitialize((params) => {
304
+ workspaceRoots = dedupeWorkspaceRoots([
305
+ ...getWorkspaceRootsFromInitializeParams(params),
306
+ ...workspaceRoots
307
+ ]);
285
308
  return {
286
309
  capabilities: {
287
310
  textDocumentSync: import_node2.TextDocumentSyncKind.Incremental,
288
311
  completionProvider: {
289
- // Trigger completions inside JSDoc comments when `@` is typed
290
- triggerCharacters: ["@"]
312
+ // Trigger completions inside JSDoc comments for tags and target specifiers
313
+ triggerCharacters: ["@", ":"]
291
314
  },
292
315
  hoverProvider: true,
293
316
  definitionProvider: true
@@ -298,11 +321,39 @@ function createServer(options = {}) {
298
321
  }
299
322
  };
300
323
  });
301
- connection.onCompletion(() => {
302
- return getCompletionItems(options.extensions);
324
+ connection.onCompletion(async (params) => {
325
+ const document = documents.get(params.textDocument.uri);
326
+ if (!document) {
327
+ return [];
328
+ }
329
+ const offset = document.offsetAt(params.position);
330
+ const documentText = document.getText();
331
+ const filePath = fileUriToPathOrNull(params.textDocument.uri);
332
+ const semanticContext = options.usePluginTransport === false || filePath === null ? null : await getPluginCompletionContextForDocument(
333
+ workspaceRoots,
334
+ filePath,
335
+ documentText,
336
+ offset,
337
+ pluginQueryTimeoutMs
338
+ );
339
+ return getCompletionItemsAtOffset(documentText, offset, options.extensions, semanticContext);
303
340
  });
304
- connection.onHover((_params) => {
305
- return getHoverForTag("", options.extensions);
341
+ connection.onHover(async (params) => {
342
+ const document = documents.get(params.textDocument.uri);
343
+ if (!document) {
344
+ return null;
345
+ }
346
+ const offset = document.offsetAt(params.position);
347
+ const documentText = document.getText();
348
+ const filePath = fileUriToPathOrNull(params.textDocument.uri);
349
+ const semanticHover = options.usePluginTransport === false || filePath === null ? null : await getPluginHoverForDocument(
350
+ workspaceRoots,
351
+ filePath,
352
+ documentText,
353
+ offset,
354
+ pluginQueryTimeoutMs
355
+ );
356
+ return getHoverAtOffset(documentText, offset, options.extensions, semanticHover);
306
357
  });
307
358
  connection.onDefinition((_params) => {
308
359
  return getDefinition();