texlite 0.9.0 → 0.9.2

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 (93) hide show
  1. package/DESIGN.md +7 -0
  2. package/OPERATIONS.md +70 -3
  3. package/README.md +5 -0
  4. package/README.zh-CN.md +5 -0
  5. package/THIRD_PARTY_NOTICES.md +29 -0
  6. package/dist/client/assets/{AdminUsers-3CHb5012.js → AdminUsers-Buf5ZclR.js} +1 -1
  7. package/dist/client/assets/{CitationLibraryDialog-zPpsUhWJ.js → CitationLibraryDialog-DrKbIlD-.js} +1 -1
  8. package/dist/client/assets/Dashboard-DLk2j3oq.js +2 -0
  9. package/dist/client/assets/GitDialog-CTWAU8q6.js +3 -0
  10. package/dist/client/assets/HistoryDialog-Mo4p8HEg.js +7 -0
  11. package/dist/client/assets/LatexEditor-CwvakYva.js +135 -0
  12. package/dist/client/assets/{PdfPreview-BFVNczSk.js → PdfPreview-BUpELZd-.js} +1 -1
  13. package/dist/client/assets/{ProjectIconPickerDialog-Cf0zBf8u.js → ProjectIconPickerDialog-BiWSwDdt.js} +1 -1
  14. package/dist/client/assets/{ProjectNavigationDialogs-TPbe11iR.js → ProjectNavigationDialogs-CpjkXZTz.js} +1 -1
  15. package/dist/client/assets/{ProjectSettings-CDQvIlQ4.js → ProjectSettings-DoxitHbX.js} +1 -1
  16. package/dist/client/assets/ProjectWorkspace-iNqXZlsj.js +29 -0
  17. package/dist/client/assets/SelectionHistoryDialog-9FWlc3Rb.js +3 -0
  18. package/dist/client/assets/{SystemMetricsDialog-CuqHPUnn.js → SystemMetricsDialog-24kifZO6.js} +1 -1
  19. package/dist/client/assets/TagManagementDialog-BHIA0vEA.js +1 -0
  20. package/dist/client/assets/{WordCountDialog-B4PI0vEk.js → WordCountDialog-DFIWvLrP.js} +1 -1
  21. package/dist/client/assets/check-C3GZh6a1.js +1 -0
  22. package/dist/client/assets/chevron-left-l1oupvGO.js +1 -0
  23. package/dist/client/assets/editor-3rgn4IYh.js +14 -0
  24. package/dist/client/assets/{hard-drive-6uYFyezl.js → hard-drive-B-NSDVVA.js} +1 -1
  25. package/dist/client/assets/{index-BRT5O-Hg.js → index-CNTGCh7t.js} +1 -1
  26. package/dist/client/assets/index-CrlfruX_.css +1 -0
  27. package/dist/client/assets/index-DIM_KiG2.js +12 -0
  28. package/dist/client/assets/katex-D9PXdmFF.css +1 -0
  29. package/dist/client/assets/{key-round-DOAxDSfz.js → key-round-BjL9H8-H.js} +1 -1
  30. package/dist/client/assets/{latexFormatterWorker-DYkYtcFy.js → latexFormatterWorker-BRmsns8a.js} +2 -2
  31. package/dist/client/assets/minimize-2-DDCq8c49.js +1 -0
  32. package/dist/client/assets/{minus-9eVJnugf.js → minus-DgKZCT9F.js} +1 -1
  33. package/dist/client/assets/{pdf-BB1XvBIF.js → pdf-DUYcbPMK.js} +1 -1
  34. package/dist/client/assets/{plus-BGXipRoG.js → plus-CezUzSIZ.js} +1 -1
  35. package/dist/client/assets/{refresh-cw-Dvp3O90f.js → refresh-cw-CDFNL-qu.js} +1 -1
  36. package/dist/client/assets/{search-b1ORp1k8.js → search-CrDJjHoj.js} +1 -1
  37. package/dist/client/assets/{sigma-Bk0_MzWi.js → sigma-DOcJr7VG.js} +1 -1
  38. package/dist/client/assets/spellCheck-BgWyesvf.js +1 -0
  39. package/dist/client/assets/{tags-JAkbmWns.js → tags-BedQ75lh.js} +1 -1
  40. package/dist/client/assets/{type-CE2DYkPH.js → type-glZNdzZR.js} +1 -1
  41. package/dist/client/assets/{user-plus-CKalKSsw.js → user-plus-gQ6q5Nom.js} +1 -1
  42. package/dist/client/assets/{x-Bq29njHL.js → x-CSzFikn2.js} +1 -1
  43. package/dist/client/index.html +6 -4
  44. package/dist/server/app.js +130 -83
  45. package/dist/server/cli.js +4 -2
  46. package/dist/server/compileArtifacts.js +3 -2
  47. package/dist/server/config.js +27 -0
  48. package/dist/server/harper.js +205 -8
  49. package/dist/server/history.js +53 -3
  50. package/dist/server/i18n.js +8 -0
  51. package/dist/server/index.js +4 -2
  52. package/dist/server/instanceLock.js +4 -2
  53. package/dist/server/latexCompletion.js +126 -63
  54. package/dist/server/latexDocumentGraph.js +106 -0
  55. package/dist/server/latexSpellMask.js +196 -43
  56. package/dist/server/pm2.js +3 -2
  57. package/dist/server/projectReferences.js +109 -0
  58. package/dist/server/routes/auth.js +5 -2
  59. package/dist/server/routes/comments.js +13 -1
  60. package/dist/server/routes/compile.js +2 -2
  61. package/dist/server/routes/projectFiles.js +12 -3
  62. package/dist/server/routes/projectHistory.js +13 -1
  63. package/dist/server/routes/projectReferences.js +36 -0
  64. package/dist/server/routes/projects.js +41 -4
  65. package/dist/server/routes/system.js +1 -0
  66. package/dist/server/zip.js +2 -16
  67. package/dist/shared/basePath.js +44 -0
  68. package/dist/shared/bibtexReferences.js +135 -0
  69. package/dist/shared/latexCommandSnippets.js +91 -0
  70. package/dist/shared/latexDependencies.js +71 -0
  71. package/dist/shared/latexLiterals.js +215 -0
  72. package/dist/shared/latexReferenceCommands.js +75 -0
  73. package/dist/shared/latexReferences.js +193 -0
  74. package/dist/shared/latexRoot.js +21 -0
  75. package/dist/shared/latexScanner.js +183 -0
  76. package/dist/shared/writingChecks.js +4 -0
  77. package/package.json +11 -4
  78. package/texlite.config.example.json +2 -4
  79. package/dist/client/assets/Dashboard-CWfSLzD3.js +0 -2
  80. package/dist/client/assets/GitDialog-BUU2U_LV.js +0 -3
  81. package/dist/client/assets/HistoryDialog-B6aopsrw.js +0 -7
  82. package/dist/client/assets/LatexEditor-CM445Tk0.js +0 -65
  83. package/dist/client/assets/ProjectWorkspace-C6GrvKN6.js +0 -27
  84. package/dist/client/assets/SelectionHistoryDialog-CyE0_YDs.js +0 -3
  85. package/dist/client/assets/TagManagementDialog-gRLM3lLx.js +0 -1
  86. package/dist/client/assets/check-BXvzrdoz.js +0 -1
  87. package/dist/client/assets/chevron-left-BB5MYb7g.js +0 -1
  88. package/dist/client/assets/editor-CiK7TK0p.js +0 -14
  89. package/dist/client/assets/index-C0t11tlT.js +0 -14
  90. package/dist/client/assets/index-DXWp2bzM.css +0 -1
  91. package/dist/client/assets/katex-CEK31ho9.css +0 -1
  92. package/dist/client/assets/minimize-2-BWtbag2I.js +0 -1
  93. package/dist/client/assets/spellCheck-BJjWgnef.js +0 -1
@@ -193,6 +193,23 @@ export class ProjectHistoryService {
193
193
  .get(versionId, projectId);
194
194
  return row ? parseManifest(row.manifest_json) : null;
195
195
  }
196
+ /**
197
+ * A short autosave window deliberately coalesces updates into one row. A
198
+ * client that has been inspecting that row must not restore newer contents
199
+ * it has not reviewed, so verify the manifest fingerprint immediately
200
+ * before a restore mutates the project.
201
+ */
202
+ assertSnapshotHash(projectId, versionId, expectedHash) {
203
+ if (!expectedHash)
204
+ return;
205
+ const row = this.db.prepare("SELECT manifest_json FROM project_history_versions WHERE id = ? AND project_id = ?")
206
+ .get(versionId, projectId);
207
+ if (!row)
208
+ throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
209
+ if (historySnapshotHash(row.manifest_json) !== expectedHash) {
210
+ throw httpError(409, "HISTORY_VERSION_CHANGED");
211
+ }
212
+ }
196
213
  /**
197
214
  * Validate a single-file restore before the project enters maintenance.
198
215
  * restore() repeats the target check while the exclusive operation is held
@@ -229,7 +246,7 @@ export class ProjectHistoryService {
229
246
  const entry = this.manifest(projectId, versionId)?.files[filePath];
230
247
  if (!entry || entry.size > MAX_TEXT_PREVIEW_BYTES)
231
248
  return null;
232
- return fs.readFileSync(this.objectPath(projectId, entry.digest), "utf8");
249
+ return this.readStoredObject(projectId, entry.digest, filePath);
233
250
  }
234
251
  restore(projectId, versionId, filePathInput) {
235
252
  const manifest = this.manifest(projectId, versionId);
@@ -245,7 +262,7 @@ export class ProjectHistoryService {
245
262
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
246
263
  const temporary = `${target}.history-${randomUUID()}.tmp`;
247
264
  try {
248
- fs.copyFileSync(this.objectPath(projectId, entry.digest), temporary);
265
+ this.copyStoredObject(projectId, entry.digest, filePath, temporary);
249
266
  fs.chmodSync(temporary, 0o600);
250
267
  fs.renameSync(temporary, target);
251
268
  }
@@ -340,6 +357,35 @@ export class ProjectHistoryService {
340
357
  throw new Error("Invalid history object digest");
341
358
  return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
342
359
  }
360
+ /**
361
+ * A history manifest can outlive a damaged or manually removed object. Keep
362
+ * that operational failure distinct from an absent path in the snapshot so
363
+ * the UI can tell the user why viewing or restoring it is impossible.
364
+ */
365
+ readStoredObject(projectId, digest, filePath) {
366
+ try {
367
+ return fs.readFileSync(this.objectPath(projectId, digest), "utf8");
368
+ }
369
+ catch (error) {
370
+ this.rethrowHistoryObjectError(error, filePath);
371
+ }
372
+ }
373
+ copyStoredObject(projectId, digest, filePath, target) {
374
+ try {
375
+ fs.copyFileSync(this.objectPath(projectId, digest), target);
376
+ }
377
+ catch (error) {
378
+ this.rethrowHistoryObjectError(error, filePath);
379
+ }
380
+ }
381
+ rethrowHistoryObjectError(error, filePath) {
382
+ const code = typeof error === "object" && error !== null && "code" in error && typeof error.code === "string"
383
+ ? error.code : "";
384
+ if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
385
+ throw httpError(410, "HISTORY_OBJECT_MISSING", { path: filePath });
386
+ }
387
+ throw error;
388
+ }
343
389
  storedObjectDigests(projectId) {
344
390
  const objectsRoot = path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects");
345
391
  if (!fs.existsSync(objectsRoot))
@@ -493,7 +539,7 @@ export class ProjectHistoryService {
493
539
  for (const [filePath, entry] of Object.entries(manifest.files)) {
494
540
  const target = path.join(temporary, safeRelativePath(filePath));
495
541
  fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
496
- fs.copyFileSync(this.objectPath(projectId, entry.digest), target);
542
+ this.copyStoredObject(projectId, entry.digest, filePath, target);
497
543
  fs.chmodSync(target, 0o600);
498
544
  }
499
545
  fs.renameSync(live, backup);
@@ -581,6 +627,7 @@ function versionJson(row) {
581
627
  const files = Object.values(manifest.files);
582
628
  return {
583
629
  id: row.id,
630
+ snapshotHash: historySnapshotHash(row.manifest_json),
584
631
  reason: row.reason,
585
632
  label: row.label,
586
633
  createdAt: row.created_at,
@@ -594,3 +641,6 @@ function versionJson(row) {
594
641
  totalSize: files.reduce((sum, file) => sum + file.size, 0)
595
642
  };
596
643
  }
644
+ function historySnapshotHash(manifestJson) {
645
+ return createHash("sha256").update(manifestJson, "utf8").digest("hex");
646
+ }
@@ -16,6 +16,7 @@ const enMessages = {
16
16
  COMMENT_FILE_NOT_FOUND: "The file for this comment does not exist.",
17
17
  COMMENT_NOT_FOUND: "The comment does not exist.",
18
18
  COMMENT_RANGE_INVALID: "The comment source range is invalid.",
19
+ COMMENT_SOURCE_CHANGED: "The source changed while this comment was being prepared. Reselect the passage and try again.",
19
20
  COMMENT_UPDATE_EMPTY: "There is no comment change to save.",
20
21
  COMPILE_ARTIFACT_NOT_FOUND: "The compiled artifact does not exist.",
21
22
  COMPILE_ARTIFACTS_NOT_FOUND: "No compiled artifacts are available yet.",
@@ -45,9 +46,11 @@ const enMessages = {
45
46
  GIT_UNAVAILABLE: "Git is not available on this server.",
46
47
  HARPER_UNAVAILABLE: "Harper spelling and grammar checking is temporarily unavailable.",
47
48
  HISTORY_FILE_NOT_FOUND: "The historical file does not exist.",
49
+ HISTORY_OBJECT_MISSING: "A stored file for this snapshot is missing. It can no longer be viewed or restored.",
48
50
  HISTORY_FILE_PREVIEW_UNSUPPORTED: "This historical file cannot be compared as text.",
49
51
  HISTORY_TARGET_CONFLICT: "The historical version cannot be restored to the requested path.",
50
52
  HISTORY_VERSION_NOT_FOUND: "The history version does not exist.",
53
+ HISTORY_VERSION_CHANGED: "The selected project snapshot changed while it was being reviewed. Refresh it before restoring.",
51
54
  SELECTION_HISTORY_SOURCE_CHANGED: "The source changed while opening selection history. Wait for synchronization, then select the passage and open its history again.",
52
55
  INVALID_PATH: "The file path is invalid.",
53
56
  LAST_ADMIN: "At least one active administrator must remain.",
@@ -89,6 +92,7 @@ const enMessages = {
89
92
  SOURCE_FLUSH_FAILED: "Some source files could not be saved. The project operation was cancelled.",
90
93
  SPELLCHECK_SOURCE_INVALID: "The source to check is invalid.",
91
94
  SPELLCHECK_SOURCE_TOO_LARGE: "The source to check is too large.",
95
+ SPELLCHECK_SUPERSEDED: "A newer writing check replaced this request.",
92
96
  SYNCTEX_EXTERNAL_PATH: "SyncTeX returned a source path outside the project.",
93
97
  SYNCTEX_NOT_AVAILABLE: "No SyncTeX data is available. Compile the project again.",
94
98
  SYMLINK_FORBIDDEN: "Symbolic links are not allowed in project sources.",
@@ -144,6 +148,7 @@ const zhMessages = {
144
148
  COMMENT_FILE_NOT_FOUND: "批注对应的文件不存在。",
145
149
  COMMENT_NOT_FOUND: "批注不存在。",
146
150
  COMMENT_RANGE_INVALID: "批注的源码区间无效。",
151
+ COMMENT_SOURCE_CHANGED: "准备批注时源码已发生变化。请重新选择文本后再试。",
147
152
  COMMENT_UPDATE_EMPTY: "没有可保存的批注修改。",
148
153
  COMPILE_ARTIFACT_NOT_FOUND: "编译产物不存在。",
149
154
  COMPILE_ARTIFACTS_NOT_FOUND: "尚无可用的编译产物。",
@@ -173,9 +178,11 @@ const zhMessages = {
173
178
  GIT_UNAVAILABLE: "服务器未安装 Git。",
174
179
  HARPER_UNAVAILABLE: "Harper 拼写与语法检查暂不可用。",
175
180
  HISTORY_FILE_NOT_FOUND: "历史文件不存在。",
181
+ HISTORY_OBJECT_MISSING: "该快照的已存储文件缺失,无法再查看或恢复。",
176
182
  HISTORY_FILE_PREVIEW_UNSUPPORTED: "该历史文件不能作为文本比较。",
177
183
  HISTORY_TARGET_CONFLICT: "无法将历史版本恢复到所请求的路径。",
178
184
  HISTORY_VERSION_NOT_FOUND: "历史版本不存在。",
185
+ HISTORY_VERSION_CHANGED: "查看期间该项目快照已发生变化。请刷新后再恢复。",
179
186
  SELECTION_HISTORY_SOURCE_CHANGED: "打开选区历史时源码已发生变化。请等待同步完成,重新选择文本后再打开历史。",
180
187
  INVALID_PATH: "文件路径无效。",
181
188
  LAST_ADMIN: "系统必须保留至少一名可用管理员。",
@@ -217,6 +224,7 @@ const zhMessages = {
217
224
  SOURCE_FLUSH_FAILED: "部分源码未能保存至磁盘,项目操作已取消。",
218
225
  SPELLCHECK_SOURCE_INVALID: "待检查源码格式不正确。",
219
226
  SPELLCHECK_SOURCE_TOO_LARGE: "待检查源码过大。",
227
+ SPELLCHECK_SUPERSEDED: "更新的写作检查已替代此请求。",
220
228
  SYNCTEX_EXTERNAL_PATH: "SyncTeX 返回了项目外部的源码路径。",
221
229
  SYNCTEX_NOT_AVAILABLE: "尚无可用的 SyncTeX 数据,请重新编译。",
222
230
  SYMLINK_FORBIDDEN: "项目源码中不允许使用符号链接。",
@@ -6,6 +6,7 @@ import { buildApp } from "./app.js";
6
6
  import { assertEnvironment } from "./environment.js";
7
7
  import { acquireDataDirectoryLock } from "./instanceLock.js";
8
8
  import { LogRotation } from "./logRotation.js";
9
+ import { basePathHref } from "../shared/basePath.js";
9
10
  export async function startServer(configPath) {
10
11
  const config = loadConfig(configPath);
11
12
  const lock = acquireDataDirectoryLock(config);
@@ -23,11 +24,12 @@ export async function startServer(configPath) {
23
24
  app = await buildApp(config, db);
24
25
  await app.listen({ host: config.host, port: config.port });
25
26
  app.log.info(`Environment ready: ${environment.map((item) => `${item.name} ${item.version}`).join(", ")}`);
26
- app.log.info(`${config.siteName} running at http://${config.host}:${config.port}`);
27
+ const address = `http://${config.host}:${config.port}${basePathHref(config.basePath)}`;
28
+ app.log.info(`${config.siteName} running at ${address}`);
27
29
  if (typeof process.send === "function")
28
30
  process.send("ready");
29
31
  return {
30
- address: `http://${config.host}:${config.port}`,
32
+ address,
31
33
  close: async () => {
32
34
  try {
33
35
  if (app)
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { basePathHref } from "../shared/basePath.js";
4
5
  export const LOCK_FILE_NAME = ".texlite.lock";
5
6
  export function isProcessAlive(pid) {
6
7
  if (!Number.isInteger(pid) || pid <= 0)
@@ -45,7 +46,8 @@ export function acquireDataDirectoryLock(config) {
45
46
  token: randomUUID(),
46
47
  configPath: config.configPath,
47
48
  host: config.host,
48
- port: config.port
49
+ port: config.port,
50
+ basePath: config.basePath
49
51
  };
50
52
  for (let attempt = 0; attempt < 2; attempt += 1) {
51
53
  try {
@@ -103,7 +105,7 @@ export function acquireDataDirectoryLock(config) {
103
105
  throw new Error(`The data directory is already locked by another TexLite instance:\n` +
104
106
  ` - PID: ${existing.pid}\n` +
105
107
  ` - Config: ${existing.configPath}\n` +
106
- ` - Address: http://${existing.host}:${existing.port}\n` +
108
+ ` - Address: http://${existing.host}:${existing.port}${basePathHref(existing.basePath ?? "/")}\n` +
107
109
  ` - Started: ${existing.startedAt}\n` +
108
110
  ` - Data directory: ${config.dataDir}\n` +
109
111
  `Do not run multiple TexLite instances with the same data directory.`);
@@ -1,11 +1,16 @@
1
1
  import fs from "node:fs";
2
2
  import { listProjectFiles, listProjectFilesAsync, resolveSourcePath } from "./files.js";
3
+ import { commandSnippet, newCommandArguments, xparseCommandArguments, xparseCommandDefinitions } from "../shared/latexCommandSnippets.js";
4
+ import { maskLatexComments, maskLatexLiteralContent } from "../shared/latexLiterals.js";
5
+ import { findLatexReferenceDefinitions } from "../shared/latexReferences.js";
6
+ import { declaredBibliographyPaths, latexDocumentDirectives } from "./latexDocumentGraph.js";
3
7
  const textExtensions = /\.(?:tex|sty|cls|def|cfg|bib|bst|fd|dtx|ins)$/i;
4
8
  const definitionExtensions = /\.(?:tex|sty|cls|def|cfg|dtx|ins)$/i;
5
9
  const maxIndexedFileBytes = 3 * 1024 * 1024;
6
10
  const maxIndexedBytes = 24 * 1024 * 1024;
7
11
  const maxIndexedFiles = 500;
8
12
  const maxCachedProjects = 64;
13
+ const maxCachedRootScopes = 8;
9
14
  // Core LaTeX commands are kept in the completion index even when the project
10
15
  // has not loaded a package-specific definition yet. Keep the catalogue
11
16
  // grouped by purpose so missing primitives are easy to spot and extend.
@@ -103,11 +108,11 @@ function item(label, detail, kind, source, apply) {
103
108
  }
104
109
  function createIndex() {
105
110
  return {
106
- commands: new Map(), environments: new Map(), labels: new Map(), citations: new Map(), packages: new Map(), files: new Map()
111
+ commands: new Map(), environments: new Map(), labels: new Map(), citations: new Map(), packages: new Map(), classes: new Map(), files: new Map()
107
112
  };
108
113
  }
109
114
  function withoutComments(content) {
110
- return content.split("\n").map((line) => line.replace(/(^|[^\\])%.*$/, "$1")).join("\n");
115
+ return maskLatexComments(maskLatexLiteralContent(content));
111
116
  }
112
117
  function commandItem(index, name, detail, source, apply) {
113
118
  if (!name.startsWith("\\"))
@@ -120,31 +125,23 @@ function commandItem(index, name, detail, source, apply) {
120
125
  if (!existing || (existing.source === "LaTeX" && source !== "LaTeX"))
121
126
  index.commands.set(name, item(name, detail, "function", source, apply));
122
127
  }
123
- function commandSnippet(name, argumentCount) {
124
- if (argumentCount <= 0)
125
- return undefined;
126
- const placeholder = (index) => `\${${index}}`;
127
- return name + Array.from({ length: argumentCount }, (_, index) => `{${placeholder(index + 1)}}`).join("");
128
- }
129
- function xparseArgumentCount(specification) {
130
- // The common xparse argument types all consume one user supplied value.
131
- // Modifiers such as `+` and argument delimiters are intentionally ignored.
132
- return [...specification.matchAll(/[moOrRdDsStvb]/g)].length;
133
- }
134
128
  function extractSymbols(index, filePath, original) {
135
129
  const content = withoutComments(original);
136
130
  const source = filePath;
137
- for (const match of content.matchAll(/\\(?:newcommand|renewcommand|providecommand|DeclareRobustCommand)\s*\*?\s*(?:\{\s*)?\\([A-Za-z@][A-Za-z@0-9:_]*)\s*(?:\})?\s*(?:\[(\d+)\])?/g)) {
138
- const args = Number.parseInt(match[2] ?? "0", 10);
139
- commandItem(index, `\\${match[1]}`, `Project command (${args} argument${args === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, args));
131
+ for (const match of content.matchAll(/\\(?:newcommand|renewcommand|providecommand|DeclareRobustCommand)\s*\*?\s*(?:\{\s*)?\\([A-Za-z@][A-Za-z@0-9:_]*)\s*(?:\})?\s*(?:\[(\d+)\])?(?:\s*(\[[^\]]*\]))?/g)) {
132
+ const arguments_ = newCommandArguments(Number.parseInt(match[2] ?? "0", 10), Boolean(match[3]));
133
+ commandItem(index, `\\${match[1]}`, `Project command (${arguments_.length} argument${arguments_.length === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, arguments_));
140
134
  }
141
- for (const match of content.matchAll(/\\(?:NewDocumentCommand|RenewDocumentCommand|ProvideDocumentCommand|DeclareDocumentCommand|DeclareExpandableDocumentCommand|RenewExpandableDocumentCommand|ProvideExpandableDocumentCommand)\s*\{\s*\\([A-Za-z@][A-Za-z@0-9:_]*)\s*\}\s*\{([^}]*)\}/g)) {
142
- const args = xparseArgumentCount(match[2]);
143
- commandItem(index, `\\${match[1]}`, `Project command (${args} argument${args === 1 ? "" : "s"})`, source, commandSnippet(`\\${match[1]}`, args));
135
+ for (const definition of xparseCommandDefinitions(content)) {
136
+ const arguments_ = xparseCommandArguments(definition.specification);
137
+ const detail = arguments_
138
+ ? `Project command (${arguments_.length} argument${arguments_.length === 1 ? "" : "s"})`
139
+ : "Project command";
140
+ commandItem(index, `\\${definition.name}`, detail, source, arguments_ ? commandSnippet(`\\${definition.name}`, arguments_) : undefined);
144
141
  }
145
142
  for (const match of content.matchAll(/\\(?:def|gdef|edef|xdef)\s*\\([A-Za-z@][A-Za-z@0-9:_]*)((?:\s*#\d+)*)/g)) {
146
143
  const args = [...(match[2] ?? "").matchAll(/#\d+/g)].length;
147
- commandItem(index, `\\${match[1]}`, "Project macro", source, commandSnippet(`\\${match[1]}`, args));
144
+ commandItem(index, `\\${match[1]}`, "Project macro", source, commandSnippet(`\\${match[1]}`, newCommandArguments(args, false)));
148
145
  }
149
146
  for (const match of content.matchAll(/\\DeclareMathOperator\s*\*?\s*\{?\\([A-Za-z@][A-Za-z@0-9:_]*)\}?/g))
150
147
  commandItem(index, `\\${match[1]}`, "Project math operator", source);
@@ -166,8 +163,10 @@ function extractSymbols(index, filePath, original) {
166
163
  index.environments.set(name, item(name, "Theorem environment", "keyword", source));
167
164
  }
168
165
  }
169
- for (const match of content.matchAll(/\\(?:label|hypertarget)\s*\{([^}]+)\}/g)) {
170
- const label = match[1].trim();
166
+ for (const definition of findLatexReferenceDefinitions(content, "label")) {
167
+ if (definition.source !== "label")
168
+ continue;
169
+ const label = definition.key;
171
170
  if (label && !index.labels.has(label))
172
171
  index.labels.set(label, item(label, "Label", "constant", source));
173
172
  }
@@ -176,29 +175,24 @@ function extractSymbols(index, filePath, original) {
176
175
  if (file)
177
176
  index.files.set(file, item(file, "Project file", "text", source));
178
177
  }
179
- for (const match of content.matchAll(/\\(?:usepackage|RequirePackage|documentclass)\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
178
+ for (const match of content.matchAll(/\\(?:usepackage|RequirePackage|documentclass)\s*(?:\[[^\]]*\])?\s*\{([^}]+)\}/g)) {
180
179
  for (const packageName of match[1].split(",").map((value) => value.trim()).filter(Boolean)) {
181
- const target = /documentclass|documentstyle/.test(match[0]) ? index.files : index.packages;
180
+ const target = /documentclass|documentstyle/.test(match[0]) ? index.classes : index.packages;
182
181
  if (!target.has(packageName))
183
- target.set(packageName, item(packageName, target === index.files ? "Document class" : "Package", "text", source));
182
+ target.set(packageName, item(packageName, target === index.classes ? "Document class" : "Package", target === index.classes ? "class" : "text", source));
184
183
  }
185
184
  }
186
- for (const match of content.matchAll(/\\(?:cite|citep|citet|parencite|textcite|autocite|footcite)(?:\w*)?\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
187
- for (const key of match[1].split(",").map((value) => value.trim()).filter(Boolean)) {
188
- if (!index.citations.has(key))
189
- index.citations.set(key, item(key, "Citation key", "constant", source));
185
+ // `\\bibitem` suggestions are scoped by the client to the file currently
186
+ // open in the editor. Keep the server index reserved for standalone
187
+ // project BibTeX databases, so old cite uses and other source files do
188
+ // not leak into a citation menu.
189
+ if (/\.bib$/i.test(filePath)) {
190
+ for (const definition of findLatexReferenceDefinitions(original, "citation", true)) {
191
+ const key = definition.key;
192
+ if (key && !index.citations.has(key))
193
+ index.citations.set(key, item(key, "BibTeX key", "constant", source));
190
194
  }
191
195
  }
192
- for (const match of content.matchAll(/\\bibitem\s*(?:\[[^]]*\])?\s*\{([^}]+)\}/g)) {
193
- const key = match[1].trim();
194
- if (key && !index.citations.has(key))
195
- index.citations.set(key, item(key, "Bibliography key", "constant", source));
196
- }
197
- for (const match of content.matchAll(/@(?:\w+)\s*\{\s*([^,\s]+)\s*,/g)) {
198
- const key = match[1].trim();
199
- if (key && !index.citations.has(key))
200
- index.citations.set(key, item(key, "BibTeX key", "constant", source));
201
- }
202
196
  }
203
197
  function sorted(map) {
204
198
  return [...map.values()].sort((left, right) => left.label.localeCompare(right.label));
@@ -212,14 +206,21 @@ function standardIndex() {
212
206
  for (const packageName of standardPackages)
213
207
  index.packages.set(packageName.trim(), item(packageName.trim(), "Package", "text", "LaTeX"));
214
208
  for (const className of standardClasses)
215
- index.files.set(className, item(className, "Document class", "class", "LaTeX"));
209
+ index.classes.set(className, item(className, "Document class", "class", "LaTeX"));
216
210
  return index;
217
211
  }
212
+ function addProjectFile(index, filePath) {
213
+ index.files.set(filePath, item(filePath, "Project file", "text", "Project"));
214
+ if (/\.cls$/i.test(filePath)) {
215
+ const name = filePath.replace(/\.cls$/i, "");
216
+ index.classes.set(name, item(name, "Document class", "class", filePath));
217
+ }
218
+ }
218
219
  export function buildLatexCompletionIndex(config, projectId) {
219
220
  const index = standardIndex();
220
221
  const allEntries = listProjectFiles(config, projectId).filter((entry) => entry.type === "file").slice(0, maxIndexedFiles);
221
222
  for (const entry of allEntries)
222
- index.files.set(entry.path, item(entry.path, "Project file", "text", "Project"));
223
+ addProjectFile(index, entry.path);
223
224
  const entries = allEntries.filter((entry) => textExtensions.test(entry.path));
224
225
  let indexedBytes = 0;
225
226
  for (const entry of entries) {
@@ -240,33 +241,38 @@ export function buildLatexCompletionIndex(config, projectId) {
240
241
  }
241
242
  return {
242
243
  commands: sorted(index.commands), environments: sorted(index.environments), labels: sorted(index.labels),
243
- citations: sorted(index.citations), packages: sorted(index.packages), files: sorted(index.files)
244
+ citations: sorted(index.citations), packages: sorted(index.packages), classes: sorted(index.classes), files: sorted(index.files)
244
245
  };
245
246
  }
246
247
  /** Reuses extracted symbols for files whose mtime and size did not change. */
247
248
  export class LatexCompletionService {
248
249
  config;
249
250
  cache = new Map();
251
+ // One project can have several selectable root documents. Keep a small
252
+ // result cache per project so switching roots does not accidentally reuse
253
+ // citation keys collected for another manuscript.
250
254
  resultCache = new Map();
251
255
  pending = new Map();
252
256
  constructor(config) {
253
257
  this.config = config;
254
258
  }
255
- build(projectId) {
256
- const existing = this.pending.get(projectId);
259
+ build(projectId, mainFile) {
260
+ const scope = completionScope(mainFile);
261
+ const pendingKey = `${projectId}\0${scope}`;
262
+ const existing = this.pending.get(pendingKey);
257
263
  if (existing)
258
264
  return existing;
259
- const request = this.buildIncremental(projectId)
265
+ const request = this.buildIncremental(projectId, mainFile)
260
266
  // Do not retain a partial per-file cache when a project scan fails.
261
267
  .catch((error) => {
262
268
  this.invalidate(projectId);
263
269
  throw error;
264
270
  })
265
271
  .finally(() => {
266
- if (this.pending.get(projectId) === request)
267
- this.pending.delete(projectId);
272
+ if (this.pending.get(pendingKey) === request)
273
+ this.pending.delete(pendingKey);
268
274
  });
269
- this.pending.set(projectId, request);
275
+ this.pending.set(pendingKey, request);
270
276
  return request;
271
277
  }
272
278
  invalidate(projectId) {
@@ -281,13 +287,14 @@ export class LatexCompletionService {
281
287
  cachedFiles: [...this.cache.values()].reduce((total, files) => total + files.size, 0)
282
288
  };
283
289
  }
284
- async buildIncremental(projectId) {
290
+ async buildIncremental(projectId, mainFile) {
291
+ const scope = completionScope(mainFile);
285
292
  const index = standardIndex();
286
293
  const allEntries = (await listProjectFilesAsync(this.config, projectId)).filter((entry) => entry.type === "file").slice(0, maxIndexedFiles);
287
294
  const signature = allEntries.map((entry) => `${entry.path}:${entry.size ?? 0}:${entry.mtimeMs ?? 0}`).sort().join("\n");
288
- const cachedResult = this.resultCache.get(projectId);
295
+ const cachedResult = this.resultCache.get(projectId)?.get(scope);
289
296
  if (cachedResult?.signature === signature) {
290
- this.touch(projectId);
297
+ this.touch(projectId, scope);
291
298
  return cachedResult.index;
292
299
  }
293
300
  const projectCache = this.cache.get(projectId) ?? new Map();
@@ -297,7 +304,9 @@ export class LatexCompletionService {
297
304
  if (!livePaths.has(cachedPath))
298
305
  projectCache.delete(cachedPath);
299
306
  for (const entry of allEntries)
300
- index.files.set(entry.path, item(entry.path, "Project file", "text", "Project"));
307
+ addProjectFile(index, entry.path);
308
+ const indexedFiles = [];
309
+ const sourceDirectives = new Map();
301
310
  let indexedBytes = 0;
302
311
  for (const entry of allEntries.filter((candidate) => textExtensions.test(candidate.path))) {
303
312
  const size = entry.size ?? 0;
@@ -318,26 +327,68 @@ export class LatexCompletionService {
318
327
  const fileIndex = createIndex();
319
328
  if (definitionExtensions.test(entry.path) || /\.bib$/i.test(entry.path))
320
329
  extractSymbols(fileIndex, entry.path, content);
321
- cached = { signature, index: fileIndex };
330
+ cached = {
331
+ signature,
332
+ index: fileIndex,
333
+ ...(definitionExtensions.test(entry.path) ? { directives: latexDocumentDirectives(content) } : {})
334
+ };
322
335
  projectCache.set(entry.path, cached);
323
336
  }
324
- mergeIndex(index, cached.index);
337
+ if (cached.directives)
338
+ sourceDirectives.set(entry.path, cached.directives);
339
+ indexedFiles.push({ path: entry.path, symbols: cached });
340
+ mergeIndex(index, cached.index, false);
341
+ }
342
+ // A project can hold templates, archives, and unrelated bibliography
343
+ // databases. Citation completion follows the selected root's \input graph
344
+ // and only includes BibTeX resources it explicitly declares. If a caller
345
+ // does not provide a root (the backwards-compatible service API), keep the
346
+ // former project-wide bibliography behavior.
347
+ const bibPaths = allEntries.filter((entry) => /\.bib$/i.test(entry.path)).map((entry) => entry.path);
348
+ const selectedBibliographies = mainFile
349
+ ? declaredBibliographyPaths(sourceDirectives, mainFile, bibPaths)
350
+ : null;
351
+ const allowedBibliographies = new Set(selectedBibliographies ?? bibPaths);
352
+ for (const { path: filePath, symbols } of indexedFiles) {
353
+ if (!allowedBibliographies.has(filePath))
354
+ continue;
355
+ mergeCitations(index, symbols.index);
325
356
  }
326
357
  const result = {
327
358
  commands: sorted(index.commands), environments: sorted(index.environments), labels: sorted(index.labels),
328
- citations: sorted(index.citations), packages: sorted(index.packages), files: sorted(index.files)
359
+ citations: sorted(index.citations), packages: sorted(index.packages), classes: sorted(index.classes), files: sorted(index.files)
329
360
  };
330
- this.resultCache.set(projectId, { signature, index: result });
331
- this.touch(projectId);
361
+ const projectResults = this.resultCache.get(projectId) ?? new Map();
362
+ this.resultCache.set(projectId, projectResults);
363
+ // A root document is user-selectable, so do not let a project with many
364
+ // standalone examples retain an unbounded number of full completion
365
+ // result arrays. The per-file symbol cache remains shared by all roots.
366
+ if (projectResults.has(scope))
367
+ projectResults.delete(scope);
368
+ projectResults.set(scope, { signature, index: result });
369
+ while (projectResults.size > maxCachedRootScopes) {
370
+ const oldestScope = projectResults.keys().next().value;
371
+ if (oldestScope === undefined)
372
+ break;
373
+ projectResults.delete(oldestScope);
374
+ }
375
+ this.touch(projectId, scope);
332
376
  this.evictLeastRecentlyUsedProjects();
333
377
  return result;
334
378
  }
335
379
  /** Keep result and per-file symbol caches on the same project-level LRU. */
336
- touch(projectId) {
337
- const result = this.resultCache.get(projectId);
338
- if (result) {
380
+ touch(projectId, scope) {
381
+ const results = this.resultCache.get(projectId);
382
+ if (results) {
383
+ if (scope !== undefined) {
384
+ const result = results.get(scope);
385
+ if (result) {
386
+ results.delete(scope);
387
+ results.set(scope, result);
388
+ }
389
+ }
339
390
  this.resultCache.delete(projectId);
340
- this.resultCache.set(projectId, result);
391
+ this.resultCache.set(projectId, results);
341
392
  }
342
393
  const symbols = this.cache.get(projectId);
343
394
  if (symbols) {
@@ -355,10 +406,15 @@ export class LatexCompletionService {
355
406
  }
356
407
  }
357
408
  }
358
- function mergeIndex(target, source) {
409
+ function completionScope(mainFile) {
410
+ return mainFile ?? "";
411
+ }
412
+ function mergeIndex(target, source, includeCitations) {
359
413
  for (const value of source.commands.values())
360
414
  commandItem(target, value.label, value.detail, value.source ?? "Project", value.apply);
361
- for (const key of ["environments", "labels", "citations", "packages", "files"]) {
415
+ for (const key of ["environments", "labels", "citations", "packages", "classes", "files"]) {
416
+ if (key === "citations" && !includeCitations)
417
+ continue;
362
418
  for (const [label, value] of source[key]) {
363
419
  const existing = target[key].get(label);
364
420
  if (!existing || (existing.source === "LaTeX" && value.source !== "LaTeX"))
@@ -366,3 +422,10 @@ function mergeIndex(target, source) {
366
422
  }
367
423
  }
368
424
  }
425
+ function mergeCitations(target, source) {
426
+ for (const [label, value] of source.citations) {
427
+ const existing = target.citations.get(label);
428
+ if (!existing || (existing.source === "LaTeX" && value.source !== "LaTeX"))
429
+ target.citations.set(label, value);
430
+ }
431
+ }
@@ -0,0 +1,106 @@
1
+ import path from "node:path";
2
+ import { findLatexBibliographyFiles, findLatexSourceIncludes } from "../shared/latexReferences.js";
3
+ /**
4
+ * Extract only the small amount of source information needed for project-level
5
+ * document graph lookups. Keeping this separate from completion symbols means
6
+ * cached completion data does not need to retain whole source files.
7
+ */
8
+ export function latexDocumentDirectives(source) {
9
+ return {
10
+ includes: findLatexSourceIncludes(source).map((reference) => reference.path),
11
+ bibliographies: findLatexBibliographyFiles(source).map((reference) => reference.path)
12
+ };
13
+ }
14
+ /** Return the selected root followed by its reachable \input-style sources. */
15
+ export function documentSourceOrder(sources, mainFile) {
16
+ if (!mainFile || !sources.has(mainFile))
17
+ return [];
18
+ const result = [];
19
+ const queued = [mainFile];
20
+ const seen = new Set();
21
+ while (queued.length) {
22
+ const current = queued.shift();
23
+ if (!current || seen.has(current))
24
+ continue;
25
+ const directives = sources.get(current);
26
+ if (!directives)
27
+ continue;
28
+ seen.add(current);
29
+ result.push(current);
30
+ for (const include of directives.includes) {
31
+ const resolved = resolveProjectReferencePath(include, current, ".tex", sources, false);
32
+ if (resolved && !seen.has(resolved))
33
+ queued.push(resolved);
34
+ }
35
+ }
36
+ return result;
37
+ }
38
+ /**
39
+ * Return exactly the BibTeX resources declared by a selected root and its
40
+ * input graph. `null` means the selected root was unavailable to the graph
41
+ * scanner, so callers can retain a backwards-compatible fallback.
42
+ */
43
+ export function declaredBibliographyPaths(sources, mainFile, bibPaths) {
44
+ if (!mainFile || !sources.has(mainFile))
45
+ return null;
46
+ const availableBibliographies = new Set(bibPaths);
47
+ const declared = [];
48
+ for (const sourcePath of documentSourceOrder(sources, mainFile)) {
49
+ const directives = sources.get(sourcePath);
50
+ if (!directives)
51
+ continue;
52
+ for (const bibliography of directives.bibliographies) {
53
+ const resolved = resolveProjectReferencePath(bibliography, sourcePath, ".bib", availableBibliographies, true);
54
+ if (resolved)
55
+ declared.push(resolved);
56
+ }
57
+ }
58
+ return uniqueProjectPaths(declared);
59
+ }
60
+ export function resolveProjectReferencePath(rawPath, currentPath, extension, available, rootFirst) {
61
+ const raw = normalizeReferenceInput(rawPath);
62
+ if (!raw)
63
+ return null;
64
+ const parent = path.posix.dirname(currentPath);
65
+ // TeX resolves relative files against the file that declared them. A raw
66
+ // ../ reference is valid when this final candidate remains inside the
67
+ // project tree (for example chapters/intro.tex -> ../refs.bib).
68
+ const relative = normalizeProjectPath(parent === "." ? raw : path.posix.join(parent, raw));
69
+ const root = normalizeProjectPath(raw);
70
+ const roots = rootFirst ? [root, relative] : [relative, root];
71
+ const candidates = roots.flatMap((candidate) => {
72
+ if (!candidate)
73
+ return [];
74
+ return candidate.toLowerCase().endsWith(extension)
75
+ ? [candidate]
76
+ : [candidate, candidate + extension];
77
+ });
78
+ for (const candidate of uniqueProjectPaths(candidates)) {
79
+ if (available.has(candidate))
80
+ return candidate;
81
+ }
82
+ return null;
83
+ }
84
+ export function uniqueProjectPaths(paths) {
85
+ const seen = new Set();
86
+ return paths.filter((entryPath) => {
87
+ if (!entryPath || seen.has(entryPath))
88
+ return false;
89
+ seen.add(entryPath);
90
+ return true;
91
+ });
92
+ }
93
+ function normalizeReferenceInput(value) {
94
+ const trimmed = value.trim().replaceAll("\\", "/");
95
+ if (!trimmed || trimmed.startsWith("/") || /^[A-Za-z]:\//.test(trimmed) || trimmed.includes("\0"))
96
+ return null;
97
+ return trimmed;
98
+ }
99
+ function normalizeProjectPath(value) {
100
+ const normalized = path.posix.normalize(value);
101
+ if (normalized === "." || normalized === ".." || normalized.startsWith("../"))
102
+ return null;
103
+ if (normalized.split("/").some((segment) => segment.toLowerCase() === ".git"))
104
+ return null;
105
+ return normalized;
106
+ }