@likec4/language-server 1.36.0 → 1.37.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.
Files changed (45) hide show
  1. package/dist/LikeC4LanguageServices.d.ts +38 -5
  2. package/dist/LikeC4LanguageServices.js +40 -10
  3. package/dist/Rpc.js +22 -6
  4. package/dist/bundled.mjs +2480 -2464
  5. package/dist/config/schema.d.ts +1 -1
  6. package/dist/config/schema.js +1 -1
  7. package/dist/likec4lib.d.ts +1 -1
  8. package/dist/lsp/DocumentLinkProvider.js +3 -3
  9. package/dist/lsp/DocumentSymbolProvider.js +1 -1
  10. package/dist/mcp/tools/list-projects.js +2 -2
  11. package/dist/mcp/tools/read-project-summary.js +2 -2
  12. package/dist/model/builder/buildModel.d.ts +1 -1
  13. package/dist/model/builder/buildModel.js +4 -6
  14. package/dist/model/model-parser.d.ts +9 -9
  15. package/dist/model/model-parser.js +3 -0
  16. package/dist/model/parser/Base.d.ts +1 -1
  17. package/dist/model/parser/Base.js +1 -1
  18. package/dist/model/parser/DeploymentModelParser.d.ts +1 -1
  19. package/dist/model/parser/DeploymentViewParser.d.ts +1 -1
  20. package/dist/model/parser/DeploymentViewParser.js +2 -2
  21. package/dist/model/parser/FqnRefParser.d.ts +1 -1
  22. package/dist/model/parser/FqnRefParser.js +8 -1
  23. package/dist/model/parser/GlobalsParser.d.ts +1 -1
  24. package/dist/model/parser/ImportsParser.d.ts +1 -1
  25. package/dist/model/parser/ModelParser.d.ts +1 -1
  26. package/dist/model/parser/PredicatesParser.d.ts +1 -1
  27. package/dist/model/parser/SpecificationParser.d.ts +1 -1
  28. package/dist/model/parser/ViewsParser.d.ts +1 -1
  29. package/dist/model/parser/ViewsParser.js +3 -3
  30. package/dist/module.d.ts +4 -0
  31. package/dist/module.js +4 -1
  32. package/dist/protocol.d.ts +18 -4
  33. package/dist/protocol.js +5 -1
  34. package/dist/test/testServices.d.ts +5 -2
  35. package/dist/test/testServices.js +4 -1
  36. package/dist/validation/DocumentValidator.d.ts +11 -0
  37. package/dist/validation/DocumentValidator.js +16 -0
  38. package/dist/validation/index.d.ts +1 -1
  39. package/dist/validation/index.js +1 -0
  40. package/dist/workspace/LangiumDocuments.d.ts +1 -0
  41. package/dist/workspace/LangiumDocuments.js +11 -2
  42. package/dist/workspace/ProjectsManager.d.ts +35 -17
  43. package/dist/workspace/ProjectsManager.js +169 -47
  44. package/dist/workspace/WorkspaceManager.js +1 -1
  45. package/package.json +6 -6
@@ -1,14 +1,27 @@
1
1
  import type { NonEmptyReadonlyArray, ProjectId } from '@likec4/core';
2
- import { type FileSystemNode, type LangiumDocument, URI, WorkspaceCache } from 'langium';
3
- import picomatch from 'picomatch/posix';
2
+ import { type Cancellation, type FileSystemNode, type LangiumDocument, URI, WorkspaceCache } from 'langium';
3
+ import picomatch from 'picomatch';
4
+ import type { Tagged } from 'type-fest';
4
5
  import { ProjectConfig } from '../config';
5
6
  import type { LikeC4SharedServices } from '../module';
6
- interface Project {
7
+ /**
8
+ * A tagged string that represents a project folder.
9
+ * Always has trailing slash.
10
+ */
11
+ export type ProjectFolder = Tagged<string, 'ProjectFolder'>;
12
+ export declare function ProjectFolder(folder: URI | string): ProjectFolder;
13
+ interface ProjectData {
7
14
  id: ProjectId;
8
15
  config: ProjectConfig;
9
- folder: string;
16
+ folder: ProjectFolder;
17
+ folderUri: URI;
10
18
  exclude?: picomatch.Matcher;
11
19
  }
20
+ export interface Project {
21
+ id: ProjectId;
22
+ folderUri: URI;
23
+ config: ProjectConfig;
24
+ }
12
25
  export declare class ProjectsManager {
13
26
  protected services: LikeC4SharedServices;
14
27
  /**
@@ -27,7 +40,9 @@ export declare class ProjectsManager {
27
40
  * This ensures that the most specific project is used for a document.
28
41
  */
29
42
  private _projects;
43
+ private excludedDocuments;
30
44
  private defaultGlobalProject;
45
+ private reloadProjectsLimiter;
31
46
  constructor(services: LikeC4SharedServices);
32
47
  /**
33
48
  * Returns:
@@ -37,29 +52,32 @@ export declare class ProjectsManager {
37
52
  */
38
53
  get defaultProjectId(): ProjectId | undefined;
39
54
  get all(): NonEmptyReadonlyArray<ProjectId>;
40
- getProject(arg: ProjectId | LangiumDocument): {
41
- id: ProjectId;
42
- folder: URI;
43
- config: Readonly<ProjectConfig>;
44
- };
55
+ getProject(arg: ProjectId | LangiumDocument): Project;
45
56
  ensureProjectId(projectId?: ProjectId | undefined): ProjectId;
46
57
  hasMultipleProjects(): boolean;
47
- checkIfExcluded(documentUri: URI): boolean;
48
- isConfigFile(entry: FileSystemNode): boolean;
58
+ checkIfExcluded(document: LangiumDocument | URI | string): boolean;
59
+ /**
60
+ * Checks if it is a config file and it is not excluded by default exclude pattern
61
+ *
62
+ * @param entry The file system entry to check
63
+ */
64
+ isConfigFile(entry: URI): boolean;
49
65
  /**
50
66
  * Checks if the provided file system entry is a valid project config file.
51
67
  *
52
68
  * @param entry The file system entry to check
53
69
  */
54
- loadConfigFile(entry: FileSystemNode): Promise<Project | undefined>;
55
- registerProject(configFile: URI): Promise<Project>;
70
+ loadConfigFile(entry: FileSystemNode): Promise<ProjectData | undefined>;
71
+ registerProject(configFile: URI): Promise<ProjectData>;
56
72
  registerProject(opts: {
57
73
  config: ProjectConfig;
58
74
  folderUri: URI | string;
59
- }): Promise<Project>;
75
+ }): Promise<ProjectData>;
60
76
  belongsTo(document: LangiumDocument | URI | string): ProjectId;
61
- protected findProjectForDocument(documentUri: string): Omit<Project, 'folder'>;
62
- private _mappingsToProject;
63
- protected get mappingsToProject(): WorkspaceCache<string, Omit<Project, 'folder'>>;
77
+ reloadProjects(token?: Cancellation.CancellationToken): Promise<void>;
78
+ protected uniqueProjectId(name: string): ProjectId;
79
+ protected resetProjectIds(): void;
80
+ protected findProjectForDocument(documentUri: string): any;
81
+ protected get mappingsToProject(): WorkspaceCache<string, Pick<ProjectData, 'id' | 'config' | 'exclude'>>;
64
82
  }
65
83
  export {};
@@ -1,18 +1,33 @@
1
- import { BiMap, invariant, nonNullable } from "@likec4/core";
1
+ import { BiMap, delay, invariant, memoizeProp, nonNullable } from "@likec4/core";
2
2
  import { loggable } from "@likec4/log";
3
- import { URI, WorkspaceCache } from "langium";
4
- import picomatch from "picomatch/posix";
5
- import { hasAtLeast, isNullish, map, pipe, prop, sortBy } from "remeda";
3
+ import { deepEqual } from "fast-equals";
4
+ import {
5
+ interruptAndCheck,
6
+ URI,
7
+ WorkspaceCache
8
+ } from "langium";
9
+ import PQueue from "p-queue";
10
+ import picomatch from "picomatch";
11
+ import { hasAtLeast, isNullish, isTruthy, map, pickBy, pipe, prop, sortBy } from "remeda";
6
12
  import {
7
13
  hasProtocol,
8
14
  joinRelativeURL,
15
+ normalizeURL,
9
16
  parseFilename,
10
17
  withoutProtocol,
11
- withProtocol
18
+ withProtocol,
19
+ withTrailingSlash
12
20
  } from "ufo";
13
21
  import { parseConfigJson, validateConfig } from "../config/index.js";
14
22
  import { logger as mainLogger } from "../logger.js";
15
23
  const logger = mainLogger.getChild("ProjectsManager");
24
+ export function ProjectFolder(folder) {
25
+ if (URI.isUri(folder)) {
26
+ folder = folder.toString();
27
+ }
28
+ folder = hasProtocol(folder) ? folder : withProtocol(folder, "file://");
29
+ return withTrailingSlash(normalizeURL(folder));
30
+ }
16
31
  export class ProjectsManager {
17
32
  constructor(services) {
18
33
  this.services = services;
@@ -38,14 +53,19 @@ export class ProjectsManager {
38
53
  * This ensures that the most specific project is used for a document.
39
54
  */
40
55
  _projects = [];
56
+ excludedDocuments = /* @__PURE__ */ new WeakMap();
41
57
  defaultGlobalProject = {
42
58
  id: ProjectsManager.DefaultProjectId,
43
59
  config: {
44
60
  name: ProjectsManager.DefaultProjectId,
45
- exclude: ["**/node_modules/**/*"]
61
+ exclude: ["**/node_modules/**"]
46
62
  },
47
- exclude: picomatch("**/node_modules/**/*")
63
+ exclude: picomatch("**/node_modules/**", { dot: true })
48
64
  };
65
+ reloadProjectsLimiter = new PQueue({
66
+ concurrency: 1,
67
+ timeout: 2e4
68
+ });
49
69
  /**
50
70
  * Returns:
51
71
  * - the default project ID if there are no projects.
@@ -70,20 +90,26 @@ export class ProjectsManager {
70
90
  getProject(arg) {
71
91
  const id = typeof arg === "string" ? arg : arg.likec4ProjectId || this.belongsTo(arg);
72
92
  if (id === ProjectsManager.DefaultProjectId) {
73
- const folder2 = this.services.workspace.WorkspaceManager.workspaceUri;
93
+ let folderUri2;
94
+ try {
95
+ folderUri2 = this.services.workspace.WorkspaceManager.workspaceUri;
96
+ } catch (error) {
97
+ logger.warn("Failed to get workspace URI, using default folder", { error });
98
+ folderUri2 = URI.file("");
99
+ }
74
100
  return {
75
- id,
76
- folder: folder2,
77
- config: this.defaultGlobalProject.config
101
+ id: ProjectsManager.DefaultProjectId,
102
+ config: this.defaultGlobalProject.config,
103
+ folderUri: folderUri2
78
104
  };
79
105
  }
80
106
  const {
81
107
  config,
82
- folder
108
+ folderUri
83
109
  } = nonNullable(this._projects.find((p) => p.id === id), `Project "${id}" not found`);
84
110
  return {
85
111
  id,
86
- folder: URI.parse(folder),
112
+ folderUri,
87
113
  config
88
114
  };
89
115
  }
@@ -103,14 +129,34 @@ export class ProjectsManager {
103
129
  hasMultipleProjects() {
104
130
  return this._projects.length > 1;
105
131
  }
106
- checkIfExcluded(documentUri) {
107
- let docUriAsString = documentUri.toString();
108
- const project = this.findProjectForDocument(docUriAsString);
109
- return project.exclude ? project.exclude(withoutProtocol(docUriAsString)) : false;
132
+ checkIfExcluded(document) {
133
+ if (typeof document === "string" || URI.isUri(document)) {
134
+ let docUriAsString = typeof document === "string" ? document : document.toString();
135
+ const project = this.findProjectForDocument(docUriAsString);
136
+ return project.exclude ? project.exclude(withoutProtocol(docUriAsString)) : false;
137
+ }
138
+ let isExcluded = this.excludedDocuments.get(document);
139
+ if (isExcluded === void 0) {
140
+ isExcluded = this.checkIfExcluded(document.uri);
141
+ this.excludedDocuments.set(document, isExcluded);
142
+ }
143
+ return isExcluded;
110
144
  }
145
+ /**
146
+ * Checks if it is a config file and it is not excluded by default exclude pattern
147
+ *
148
+ * @param entry The file system entry to check
149
+ */
111
150
  isConfigFile(entry) {
112
- const filename = parseFilename(entry.uri.toString(), { strict: false })?.toLowerCase();
113
- return !!filename && ProjectsManager.ConfigFileNames.includes(filename);
151
+ const filename = parseFilename(entry.toString(), { strict: false })?.toLowerCase();
152
+ const isConfigFile = !!filename && ProjectsManager.ConfigFileNames.includes(filename);
153
+ if (isConfigFile) {
154
+ if (this.defaultGlobalProject.exclude(entry.path)) {
155
+ logger.debug`exclude config file ${entry.path}`;
156
+ return false;
157
+ }
158
+ }
159
+ return isConfigFile;
114
160
  }
115
161
  /**
116
162
  * Checks if the provided file system entry is a valid project config file.
@@ -121,7 +167,7 @@ export class ProjectsManager {
121
167
  if (entry.isDirectory) {
122
168
  return void 0;
123
169
  }
124
- if (this.isConfigFile(entry)) {
170
+ if (this.isConfigFile(entry.uri)) {
125
171
  try {
126
172
  return await this.registerProject(entry.uri);
127
173
  } catch (error) {
@@ -143,39 +189,55 @@ ${loggable(error)}`
143
189
  const config2 = parseConfigJson(cfg);
144
190
  const path = joinRelativeURL(configFile.path, "..");
145
191
  const folderUri2 = configFile.with({ path });
146
- return this.registerProject({ config: config2, folderUri: folderUri2 });
192
+ return await this.registerProject({ config: config2, folderUri: folderUri2 });
147
193
  }
148
- const config = validateConfig(opts.config);
194
+ const config = pickBy(validateConfig(opts.config), isTruthy);
149
195
  const { folderUri } = opts;
150
- let id = config.name;
151
- let i = 1;
152
- while (this.projectIdToFolder.has(id)) {
153
- id = `${config.name}-${i++}`;
196
+ const folder = ProjectFolder(folderUri);
197
+ let project = this._projects.find((p) => p.folder === folder);
198
+ if (project && deepEqual(project.config, config)) {
199
+ return project;
154
200
  }
155
- let folder;
156
- if (URI.isUri(folderUri)) {
157
- folder = folderUri.toString();
201
+ let mustReset = !!project && !deepEqual(project.config, config);
202
+ let id;
203
+ if (!project) {
204
+ id = this.uniqueProjectId(config.name);
205
+ project = {
206
+ id,
207
+ config,
208
+ folder,
209
+ folderUri: URI.parse(folder)
210
+ };
211
+ mustReset = this._projects.some((p) => p.folder.startsWith(folder) || folder.startsWith(p.folder));
212
+ this._projects = pipe(
213
+ [...this._projects, project],
214
+ sortBy(
215
+ [({ folder: folder2 }) => withoutProtocol(folder2).split("/").length, "desc"]
216
+ )
217
+ );
218
+ logger.info`register project ${project.id} folder: ${folder}`;
158
219
  } else {
159
- folder = hasProtocol(folderUri) ? folderUri : withProtocol(folderUri, "file://");
220
+ if (project.config.name !== config.name) {
221
+ this.projectIdToFolder.delete(project.id);
222
+ logger.info`unregister project ${project.id} folder: ${folder}`;
223
+ id = this.uniqueProjectId(config.name);
224
+ project.id = id;
225
+ logger.info`re-register project ${project.id} folder: ${folder}`;
226
+ } else {
227
+ id = project.id;
228
+ logger.info`update project ${project.id} on config change`;
229
+ }
230
+ project.config = config;
160
231
  }
161
- const project = {
162
- id,
163
- config,
164
- folder
165
- };
166
232
  if (isNullish(config.exclude)) {
167
233
  project.exclude = this.defaultGlobalProject.exclude;
168
234
  } else if (hasAtLeast(config.exclude, 1)) {
169
- project.exclude = picomatch(config.exclude);
235
+ project.exclude = picomatch(config.exclude, { dot: true });
236
+ }
237
+ this.projectIdToFolder.set(project.id, folder);
238
+ if (mustReset) {
239
+ this.resetProjectIds();
170
240
  }
171
- this._projects = pipe(
172
- [...this._projects, project],
173
- sortBy(
174
- [({ folder: folder2 }) => withoutProtocol(folder2).split("/").length, "desc"]
175
- )
176
- );
177
- this.projectIdToFolder.set(id, folder);
178
- logger.info`register project ${id} folder: ${folder}`;
179
241
  return project;
180
242
  }
181
243
  belongsTo(document) {
@@ -189,6 +251,68 @@ ${loggable(error)}`
189
251
  }
190
252
  return this.findProjectForDocument(documentUri).id;
191
253
  }
254
+ async reloadProjects(token) {
255
+ const folders = this.services.workspace.WorkspaceManager.workspaceFolders;
256
+ if (!folders) {
257
+ logger.warn("No workspace folders found");
258
+ return;
259
+ }
260
+ if (this.reloadProjectsLimiter.size + this.reloadProjectsLimiter.pending > 0) {
261
+ logger.debug`reload projects is already queued`;
262
+ return;
263
+ }
264
+ this.reloadProjectsLimiter.add(async () => {
265
+ await delay(100);
266
+ });
267
+ this.reloadProjectsLimiter.add(async () => {
268
+ if (token) {
269
+ await interruptAndCheck(token);
270
+ }
271
+ logger.debug`reload projects`;
272
+ const configFiles = [];
273
+ for (const folder of folders) {
274
+ try {
275
+ const files = await this.services.workspace.FileSystemProvider.readDirectory(URI.parse(folder.uri));
276
+ for (const file of files) {
277
+ if (file.isFile && this.isConfigFile(file.uri)) {
278
+ configFiles.push(file);
279
+ }
280
+ }
281
+ } catch (error) {
282
+ logger.error("Failed to load config file", { error });
283
+ }
284
+ }
285
+ if (configFiles.length === 0 && this._projects.length !== 0) {
286
+ logger.warning("No config files found, but some projects were registered before");
287
+ }
288
+ this._projects = [];
289
+ this.projectIdToFolder.clear();
290
+ for (const entry of configFiles) {
291
+ try {
292
+ await this.registerProject(entry.uri);
293
+ } catch (error) {
294
+ logger.error("Failed to load config file", { error });
295
+ }
296
+ }
297
+ this.resetProjectIds();
298
+ const docs = this.services.workspace.LangiumDocuments.all.map((d) => d.uri).toArray();
299
+ logger.info("invalidate and rebuild documents {docs}", { docs: docs.length });
300
+ await this.services.workspace.DocumentBuilder.update(docs, []);
301
+ });
302
+ }
303
+ uniqueProjectId(name) {
304
+ let id = name;
305
+ let i = 1;
306
+ while (this.projectIdToFolder.has(id)) {
307
+ id = `${name}-${i++}`;
308
+ }
309
+ return id;
310
+ }
311
+ resetProjectIds() {
312
+ this.mappingsToProject.clear();
313
+ this.excludedDocuments = /* @__PURE__ */ new WeakMap();
314
+ this.services.workspace.LangiumDocuments.resetProjectIds();
315
+ }
192
316
  findProjectForDocument(documentUri) {
193
317
  return this.mappingsToProject.get(documentUri, () => {
194
318
  const project = this._projects.find(({ folder }) => documentUri.startsWith(folder));
@@ -197,9 +321,7 @@ ${loggable(error)}`
197
321
  }
198
322
  // The mapping between document URIs and their corresponding project ID
199
323
  // Lazy-created due to initialization order of the LanguageServer
200
- _mappingsToProject;
201
324
  get mappingsToProject() {
202
- this._mappingsToProject ??= new WorkspaceCache(this.services);
203
- return this._mappingsToProject;
325
+ return memoizeProp(this, "_mappingsToProject", () => new WorkspaceCache(this.services));
204
326
  }
205
327
  }
@@ -64,7 +64,7 @@ export class LikeC4WorkspaceManager extends DefaultWorkspaceManager {
64
64
  * Determine whether the given folder entry shall be included while indexing the workspace.
65
65
  */
66
66
  includeEntry(_workspaceFolder, entry, selector) {
67
- if (this.services.workspace.ProjectsManager.isConfigFile(entry)) {
67
+ if (this.services.workspace.ProjectsManager.isConfigFile(entry.uri)) {
68
68
  return false;
69
69
  }
70
70
  if (entry.isFile) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@likec4/language-server",
3
3
  "description": "LikeC4 Language Server",
4
- "version": "1.36.0",
4
+ "version": "1.37.0",
5
5
  "license": "MIT",
6
6
  "bugs": "https://github.com/likec4/likec4/issues",
7
7
  "homepage": "https://likec4.dev",
@@ -133,11 +133,11 @@
133
133
  "vscode-uri": "3.1.0",
134
134
  "which": "^5.0.0",
135
135
  "zod": "3.25.67",
136
- "@likec4/icons": "1.36.0",
137
- "@likec4/tsconfig": "1.36.0",
138
- "@likec4/layouts": "1.36.0",
139
- "@likec4/log": "1.36.0",
140
- "@likec4/core": "1.36.0"
136
+ "@likec4/core": "1.37.0",
137
+ "@likec4/icons": "1.37.0",
138
+ "@likec4/layouts": "1.37.0",
139
+ "@likec4/log": "1.37.0",
140
+ "@likec4/tsconfig": "1.37.0"
141
141
  },
142
142
  "scripts": {
143
143
  "typecheck": "tsc -b --verbose",