@moritzwilksch/diffle 0.2.5 → 0.2.7

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 (83) hide show
  1. package/README.md +5 -4
  2. package/dist/client/assets/{angular-html-40_PP5IN.js → angular-html-DosOYpNl.js} +1 -1
  3. package/dist/client/assets/{angular-ts-Cg4RjOSa.js → angular-ts-DkqBGDar.js} +1 -1
  4. package/dist/client/assets/{apl-DF-eBnzw.js → apl-DhBUfBJf.js} +1 -1
  5. package/dist/client/assets/{astro-BOvFneDi.js → astro-C9ZTzAue.js} +1 -1
  6. package/dist/client/assets/{blade-Vanj6sax.js → blade-B1PWKc0N.js} +1 -1
  7. package/dist/client/assets/{c-DRaVis1k.js → c-DBA56zb-.js} +1 -1
  8. package/dist/client/assets/{chapel-DmJmjyke.js → chapel-ChRt-4dY.js} +1 -1
  9. package/dist/client/assets/{cobol-CZijWbs8.js → cobol-B4Ub-iW1.js} +1 -1
  10. package/dist/client/assets/{coffee-C7ADU3ov.js → coffee-x1BuBd_Q.js} +1 -1
  11. package/dist/client/assets/{cpp-CUnyhPxl.js → cpp-w_0aDvpB.js} +1 -1
  12. package/dist/client/assets/{crystal-CtVeCajj.js → crystal-ClQ0qP1s.js} +1 -1
  13. package/dist/client/assets/{css-Bgit8wl7.js → css-DC2faGEc.js} +1 -1
  14. package/dist/client/assets/{edge-Co1HUhdJ.js → edge-DmJBQ2gv.js} +1 -1
  15. package/dist/client/assets/{elixir-fkbKJn63.js → elixir-DCLVXjiT.js} +1 -1
  16. package/dist/client/assets/{elm-Uv71dj5J.js → elm-DlbDR8QF.js} +1 -1
  17. package/dist/client/assets/{erb-DDWRP60u.js → erb-DMRo3oW4.js} +1 -1
  18. package/dist/client/assets/{git-rebase-CQZTa8sS.js → git-rebase-DWIcl-q4.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-DTm3z0uF.js → glimmer-js-DUaEeoFR.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-BVwvohFz.js → glimmer-ts-6tsiEBq-.js} +1 -1
  21. package/dist/client/assets/{glsl-Vqc6iypF.js → glsl-DdOwn0FP.js} +1 -1
  22. package/dist/client/assets/{graphql-C_PazajP.js → graphql-IJK_VTno.js} +1 -1
  23. package/dist/client/assets/{hack-KQ3tbVQ0.js → hack-DifbMrOT.js} +1 -1
  24. package/dist/client/assets/{haml-DZsm7DhW.js → haml-DeQ6UFH5.js} +1 -1
  25. package/dist/client/assets/{handlebars-BeI0Z9Ge.js → handlebars-BKpjBAcW.js} +1 -1
  26. package/dist/client/assets/{html-CtB5Q44Y.js → html-CTtX_fVs.js} +1 -1
  27. package/dist/client/assets/{html-derivative-WXvMfLLj.js → html-derivative-C6nfxO2F.js} +1 -1
  28. package/dist/client/assets/{http-C6pj3MSd.js → http-eQW7tfW5.js} +1 -1
  29. package/dist/client/assets/{hurl-BrHyymJJ.js → hurl-DVJuWXn_.js} +1 -1
  30. package/dist/client/assets/index-CqE8hk_s.js +1833 -0
  31. package/dist/client/assets/index-DZQ9igGc.css +2 -0
  32. package/dist/client/assets/{java-CPaOvu3h.js → java-ClwPIFd_.js} +1 -1
  33. package/dist/client/assets/{javascript-BPRT6jkl.js → javascript-D-3TtS2j.js} +1 -1
  34. package/dist/client/assets/{jinja-DTGLaj2c.js → jinja-B-6OnCvz.js} +1 -1
  35. package/dist/client/assets/{jison-DA5wRqQC.js → jison-BmwL1UCC.js} +1 -1
  36. package/dist/client/assets/{json-ED0VVZPE.js → json-D8CIZ6_Z.js} +1 -1
  37. package/dist/client/assets/{jsx-ssUbs28C.js → jsx-hHZI2uuq.js} +1 -1
  38. package/dist/client/assets/{julia-DLNyC4Uk.js → julia-CePjwP87.js} +1 -1
  39. package/dist/client/assets/{just-R9Z5Mjrx.js → just-BPRk2cx_.js} +1 -1
  40. package/dist/client/assets/{latex-BXG3aIes.js → latex-lrY1zFXa.js} +1 -1
  41. package/dist/client/assets/{liquid-CdepfROv.js → liquid-5ePjgMO9.js} +1 -1
  42. package/dist/client/assets/{lua-xbhHkSeE.js → lua-1rnc87r4.js} +1 -1
  43. package/dist/client/assets/{marko-BmP-9r68.js → marko-IKwBj9dr.js} +1 -1
  44. package/dist/client/assets/{mdc-URCCWXDZ.js → mdc-o7rCfvqw.js} +1 -1
  45. package/dist/client/assets/{nginx-CNVoVwWe.js → nginx-BsG6mAEE.js} +1 -1
  46. package/dist/client/assets/{nim-DcrAx62m.js → nim-v9Re54Nf.js} +1 -1
  47. package/dist/client/assets/{org-Cve18kq6.js → org-QPi2LyaR.js} +1 -1
  48. package/dist/client/assets/{perl-CNeZNpSH.js → perl-Bp-K4EZR.js} +1 -1
  49. package/dist/client/assets/{php-B_yAcPWV.js → php-9fs1O83G.js} +1 -1
  50. package/dist/client/assets/{pug-KzcMA0F1.js → pug-BObrWPFa.js} +1 -1
  51. package/dist/client/assets/{qml-BiamCZ_t.js → qml-CtVRO65R.js} +1 -1
  52. package/dist/client/assets/{r-DR_qiHtN.js → r-BhAilYXn.js} +1 -1
  53. package/dist/client/assets/{razor-CWQHCbLD.js → razor-r2okdCFg.js} +1 -1
  54. package/dist/client/assets/{regexp-BX6N0GwV.js → regexp-Du1vBRb3.js} +1 -1
  55. package/dist/client/assets/{rst-Cphc8U5i.js → rst-Bh3lie48.js} +1 -1
  56. package/dist/client/assets/{ruby-e-dOujhw.js → ruby-vs3FXmpi.js} +1 -1
  57. package/dist/client/assets/{sas-CLccRu7Y.js → sas-DkxBsIWw.js} +1 -1
  58. package/dist/client/assets/{scss-DddDFRbZ.js → scss-dEqn8th9.js} +1 -1
  59. package/dist/client/assets/{shellscript-BRMFd79r.js → shellscript-DuK80VLO.js} +1 -1
  60. package/dist/client/assets/{shellsession-mBDRZscv.js → shellsession-K16miZZV.js} +1 -1
  61. package/dist/client/assets/{soy-CWg8ljNL.js → soy-DseiXgsm.js} +1 -1
  62. package/dist/client/assets/{sql-Bpvi_qeu.js → sql-aXIOcbM3.js} +1 -1
  63. package/dist/client/assets/{stata-DmDI8_ZD.js → stata-CEH4kh2r.js} +1 -1
  64. package/dist/client/assets/{surrealql-BK43wnCn.js → surrealql-COLqIpYI.js} +1 -1
  65. package/dist/client/assets/{svelte-CqNHamjt.js → svelte-Bv_6pJZf.js} +1 -1
  66. package/dist/client/assets/{templ-CXX6LdND.js → templ-BK2D_AFL.js} +1 -1
  67. package/dist/client/assets/{tex-B5fWHM8G.js → tex--R7BXLUz.js} +1 -1
  68. package/dist/client/assets/{ts-tags-B4vli5O4.js → ts-tags-C_tPUBdU.js} +1 -1
  69. package/dist/client/assets/{tsx-BuPcRuDt.js → tsx-CkHIsN3A.js} +1 -1
  70. package/dist/client/assets/{twig-Dde1JkFC.js → twig-Br2AoIs8.js} +1 -1
  71. package/dist/client/assets/{typescript-BSNd5gmZ.js → typescript-C733jxER.js} +1 -1
  72. package/dist/client/assets/{typst-6P_s0OU3.js → typst-1qUjIU5z.js} +1 -1
  73. package/dist/client/assets/{vue-CDLX_0xc.js → vue-DtvZUYyv.js} +1 -1
  74. package/dist/client/assets/{vue-html-DOu8fRko.js → vue-html-Db1X0s7Z.js} +1 -1
  75. package/dist/client/assets/{vue-vine-Bi0JP7vZ.js → vue-vine-DQulvS_V.js} +1 -1
  76. package/dist/client/assets/{xml-BT2xddIz.js → xml-DY9-QC9A.js} +1 -1
  77. package/dist/client/assets/{xsl-BAcHcoe6.js → xsl-LICYc3rB.js} +1 -1
  78. package/dist/client/assets/{yaml-Dqw9UXyh.js → yaml-DmBUKFWe.js} +1 -1
  79. package/dist/client/index.html +2 -2
  80. package/dist/server/main.js +1809 -1122
  81. package/package.json +7 -1
  82. package/dist/client/assets/index-DLiMd279.css +0 -2
  83. package/dist/client/assets/index-WLpwHHb8.js +0 -1787
@@ -2,8 +2,8 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { EventEmitter } from "node:events";
4
4
  import childProcess, { execFile, spawn } from "node:child_process";
5
- import path, { delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
- import fs, { accessSync, constants, existsSync, readFileSync, realpathSync, statSync } from "node:fs";
5
+ import path, { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
6
+ import fs, { accessSync, constants, existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
7
7
  import process$1 from "node:process";
8
8
  import { stripVTControlCharacters } from "node:util";
9
9
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -3952,7 +3952,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
3952
3952
  }
3953
3953
  //#endregion
3954
3954
  //#region package.json
3955
- var version$1 = "0.2.5";
3955
+ var version$1 = "0.2.7";
3956
3956
  //#endregion
3957
3957
  //#region src/server/comments/anchor.ts
3958
3958
  function splitLines(contents) {
@@ -11401,7 +11401,7 @@ const ZodType = /*@__PURE__*/ $constructor("ZodType", (inst, def) => {
11401
11401
  return array(this);
11402
11402
  },
11403
11403
  or(arg) {
11404
- return union([this, arg]);
11404
+ return union$1([this, arg]);
11405
11405
  },
11406
11406
  and(arg) {
11407
11407
  return intersection(this, arg);
@@ -11950,7 +11950,7 @@ const ZodUnion = /*@__PURE__*/ $constructor("ZodUnion", (inst, def) => {
11950
11950
  inst._zod.processJSONSchema = (ctx, json, params) => unionProcessor(inst, ctx, json, params);
11951
11951
  inst.options = def.options;
11952
11952
  });
11953
- function union(options, params) {
11953
+ function union$1(options, params) {
11954
11954
  return new ZodUnion({
11955
11955
  type: "union",
11956
11956
  options,
@@ -12238,10 +12238,14 @@ function superRefine(fn, params) {
12238
12238
  * server is told in `didOpen`, so it must be the spec's spelling, not ours.
12239
12239
  */
12240
12240
  const LanguageIdSchema = _enum([
12241
+ "astro",
12241
12242
  "c",
12242
12243
  "cpp",
12244
+ "csharp",
12245
+ "css",
12243
12246
  "go",
12244
12247
  "haskell",
12248
+ "html",
12245
12249
  "java",
12246
12250
  "javascript",
12247
12251
  "javascriptreact",
@@ -12260,6 +12264,7 @@ const LanguageIdSchema = _enum([
12260
12264
  "toml",
12261
12265
  "typescript",
12262
12266
  "typescriptreact",
12267
+ "vue",
12263
12268
  "yaml",
12264
12269
  "zig"
12265
12270
  ]);
@@ -12681,6 +12686,8 @@ object({
12681
12686
  contextLines: number(),
12682
12687
  /** When a review between refs recomputes as its refs move; see `FollowRefsSchema`. Default auto. */
12683
12688
  followRefs: FollowRefsSchema,
12689
+ /** Wrap long lines in the viewer instead of scrolling them sideways. Default true. */
12690
+ lineWrap: boolean(),
12684
12691
  /**
12685
12692
  * Language server command per language, replacing the built-in candidate for it; an empty
12686
12693
  * string disables the language. Read-only over HTTP; set with `diffle config set-lsp`.
@@ -12691,6 +12698,7 @@ const DEFAULT_USER_CONFIG = {
12691
12698
  autoViewed: [],
12692
12699
  contextLines: 5,
12693
12700
  followRefs: "auto",
12701
+ lineWrap: true,
12694
12702
  lspCommands: {}
12695
12703
  };
12696
12704
  /** A point in a new-side file. LSP semantics: the column counts UTF-16 units, like a JS string index. */
@@ -12710,8 +12718,8 @@ const LspLocationSchema = LspPositionSchema.extend({
12710
12718
  */
12711
12719
  external: literal(true).optional()
12712
12720
  });
12713
- /** A process plus the languages the pool routes to it. */
12714
- const LspServerStatusSchema = object({
12721
+ /** One language server process, as its bridge sees it. */
12722
+ const LspProcessStatusSchema = object({
12715
12723
  /** Program name, for messages: the basename of the command's first word, e.g. `pyrefly`. */
12716
12724
  name: string(),
12717
12725
  /** The command line it was started from. */
@@ -12725,19 +12733,83 @@ const LspServerStatusSchema = object({
12725
12733
  message: string().optional(),
12726
12734
  /** Active work reported through LSP work-done progress; absent when none is reported. */
12727
12735
  activity: string().array().optional(),
12728
- /** Last warning or error reported by the server, not a process failure. */
12736
+ /**
12737
+ * What the server last said to the reader through `window/showMessage`, when it was a
12738
+ * warning or an error. Its log never stands here: that is `log`.
12739
+ */
12729
12740
  notice: object({
12730
12741
  severity: _enum(["warning", "error"]),
12731
12742
  message: string()
12732
12743
  }).optional(),
12733
- /** Bounded stderr tail for servers that report failures only through their process output. */
12734
- stderr: string().optional()
12735
- }).extend({ languages: LanguageIdSchema.array() });
12744
+ /** The server's output channel in brief; `GET /api/lsp/log` lists the lines. */
12745
+ log: object({
12746
+ /** Changes whenever a line is added or repeated, so a reader showing the log knows to fetch again. */
12747
+ seq: number().int().nonnegative(),
12748
+ /** Error and warning lines since the server started, kept or not. */
12749
+ errors: number().int().nonnegative(),
12750
+ warnings: number().int().nonnegative()
12751
+ })
12752
+ });
12753
+ /** Where a log line came from: `window/showMessage`, `window/logMessage`, or the process's stderr. */
12754
+ const LspLogSourceSchema = _enum([
12755
+ "message",
12756
+ "log",
12757
+ "stderr"
12758
+ ]);
12759
+ const LspLogLevelSchema = _enum([
12760
+ "error",
12761
+ "warning",
12762
+ "info",
12763
+ "log"
12764
+ ]);
12765
+ /** One line of a server's output channel. The bridge keeps a bounded number of recent ones. */
12766
+ const LspLogEntrySchema = object({
12767
+ /** Stable identity of the line within the run. */
12768
+ id: number().int().positive(),
12769
+ /** Milliseconds since the epoch, of its latest repeat. */
12770
+ time: number(),
12771
+ source: LspLogSourceSchema,
12772
+ level: LspLogLevelSchema,
12773
+ message: string(),
12774
+ /** How many times in a row the server said exactly this. */
12775
+ count: number().int().positive()
12776
+ });
12777
+ /** `GET /api/lsp/log?server=<command>`: the retained lines of one server, oldest first. */
12778
+ const LspLogQuerySchema = object({ server: string().min(1) });
12779
+ object({ entries: LspLogEntrySchema.array() });
12780
+ /**
12781
+ * A request the bridge sends a server only once it advertised the capability, in its
12782
+ * `initialize` result or by registering it later, as an editor does. Document highlights
12783
+ * are advisory: without them the answer is empty, never an error.
12784
+ */
12785
+ const LspFeatureSchema = _enum([
12786
+ "definition",
12787
+ "typeDefinition",
12788
+ "hover",
12789
+ "references",
12790
+ "documentSymbol",
12791
+ "workspaceSymbol",
12792
+ "documentHighlight"
12793
+ ]);
12794
+ /** A process plus the languages the pool routes to it. */
12795
+ const LspServerStatusSchema = LspProcessStatusSchema.extend({
12796
+ /** Languages it owns: asked first, and alone for highlights, token kinds and outlines. */
12797
+ languages: LanguageIdSchema.array(),
12798
+ /** Languages it answers for beside their owner, such as Tailwind's class hovers in markup. */
12799
+ joins: LanguageIdSchema.array(),
12800
+ /** What its `initialize` result advertised; empty while starting. */
12801
+ supports: LspFeatureSchema.array()
12802
+ });
12736
12803
  /** A language in the diff with no server behind it. */
12737
12804
  const LspMissingSchema = object({
12738
12805
  language: LanguageIdSchema,
12739
12806
  /** Programs looked for on PATH, so the UI can name what to install. Empty when config turned the language off. */
12740
- tried: string().array()
12807
+ tried: string().array(),
12808
+ /**
12809
+ * A program that is on PATH but lacks what the server loads from beside it, such as a
12810
+ * `vue-language-server` without its tsserver plugin: installing more is not the answer.
12811
+ */
12812
+ why: string().optional()
12741
12813
  });
12742
12814
  const LspStatusSchema = object({
12743
12815
  /** False with `--no-lsp`: no server will start for this run. */
@@ -12746,7 +12818,16 @@ const LspStatusSchema = object({
12746
12818
  servers: LspServerStatusSchema.array(),
12747
12819
  missing: LspMissingSchema.array()
12748
12820
  });
12749
- object({ locations: LspLocationSchema.array() });
12821
+ /**
12822
+ * Why an empty answer may be incomplete: the server reported background work (indexing, loading
12823
+ * the workspace) while answering, or a server that would also have answered was unavailable.
12824
+ * Never set beside a non-empty answer.
12825
+ */
12826
+ const inconclusive$1 = string().optional();
12827
+ object({
12828
+ locations: LspLocationSchema.array(),
12829
+ inconclusive: inconclusive$1
12830
+ });
12750
12831
  object({
12751
12832
  /** Markdown; null when the server has nothing for the position. */
12752
12833
  contents: string().nullable(),
@@ -12756,11 +12837,9 @@ object({
12756
12837
  col: number(),
12757
12838
  endLine: number(),
12758
12839
  endCol: number()
12759
- }).optional()
12840
+ }).optional(),
12841
+ inconclusive: inconclusive$1
12760
12842
  });
12761
- object({
12762
- /** Null when the position is not inside a token or the server does not classify tokens. */
12763
- kind: string().nullable() });
12764
12843
  object({ occurrences: object({
12765
12844
  line: number().int().positive(),
12766
12845
  col: number().int().nonnegative(),
@@ -12774,16 +12853,19 @@ object({ occurrences: object({
12774
12853
  ])
12775
12854
  }).array() });
12776
12855
  object({
12777
- name: string(),
12778
- /** LSP SymbolKind number; the client maps it to a label. */
12779
- kind: number(),
12780
- /** Enclosing class or function, when any. */
12781
- container: string().optional(),
12782
- path: string(),
12783
- line: number(),
12784
- /** Last line of the declaration's full range. */
12785
- endLine: number(),
12786
- col: number()
12856
+ symbols: object({
12857
+ name: string(),
12858
+ /** LSP SymbolKind number; the client maps it to a label. */
12859
+ kind: number(),
12860
+ /** Enclosing class or function, when any. */
12861
+ container: string().optional(),
12862
+ path: string(),
12863
+ line: number(),
12864
+ /** Last line of the declaration's full range. */
12865
+ endLine: number(),
12866
+ col: number()
12867
+ }).array(),
12868
+ inconclusive: inconclusive$1
12787
12869
  });
12788
12870
  _enum([
12789
12871
  "unviewed",
@@ -12813,11 +12895,14 @@ function followsCheckout(snap) {
12813
12895
  }
12814
12896
  /** Extension (no dot, lowercase) → language. `.h` goes to c because clangd serves both. */
12815
12897
  const LANGUAGE_BY_EXT = {
12898
+ astro: "astro",
12816
12899
  bash: "shellscript",
12817
12900
  c: "c",
12818
12901
  cc: "cpp",
12819
12902
  cjs: "javascript",
12820
12903
  cpp: "cpp",
12904
+ cs: "csharp",
12905
+ css: "css",
12821
12906
  cts: "typescript",
12822
12907
  cxx: "cpp",
12823
12908
  go: "go",
@@ -12825,6 +12910,8 @@ const LANGUAGE_BY_EXT = {
12825
12910
  hh: "cpp",
12826
12911
  hpp: "cpp",
12827
12912
  hs: "haskell",
12913
+ htm: "html",
12914
+ html: "html",
12828
12915
  hxx: "cpp",
12829
12916
  java: "java",
12830
12917
  js: "javascript",
@@ -12849,6 +12936,7 @@ const LANGUAGE_BY_EXT = {
12849
12936
  toml: "toml",
12850
12937
  ts: "typescript",
12851
12938
  tsx: "typescriptreact",
12939
+ vue: "vue",
12852
12940
  yaml: "yaml",
12853
12941
  yml: "yaml",
12854
12942
  zig: "zig"
@@ -12860,40 +12948,12 @@ function languageOf(path) {
12860
12948
  const ext = /\.([^./\\]+)$/.exec(path)?.[1]?.toLowerCase();
12861
12949
  return ext && LANGUAGE_BY_EXT[ext] || null;
12862
12950
  }
12863
- /**
12864
- * Why the language server cannot answer for `path` — or, with no path, for a
12865
- * repository-wide request — and null when it can. Shared so the client's flash and the
12866
- * API's 409 say the same thing. The caller checks `followsCheckout` itself.
12867
- */
12868
- function lspBlocker(lsp, path) {
12869
- if (!lsp.enabled) return "Language servers are off for this run (--no-lsp)";
12870
- if (path == null) {
12871
- if (!lsp.servers.length) return noServer(lsp);
12872
- return lsp.servers.some((s) => s.state === "ready") ? null : notReady(lsp.servers[0]);
12873
- }
12874
- const language = languageOf(path);
12875
- if (!language) return `No language server for ${/(\.[^./\\]+)$/.exec(path)?.[1] ?? "these"} files`;
12876
- const server = lsp.servers.find((s) => s.languages.includes(language));
12877
- if (!server) return noServer(lsp, language);
12878
- return server.state === "ready" ? null : notReady(server);
12879
- }
12880
- function notReady(s) {
12881
- return s.state === "starting" ? `${s.name} is starting…` : `${s.name} is unavailable: ${s.message ?? "unknown error"}`;
12882
- }
12883
- /** No server serves `language` (or nothing runs at all): say what the reader can do about it. */
12884
- function noServer(lsp, language) {
12885
- const missing = language == null ? lsp.missing : lsp.missing.filter((m) => m.language === language);
12886
- const absent = missing.filter((m) => m.tried.length > 0);
12887
- const off = missing.filter((m) => m.tried.length === 0).map((m) => m.language);
12888
- if (absent.length) return `No language server for ${[...new Set(absent.map((m) => m.language))].join(", ")} on PATH: install one of ${[...new Set(absent.flatMap((m) => m.tried))].join(", ")}`;
12889
- if (off.length) return `The language server for ${off.join(", ")} is off in your diffle config`;
12890
- return language == null ? "No language server runs for this diff" : `No language server runs for ${language}`;
12891
- }
12892
12951
  /** Writable HTTP config fields; unknown keys, including CLI-only lspCommands, are stripped. */
12893
12952
  const ConfigUpdateSchema = object({
12894
12953
  autoViewed: string().array().optional(),
12895
12954
  contextLines: number().int().min(0).max(1e4).optional(),
12896
- followRefs: FollowRefsSchema.optional()
12955
+ followRefs: FollowRefsSchema.optional(),
12956
+ lineWrap: boolean().optional()
12897
12957
  });
12898
12958
  const ResolvedRequestSchema = object({ resolved: boolean() });
12899
12959
  const ViewedBulkRequestSchema = object({ entries: ViewedEntrySchema.array() });
@@ -13009,46 +13069,158 @@ function fuzzyRank(items, query, key) {
13009
13069
  return [...matched, ...items.filter((item) => !kept.has(item))];
13010
13070
  }
13011
13071
  //#endregion
13012
- //#region src/server/lsp/hover.ts
13072
+ //#region src/shared/lsp.ts
13073
+ /** How messages name a feature: "pyrefly offers no go to definition". */
13074
+ const LSP_FEATURE_LABELS = {
13075
+ definition: "go to definition",
13076
+ typeDefinition: "go to type definition",
13077
+ hover: "hover",
13078
+ references: "references",
13079
+ documentSymbol: "document symbols",
13080
+ workspaceSymbol: "workspace symbols",
13081
+ documentHighlight: "document highlights"
13082
+ };
13013
13083
  /**
13014
- * One markdown document for the tooltip, or null when the server sent nothing
13015
- * worth showing. Plaintext and `{language, value}` parts become fenced blocks,
13016
- * so their text is never re-read as markdown.
13084
+ * Why no language server can answer the query right now, and null when one can. The one
13085
+ * place that reasons about the run, the snapshot and the servers together, so the
13086
+ * client's flash and the API's 409 say the same thing. A null `snap` is one not loaded yet.
13017
13087
  */
13018
- function hoverMarkdown(contents) {
13019
- if (contents == null) return null;
13020
- const parts = (Array.isArray(contents) ? contents : [contents]).map(partMarkdown).filter((p) => p.trim() !== "");
13021
- return parts.length ? parts.join("\n\n") : null;
13088
+ function lspBlocker(lsp, snap, { path, feature, patient = false } = {}) {
13089
+ if (!lsp.enabled) return "Language servers are off for this run (--no-lsp)";
13090
+ if (!snap || !followsCheckout(snap)) return "Symbol navigation needs the new side to be the worktree or the checked-out commit";
13091
+ let servers = lsp.servers;
13092
+ let language;
13093
+ if (path != null) {
13094
+ language = languageOf(path) ?? void 0;
13095
+ if (!language) return `No language server for ${/(\.[^./\\]+)$/.exec(path)?.[1] ?? "these"} files`;
13096
+ servers = serversFor(lsp, language);
13097
+ }
13098
+ if (!servers.length) return noServer(lsp, language);
13099
+ const able = feature ? servers.filter((s) => s.state === "starting" || s.supports.includes(feature)) : servers;
13100
+ if (!able.length) return `${servers.map((s) => s.name).join(", ")} offers no ${LSP_FEATURE_LABELS[feature]}`;
13101
+ return able.some((s) => s.state === "ready" || patient && s.state === "starting") ? null : notReady(able[0]);
13102
+ }
13103
+ /** The servers answering for `language`, its owners before the ones joining it. */
13104
+ function serversFor(lsp, language) {
13105
+ return [...lsp.servers.filter((s) => s.languages.includes(language)), ...lsp.servers.filter((s) => s.joins.includes(language))];
13106
+ }
13107
+ /** A server's languages for a status line: the ones it owns, then the ones it joins marked `+`. */
13108
+ function languagesLabel(server) {
13109
+ return [...server.languages, ...server.joins.map((l) => `+${l}`)].join(", ");
13022
13110
  }
13023
- function partMarkdown(part) {
13024
- if (typeof part === "string") return part;
13025
- if ("kind" in part) return part.kind === "markdown" ? part.value : fence(part.value, "");
13026
- return fence(part.value, part.language);
13111
+ function notReady(s) {
13112
+ return s.state === "starting" ? `${s.name} is starting…` : `${s.name} is unavailable: ${s.message ?? "unknown error"}`;
13027
13113
  }
13028
- /** A fence long enough that a backtick run inside the code cannot close it early. */
13029
- function fence(code, lang) {
13030
- const longest = Math.max(2, ...[...code.matchAll(/`+/g)].map((m) => m[0].length));
13031
- const ticks = "`".repeat(longest + 1);
13032
- return `${ticks}${lang}\n${code.replace(/\n$/, "")}\n${ticks}`;
13114
+ /** No server serves `language` (or nothing runs at all): say what the reader can do about it. */
13115
+ function noServer(lsp, language) {
13116
+ const missing = language == null ? lsp.missing : lsp.missing.filter((m) => m.language === language);
13117
+ const broken = missing.find((m) => m.why);
13118
+ if (broken) return `No language server for ${broken.language}: ${broken.why}`;
13119
+ const absent = missing.filter((m) => m.tried.length > 0);
13120
+ const off = missing.filter((m) => m.tried.length === 0).map((m) => m.language);
13121
+ if (absent.length) return `No language server for ${[...new Set(absent.map((m) => m.language))].join(", ")} on PATH: install one of ${[...new Set(absent.flatMap((m) => m.tried))].join(", ")}`;
13122
+ if (off.length) return `The language server for ${off.join(", ")} is off in your diffle config`;
13123
+ return language == null ? "No language server runs for this diff" : `No language server runs for ${language}`;
13033
13124
  }
13034
- /** An inline markdown link whose target is a `file:` URI, as pyright and pyrefly write "Go to X". */
13035
- const fileLink = () => /\[([^\]]*)\]\(\s*<?(file:[^\s)>]+)>?\s*\)/g;
13036
- /** The `file:` URIs the hover text links to, each once. */
13037
- function fileLinkUris(md) {
13038
- return [...new Set([...md.matchAll(fileLink())].map((m) => m[2]))];
13125
+ //#endregion
13126
+ //#region src/server/persist.ts
13127
+ /** A holder older than this is presumed hung, even when its pid is alive. */
13128
+ const LOCK_STALE_MS = 3e4;
13129
+ const LOCK_TIMEOUT_MS = 1e4;
13130
+ let seq = 0;
13131
+ /**
13132
+ * Writes `text` to `file` atomically: unique temp file in the same directory,
13133
+ * then rename. Creates the directory owner-only; the file gets `mode`. A failed
13134
+ * write leaves no temp file behind.
13135
+ */
13136
+ async function writeFileAtomic(file, text, mode = 384) {
13137
+ await mkdir(dirname(file), {
13138
+ recursive: true,
13139
+ mode: 448
13140
+ });
13141
+ const tmp = `${file}.${process.pid}.${++seq}.${randomBytes(4).toString("hex")}.tmp`;
13142
+ try {
13143
+ await writeFile(tmp, text, {
13144
+ encoding: "utf8",
13145
+ mode
13146
+ });
13147
+ await rename(tmp, file);
13148
+ } catch (e) {
13149
+ await unlink(tmp).catch(() => {});
13150
+ throw e;
13151
+ }
13039
13152
  }
13153
+ const inProcess = /* @__PURE__ */ new Map();
13040
13154
  /**
13041
- * Rewrites the hover text's `file:` links to `diffle:<path>#L<line>`, which the client follows as an
13042
- * in-app jump. `resolved` maps each URI to its repo-relative path and line; a URI it omits lies
13043
- * outside the repository and becomes plain text, since opening a browser tab on a source file is
13044
- * never what the reader wants.
13155
+ * Runs `fn` while holding `<file>.lock`, exclusive across processes on this
13156
+ * machine and serialized within this one. A lock whose pid is dead, or older
13157
+ * than 30s, is taken over; waiting longer than 10s throws. The lock is
13158
+ * released whether `fn` resolves or rejects.
13045
13159
  */
13046
- function localizeFileLinks(md, resolved) {
13047
- return md.replace(fileLink(), (_m, label, uri) => {
13048
- const loc = resolved.get(uri);
13049
- if (!loc) return label;
13050
- return `[${label}](diffle:${encodeURI(loc.path)}${loc.line != null ? `#L${loc.line}` : ""})`;
13160
+ async function withFileLock(file, fn) {
13161
+ const prev = inProcess.get(file) ?? Promise.resolve();
13162
+ let release;
13163
+ const chain = prev.then(() => new Promise((r) => release = r));
13164
+ inProcess.set(file, chain);
13165
+ await prev;
13166
+ const lock = `${file}.lock`;
13167
+ try {
13168
+ await acquire(lock);
13169
+ try {
13170
+ return await fn();
13171
+ } finally {
13172
+ await unlink(lock).catch(() => {});
13173
+ }
13174
+ } finally {
13175
+ release();
13176
+ if (inProcess.get(file) === chain) inProcess.delete(file);
13177
+ }
13178
+ }
13179
+ async function acquire(lock) {
13180
+ await mkdir(dirname(lock), {
13181
+ recursive: true,
13182
+ mode: 448
13051
13183
  });
13184
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
13185
+ for (;;) {
13186
+ try {
13187
+ const fh = await open(lock, "wx", 384);
13188
+ try {
13189
+ await fh.writeFile(`${process.pid}\n`);
13190
+ } finally {
13191
+ await fh.close();
13192
+ }
13193
+ return;
13194
+ } catch (e) {
13195
+ if (e.code !== "EEXIST") throw e;
13196
+ }
13197
+ if (await isStale(lock)) {
13198
+ await unlink(lock).catch(() => {});
13199
+ continue;
13200
+ }
13201
+ if (Date.now() > deadline) throw new Error(`timed out waiting for ${lock}`);
13202
+ await new Promise((r) => setTimeout(r, 10 + Math.random() * 20));
13203
+ }
13204
+ }
13205
+ /** True when the holder is gone: dead pid, or too old to trust. A lock with no pid yet is being taken. */
13206
+ async function isStale(lock) {
13207
+ let st;
13208
+ let text;
13209
+ try {
13210
+ [st, text] = await Promise.all([stat$1(lock), readFile(lock, "utf8")]);
13211
+ } catch (e) {
13212
+ if (e.code === "ENOENT") return false;
13213
+ throw e;
13214
+ }
13215
+ if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return true;
13216
+ const pid = Number.parseInt(text, 10);
13217
+ if (!Number.isFinite(pid) || pid <= 0) return false;
13218
+ try {
13219
+ process.kill(pid, 0);
13220
+ return false;
13221
+ } catch (e) {
13222
+ return e.code === "ESRCH";
13223
+ }
13052
13224
  }
13053
13225
  //#endregion
13054
13226
  //#region src/server/lsp/JsonRpc.ts
@@ -13106,8 +13278,8 @@ var JsonRpcConnection = class {
13106
13278
  params
13107
13279
  });
13108
13280
  }
13109
- /** Observes server messages, including requests. */
13110
- onNotification(handler) {
13281
+ /** Observes every message the server sends: notifications and requests alike. */
13282
+ onMessage(handler) {
13111
13283
  this.handlers.add(handler);
13112
13284
  }
13113
13285
  /** Handles a server request and returns its result, including asynchronous results. */
@@ -13146,10 +13318,10 @@ var JsonRpcConnection = class {
13146
13318
  if (this.buffer.length < start + length) return;
13147
13319
  const body = this.buffer.subarray(start, start + length).toString("utf8");
13148
13320
  this.buffer = this.buffer.subarray(start + length);
13149
- this.onMessage(body);
13321
+ this.receive(body);
13150
13322
  }
13151
13323
  }
13152
- onMessage(body) {
13324
+ receive(body) {
13153
13325
  let msg;
13154
13326
  try {
13155
13327
  msg = JSON.parse(body);
@@ -13186,552 +13358,497 @@ var JsonRpcConnection = class {
13186
13358
  }
13187
13359
  };
13188
13360
  //#endregion
13189
- //#region src/server/lsp/which.ts
13190
- /**
13191
- * The program a shell command line runs: its first word, unquoted. Only ever asked
13192
- * about the built-in candidates, which are plain `prog arg…` lines; a command with a
13193
- * leading assignment or a pipeline has no single program and is never probed (see
13194
- * `resolveServers`).
13195
- */
13196
- function argv0(command) {
13197
- const s = command.trim();
13198
- const quote = s[0];
13199
- if (quote === "\"" || quote === "'") {
13200
- const end = s.indexOf(quote, 1);
13201
- return end === -1 ? s.slice(1) : s.slice(1, end);
13202
- }
13203
- return s.split(/\s/, 1)[0] ?? "";
13361
+ //#region src/server/lsp/configuration.ts
13362
+ const SCHEMA_CATALOG = "https://www.schemastore.org/api/json/catalog.json";
13363
+ const MAX_AGE = 864e5;
13364
+ const AssociationSchema = object({
13365
+ url: url({ protocol: /^https?$/ }),
13366
+ fileMatch: array(string()).nonempty()
13367
+ });
13368
+ const SchemaCatalogSchema = object({ schemas: array(unknown()) });
13369
+ const ConfigurationParamsSchema = object({ items: array(object({ section: string().optional() })) });
13370
+ /** Valid catalog entries, in the association format understood by the JSON server. */
13371
+ function schemaAssociations(value) {
13372
+ const catalog = SchemaCatalogSchema.safeParse(value);
13373
+ if (!catalog.success) return [];
13374
+ return catalog.data.schemas.flatMap((entry) => {
13375
+ const association = AssociationSchema.safeParse(entry);
13376
+ return association.success ? [association.data] : [];
13377
+ });
13204
13378
  }
13205
- /**
13206
- * Where `command`'s program lives, or null when it is not an executable on PATH.
13207
- * A name with a separator is resolved against `cwd` instead of searched, like a shell.
13208
- */
13209
- function findOnPath(command, env = process.env, cwd = process.cwd()) {
13210
- const prog = argv0(command);
13211
- if (!prog) return null;
13212
- if (prog.includes("/") || process.platform === "win32" && prog.includes("\\")) return executable(isAbsolute(prog) ? prog : resolve(cwd, prog));
13213
- for (const dir of (env.PATH ?? "").split(delimiter)) {
13214
- if (!dir) continue;
13215
- const hit = executable(join(dir, prog));
13216
- if (hit) return hit;
13379
+ /** Caches catalog discovery for a day and uses the last good copy when offline. */
13380
+ async function loadSchemaCatalog(cacheFile = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "diffle", "schemastore.json"), request = fetch) {
13381
+ let cached = [];
13382
+ try {
13383
+ cached = schemaAssociations(JSON.parse(await readFile(cacheFile, "utf8")));
13384
+ if (cached.length && Date.now() - (await stat$1(cacheFile)).mtimeMs < MAX_AGE) return cached;
13385
+ } catch {}
13386
+ try {
13387
+ const response = await request(SCHEMA_CATALOG, { signal: AbortSignal.timeout(5e3) });
13388
+ if (!response.ok) return cached;
13389
+ const schemas = schemaAssociations(await response.json());
13390
+ if (!schemas.length) return cached;
13391
+ await writeFileAtomic(cacheFile, JSON.stringify({ schemas }) + "\n").catch(() => {});
13392
+ return schemas;
13393
+ } catch {
13394
+ return cached;
13217
13395
  }
13218
- return null;
13219
13396
  }
13220
13397
  /**
13221
- * `file` itself when it is a runnable file, else the first `file` + PATHEXT match on
13222
- * Windows, where the execute bit means nothing and programs are `.exe`/`.cmd`.
13398
+ * Answers a language server's `workspace/configuration` request from `settings`:
13399
+ * each requested section in order, null for unknown sections, and the whole tree
13400
+ * for an omitted section. Params are untrusted JSON from the server; malformed
13401
+ * requests throw JSON-RPC Invalid Params before any settings are returned.
13223
13402
  */
13224
- function executable(file) {
13225
- if (runnable(file)) return file;
13226
- if (process.platform !== "win32") return null;
13227
- for (const ext of (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")) if (ext && runnable(file + ext)) return file + ext;
13228
- return null;
13229
- }
13230
- function runnable(file) {
13231
- try {
13232
- if (!statSync(file).isFile()) return false;
13233
- if (process.platform === "win32") return true;
13234
- accessSync(file, constants.X_OK);
13235
- return true;
13236
- } catch {
13237
- return false;
13238
- }
13403
+ function configurationItems(settings, params) {
13404
+ const request = ConfigurationParamsSchema.safeParse(params);
13405
+ if (!request.success) throw new JsonRpcError(-32602, "Invalid workspace/configuration parameters");
13406
+ return request.data.items.map((item) => {
13407
+ if (!item.section) return settings;
13408
+ let value = settings;
13409
+ for (const part of item.section.split(".")) {
13410
+ if (!value || typeof value !== "object" || !Object.hasOwn(value, part)) return null;
13411
+ value = value[part];
13412
+ }
13413
+ return value ?? null;
13414
+ });
13239
13415
  }
13240
13416
  //#endregion
13241
- //#region src/server/persist.ts
13242
- /** A holder older than this is presumed hung, even when its pid is alive. */
13243
- const LOCK_STALE_MS = 3e4;
13244
- const LOCK_TIMEOUT_MS = 1e4;
13245
- let seq = 0;
13417
+ //#region src/server/lsp/ServerOutput.ts
13418
+ /** Log lines kept per server; older ones are dropped. */
13419
+ const LOG_CAPACITY = 200;
13420
+ /** LSP `MessageType` numbers; anything else, such as the newer Debug, reads as a plain log line. */
13421
+ const MESSAGE_LEVELS = {
13422
+ 1: "error",
13423
+ 2: "warning",
13424
+ 3: "info",
13425
+ 4: "log"
13426
+ };
13246
13427
  /**
13247
- * Writes `text` to `file` atomically: unique temp file in the same directory,
13248
- * then rename. Creates the directory owner-only; the file gets `mode`. A failed
13249
- * write leaves no temp file behind.
13428
+ * What one server reports while it runs: its output channel (what it showed, what it
13429
+ * logged, what it wrote to stderr), the notice it shows the reader, and the progress it
13430
+ * runs. Each change goes to `onChange` as the status fields it moves. Only the log's
13431
+ * summary travels with the status: the lines are fetched by whoever shows them.
13250
13432
  */
13251
- async function writeFileAtomic(file, text, mode = 384) {
13252
- await mkdir(dirname(file), {
13253
- recursive: true,
13254
- mode: 448
13255
- });
13256
- const tmp = `${file}.${process.pid}.${++seq}.${randomBytes(4).toString("hex")}.tmp`;
13257
- try {
13258
- await writeFile(tmp, text, {
13259
- encoding: "utf8",
13260
- mode
13433
+ var ServerOutput = class {
13434
+ onChange;
13435
+ /** Oldest first. */
13436
+ log = [];
13437
+ nextLogId = 1;
13438
+ logSummary = {
13439
+ seq: 0,
13440
+ errors: 0,
13441
+ warnings: 0
13442
+ };
13443
+ /** stderr bytes since the last newline. */
13444
+ stderrTail = "";
13445
+ notice;
13446
+ progress = /* @__PURE__ */ new Map();
13447
+ constructor(onChange) {
13448
+ this.onChange = onChange;
13449
+ }
13450
+ /** The retained log lines, oldest first. */
13451
+ entries() {
13452
+ return this.log.map((e) => ({ ...e }));
13453
+ }
13454
+ summary() {
13455
+ return this.logSummary;
13456
+ }
13457
+ /** Whole stderr lines go to the log; a trailing partial line waits for its newline. */
13458
+ stderr(chunk) {
13459
+ const lines = (this.stderrTail + chunk).split("\n");
13460
+ this.stderrTail = lines.pop() ?? "";
13461
+ for (const line of lines) {
13462
+ if (!line.trim()) continue;
13463
+ this.record("stderr", "log", line);
13464
+ }
13465
+ }
13466
+ /** A server-to-client message: `window/*Message*` lands in the log, `$/progress` in the activity. */
13467
+ message(method, params) {
13468
+ if (!params || typeof params !== "object") return;
13469
+ const p = params;
13470
+ if (method === "window/showMessage" || method === "window/logMessage" || method === "window/showMessageRequest") {
13471
+ if (typeof p.message !== "string" || !p.message.trim()) return;
13472
+ const level = MESSAGE_LEVELS[p.type] ?? "log";
13473
+ this.record(method === "window/logMessage" ? "log" : "message", level, p.message);
13474
+ if (method === "window/logMessage" || level !== "error" && level !== "warning") return;
13475
+ const notice = {
13476
+ severity: level,
13477
+ message: p.message.trim()
13478
+ };
13479
+ if (notice.severity === this.notice?.severity && notice.message === this.notice.message) return;
13480
+ this.notice = notice;
13481
+ this.onChange({ notice });
13482
+ return;
13483
+ }
13484
+ if (method === "$/progress") this.noteProgress(p);
13485
+ }
13486
+ /**
13487
+ * Appends to the log, folding a line the server repeats into the previous entry's
13488
+ * count, and reports that the log moved.
13489
+ */
13490
+ record(source, level, message) {
13491
+ const text = message.trim();
13492
+ if (!text) return;
13493
+ const last = this.log[this.log.length - 1];
13494
+ if (last && last.source === source && last.level === level && last.message === text) {
13495
+ last.count += 1;
13496
+ last.time = Date.now();
13497
+ } else {
13498
+ this.log.push({
13499
+ id: this.nextLogId++,
13500
+ time: Date.now(),
13501
+ source,
13502
+ level,
13503
+ message: text,
13504
+ count: 1
13505
+ });
13506
+ if (this.log.length > LOG_CAPACITY) this.log.shift();
13507
+ }
13508
+ this.logSummary = {
13509
+ seq: this.logSummary.seq + 1,
13510
+ errors: this.logSummary.errors + (level === "error" ? 1 : 0),
13511
+ warnings: this.logSummary.warnings + (level === "warning" ? 1 : 0)
13512
+ };
13513
+ this.onChange({ log: this.logSummary });
13514
+ }
13515
+ /** Work-done progress by token; the activity lists every piece still running. */
13516
+ noteProgress(p) {
13517
+ if (typeof p.token !== "string" && typeof p.token !== "number") return;
13518
+ if (!p.value || typeof p.value !== "object") return;
13519
+ const { kind, title, message, percentage } = p.value;
13520
+ const percent = typeof percentage === "number" && percentage >= 0 && percentage <= 100 ? percentage : void 0;
13521
+ if (kind === "begin" && typeof title === "string") this.progress.set(p.token, {
13522
+ title,
13523
+ message: typeof message === "string" ? message : void 0,
13524
+ percentage: percent
13261
13525
  });
13262
- await rename(tmp, file);
13263
- } catch (e) {
13264
- await unlink(tmp).catch(() => {});
13265
- throw e;
13526
+ else if (kind === "report" && this.progress.has(p.token)) {
13527
+ if (typeof message === "string") this.progress.get(p.token).message = message;
13528
+ if (percent !== void 0) this.progress.get(p.token).percentage = percent;
13529
+ } else if (kind === "end") {
13530
+ if (!this.progress.delete(p.token)) return;
13531
+ } else return;
13532
+ this.onChange({ activity: [...this.progress.values()].map((p) => `${p.title}${p.percentage == null ? "" : ` (${p.percentage}%)`}${p.message ? `: ${p.message}` : ""}`) });
13266
13533
  }
13534
+ };
13535
+ //#endregion
13536
+ //#region src/server/lsp/shapes.ts
13537
+ /**
13538
+ * How a server advertises each feature: the `initialize` capability (a boolean or an
13539
+ * options object) and the method a later `client/registerCapability` names.
13540
+ */
13541
+ const FEATURES = {
13542
+ definition: {
13543
+ provider: "definitionProvider",
13544
+ method: "textDocument/definition"
13545
+ },
13546
+ typeDefinition: {
13547
+ provider: "typeDefinitionProvider",
13548
+ method: "textDocument/typeDefinition"
13549
+ },
13550
+ hover: {
13551
+ provider: "hoverProvider",
13552
+ method: "textDocument/hover"
13553
+ },
13554
+ references: {
13555
+ provider: "referencesProvider",
13556
+ method: "textDocument/references"
13557
+ },
13558
+ documentSymbol: {
13559
+ provider: "documentSymbolProvider",
13560
+ method: "textDocument/documentSymbol"
13561
+ },
13562
+ workspaceSymbol: {
13563
+ provider: "workspaceSymbolProvider",
13564
+ method: "workspace/symbol"
13565
+ },
13566
+ documentHighlight: {
13567
+ provider: "documentHighlightProvider",
13568
+ method: "textDocument/documentHighlight"
13569
+ }
13570
+ };
13571
+ /** The feature a registration's method names, or null for one diffle never asks for. */
13572
+ function featureOf(method) {
13573
+ for (const [feature, { method: m }] of Object.entries(FEATURES)) if (m === method) return feature;
13574
+ return null;
13267
13575
  }
13268
- const inProcess = /* @__PURE__ */ new Map();
13269
13576
  /**
13270
- * Runs `fn` while holding `<file>.lock`, exclusive across processes on this
13271
- * machine and serialized within this one. A lock whose pid is dead, or older
13272
- * than 30s, is taken over; waiting longer than 10s throws. The lock is
13273
- * released whether `fn` resolves or rejects.
13577
+ * What diffle tells a server it can do. Every feature allows dynamic registration, so a
13578
+ * server that advertises after `initialize` is still asked.
13274
13579
  */
13275
- async function withFileLock(file, fn) {
13276
- const prev = inProcess.get(file) ?? Promise.resolve();
13277
- let release;
13278
- const chain = prev.then(() => new Promise((r) => release = r));
13279
- inProcess.set(file, chain);
13280
- await prev;
13281
- const lock = `${file}.lock`;
13282
- try {
13283
- await acquire(lock);
13284
- try {
13285
- return await fn();
13286
- } finally {
13287
- await unlink(lock).catch(() => {});
13580
+ const CLIENT_CAPABILITIES = {
13581
+ window: { workDoneProgress: true },
13582
+ textDocument: {
13583
+ synchronization: { didSave: false },
13584
+ definition: {
13585
+ dynamicRegistration: true,
13586
+ linkSupport: true
13587
+ },
13588
+ typeDefinition: {
13589
+ dynamicRegistration: true,
13590
+ linkSupport: true
13591
+ },
13592
+ references: { dynamicRegistration: true },
13593
+ documentHighlight: { dynamicRegistration: true },
13594
+ hover: {
13595
+ dynamicRegistration: true,
13596
+ contentFormat: ["markdown", "plaintext"]
13597
+ },
13598
+ documentSymbol: {
13599
+ dynamicRegistration: true,
13600
+ hierarchicalDocumentSymbolSupport: true
13288
13601
  }
13289
- } finally {
13290
- release();
13291
- if (inProcess.get(file) === chain) inProcess.delete(file);
13602
+ },
13603
+ workspace: {
13604
+ symbol: { dynamicRegistration: true },
13605
+ workspaceFolders: true,
13606
+ configuration: true
13292
13607
  }
13608
+ };
13609
+ /** `answer` as is, or with why it may be incomplete when it is `empty` and the server was `busy`. */
13610
+ function inconclusive(answer, empty, busy) {
13611
+ return busy && empty ? {
13612
+ ...answer,
13613
+ inconclusive: busy
13614
+ } : answer;
13293
13615
  }
13294
- async function acquire(lock) {
13295
- await mkdir(dirname(lock), {
13296
- recursive: true,
13297
- mode: 448
13298
- });
13299
- const deadline = Date.now() + LOCK_TIMEOUT_MS;
13300
- for (;;) {
13301
- try {
13302
- const fh = await open(lock, "wx", 384);
13303
- try {
13304
- await fh.writeFile(`${process.pid}\n`);
13305
- } finally {
13306
- await fh.close();
13616
+ function toLsp(pos) {
13617
+ return {
13618
+ line: Math.max(0, pos.line - 1),
13619
+ character: Math.max(0, pos.col)
13620
+ };
13621
+ }
13622
+ /** An LSP range as diffle spans it: 1-based lines, 0-based columns. */
13623
+ function fromLsp({ start, end }) {
13624
+ return {
13625
+ line: start.line + 1,
13626
+ col: start.character,
13627
+ endLine: end.line + 1,
13628
+ endCol: end.character
13629
+ };
13630
+ }
13631
+ function normalizeLocations(r) {
13632
+ if (!r) return [];
13633
+ return (Array.isArray(r) ? r : [r]).map((x) => "targetUri" in x ? {
13634
+ uri: x.targetUri,
13635
+ range: x.targetSelectionRange ?? x.targetRange
13636
+ } : x);
13637
+ }
13638
+ /** Either symbol shape a server may answer `documentSymbol` with, flattened in document order. */
13639
+ function flattenSymbols(result, path) {
13640
+ const out = [];
13641
+ if (isDocumentSymbols(result)) {
13642
+ const walk = (syms, container) => {
13643
+ for (const s of syms) {
13644
+ const start = (s.selectionRange ?? s.range).start;
13645
+ const endLine = Math.max(start.line, (s.range ?? s.selectionRange).end.line) + 1;
13646
+ out.push({
13647
+ name: s.name,
13648
+ kind: s.kind,
13649
+ container,
13650
+ path,
13651
+ line: start.line + 1,
13652
+ endLine,
13653
+ col: start.character
13654
+ });
13655
+ if (s.children?.length) walk(s.children, s.name);
13307
13656
  }
13308
- return;
13309
- } catch (e) {
13310
- if (e.code !== "EEXIST") throw e;
13311
- }
13312
- if (await isStale(lock)) {
13313
- await unlink(lock).catch(() => {});
13314
- continue;
13315
- }
13316
- if (Date.now() > deadline) throw new Error(`timed out waiting for ${lock}`);
13317
- await new Promise((r) => setTimeout(r, 10 + Math.random() * 20));
13657
+ };
13658
+ walk(result);
13659
+ return out;
13318
13660
  }
13661
+ return result.map((s) => symbolInformation(s, path));
13319
13662
  }
13320
- /** True when the holder is gone: dead pid, or too old to trust. A lock with no pid yet is being taken. */
13321
- async function isStale(lock) {
13322
- let st;
13323
- let text;
13324
- try {
13325
- [st, text] = await Promise.all([stat$1(lock), readFile(lock, "utf8")]);
13326
- } catch (e) {
13327
- if (e.code === "ENOENT") return false;
13328
- throw e;
13663
+ function symbolInformation(s, path) {
13664
+ const start = "range" in s.location ? s.location.range.start : {
13665
+ line: 0,
13666
+ character: 0
13667
+ };
13668
+ const end = "range" in s.location ? s.location.range.end : start;
13669
+ return {
13670
+ name: s.name,
13671
+ kind: s.kind,
13672
+ container: s.containerName || void 0,
13673
+ path,
13674
+ line: start.line + 1,
13675
+ endLine: Math.max(start.line, end.line) + 1,
13676
+ col: start.character
13677
+ };
13678
+ }
13679
+ function isDocumentSymbols(r) {
13680
+ return r.length > 0 && !("location" in r[0]);
13681
+ }
13682
+ //#endregion
13683
+ //#region src/server/lsp/which.ts
13684
+ /**
13685
+ * The program a shell command line runs: its first word, unquoted. Only ever asked
13686
+ * about the built-in candidates, which are plain `prog arg…` lines; a command with a
13687
+ * leading assignment or a pipeline has no single program and is never probed (see
13688
+ * `resolveServers`).
13689
+ */
13690
+ function argv0(command) {
13691
+ const s = command.trim();
13692
+ const quote = s[0];
13693
+ if (quote === "\"" || quote === "'") {
13694
+ const end = s.indexOf(quote, 1);
13695
+ return end === -1 ? s.slice(1) : s.slice(1, end);
13329
13696
  }
13330
- if (Date.now() - st.mtimeMs > LOCK_STALE_MS) return true;
13331
- const pid = Number.parseInt(text, 10);
13332
- if (!Number.isFinite(pid) || pid <= 0) return false;
13333
- try {
13334
- process.kill(pid, 0);
13335
- return false;
13336
- } catch (e) {
13337
- return e.code === "ESRCH";
13697
+ return s.split(/\s/, 1)[0] ?? "";
13698
+ }
13699
+ /**
13700
+ * The program a command line runs, as status lines and the registry name it: the basename
13701
+ * of its first word without a Windows extension. `"C:\\tools\\taplo.exe" lsp stdio` → `taplo`.
13702
+ */
13703
+ function programOf(command) {
13704
+ const prog = argv0(command);
13705
+ return (prog.split(/[\\/]/).pop() || prog).replace(/\.(exe|cmd|bat)$/i, "");
13706
+ }
13707
+ /**
13708
+ * Where `command`'s program lives, or null when it is not an executable on PATH.
13709
+ * A name with a separator is resolved against `cwd` instead of searched, like a shell.
13710
+ */
13711
+ function findOnPath(command, env = process.env, cwd = process.cwd()) {
13712
+ const prog = argv0(command);
13713
+ if (!prog) return null;
13714
+ if (prog.includes("/") || process.platform === "win32" && prog.includes("\\")) return executable(isAbsolute(prog) ? prog : resolve(cwd, prog));
13715
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
13716
+ if (!dir) continue;
13717
+ const hit = executable(join(dir, prog));
13718
+ if (hit) return hit;
13338
13719
  }
13720
+ return null;
13339
13721
  }
13340
- //#endregion
13341
- //#region src/server/lsp/configuration.ts
13342
- const SCHEMA_CATALOG = "https://www.schemastore.org/api/json/catalog.json";
13343
- const MAX_AGE = 864e5;
13344
- const AssociationSchema = object({
13345
- url: url({ protocol: /^https?$/ }),
13346
- fileMatch: array(string()).nonempty()
13347
- });
13348
- const SchemaCatalogSchema = object({ schemas: array(unknown()) });
13349
- const ConfigurationParamsSchema = object({ items: array(object({ section: string().optional() })) });
13350
- /** Valid catalog entries, in the association format understood by the JSON server. */
13351
- function schemaAssociations(value) {
13352
- const catalog = SchemaCatalogSchema.safeParse(value);
13353
- if (!catalog.success) return [];
13354
- return catalog.data.schemas.flatMap((entry) => {
13355
- const association = AssociationSchema.safeParse(entry);
13356
- return association.success ? [association.data] : [];
13357
- });
13722
+ /**
13723
+ * `file` itself when it is a runnable file, else the first `file` + PATHEXT match on
13724
+ * Windows, where the execute bit means nothing and programs are `.exe`/`.cmd`.
13725
+ */
13726
+ function executable(file) {
13727
+ if (runnable(file)) return file;
13728
+ if (process.platform !== "win32") return null;
13729
+ for (const ext of (process.env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(";")) if (ext && runnable(file + ext)) return file + ext;
13730
+ return null;
13358
13731
  }
13359
- /** Caches catalog discovery for a day and uses the last good copy when offline. */
13360
- async function loadSchemaCatalog(cacheFile = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "diffle", "schemastore.json"), request = fetch) {
13361
- let cached = [];
13362
- try {
13363
- cached = schemaAssociations(JSON.parse(await readFile(cacheFile, "utf8")));
13364
- if (cached.length && Date.now() - (await stat$1(cacheFile)).mtimeMs < MAX_AGE) return cached;
13365
- } catch {}
13732
+ function runnable(file) {
13366
13733
  try {
13367
- const response = await request(SCHEMA_CATALOG, { signal: AbortSignal.timeout(5e3) });
13368
- if (!response.ok) return cached;
13369
- const schemas = schemaAssociations(await response.json());
13370
- if (!schemas.length) return cached;
13371
- await writeFileAtomic(cacheFile, JSON.stringify({ schemas }) + "\n").catch(() => {});
13372
- return schemas;
13734
+ if (!statSync(file).isFile()) return false;
13735
+ if (process.platform === "win32") return true;
13736
+ accessSync(file, constants.X_OK);
13737
+ return true;
13373
13738
  } catch {
13374
- return cached;
13739
+ return false;
13375
13740
  }
13376
13741
  }
13377
- /** Default schema settings for the languages routed to a process. */
13378
- async function settingsFor(languages, command) {
13379
- const settings = {};
13380
- if (languages.includes("yaml")) settings.yaml = {
13381
- hover: true,
13382
- schemaStore: {
13383
- enable: true,
13384
- url: SCHEMA_CATALOG
13385
- }
13386
- };
13387
- const program = argv0(command).split(/[\\/]/).pop()?.replace(/\.(exe|cmd|bat)$/i, "");
13388
- if (languages.includes("toml") && program === "taplo") settings.evenBetterToml = { schema: {
13389
- enabled: true,
13390
- catalogs: [SCHEMA_CATALOG]
13391
- } };
13392
- if (languages.some((l) => l === "json" || l === "jsonc")) settings.json = { schemas: await loadSchemaCatalog() };
13393
- return settings;
13394
- }
13395
13742
  /**
13396
- * Used by LspBridge to answer language servers' `workspace/configuration` requests.
13397
- * Returns settings for each requested section in order, or null for unknown sections;
13398
- * an omitted section requests all settings. Params are untrusted JSON from the server.
13399
- * Malformed requests throw JSON-RPC Invalid Params before any settings are returned.
13743
+ * The file `executable` finally runs, as far as the filesystem tells: a pixi global
13744
+ * trampoline is replaced by the program its configuration names (the binary on PATH
13745
+ * is a launcher, with `trampoline_configuration/<name>.json` beside it pointing into
13746
+ * the environment), then symlinks are resolved. A path that leads nowhere is returned
13747
+ * as is.
13400
13748
  */
13401
- function configurationItems(settings, params) {
13402
- const request = ConfigurationParamsSchema.safeParse(params);
13403
- if (!request.success) throw new JsonRpcError(-32602, "Invalid workspace/configuration parameters");
13404
- return request.data.items.map((item) => {
13405
- if (!item.section) return settings;
13406
- let value = settings;
13407
- for (const part of item.section.split(".")) {
13408
- if (!value || typeof value !== "object" || !Object.hasOwn(value, part)) return null;
13409
- value = value[part];
13410
- }
13411
- return value ?? null;
13412
- });
13749
+ function realExecutable(executable) {
13750
+ let target = executable;
13751
+ try {
13752
+ const config = join(dirname(executable), "trampoline_configuration", `${basename(executable)}.json`);
13753
+ const { exe } = JSON.parse(readFileSync(config, "utf8"));
13754
+ if (typeof exe === "string" && exe) target = exe;
13755
+ } catch {}
13756
+ try {
13757
+ return realpathSync(target);
13758
+ } catch {
13759
+ return target;
13760
+ }
13413
13761
  }
13414
13762
  //#endregion
13415
- //#region src/server/lsp/LspBridge.ts
13416
- /** The server is off, still starting, or gone. Routes answer 409. */
13763
+ //#region src/server/lsp/LspProcess.ts
13764
+ /** The server is off, still starting, gone, or does not offer what was asked. Routes answer 409. */
13417
13765
  var LspUnavailableError = class extends Error {};
13418
- /**
13419
- * Token types by index when the server publishes no legend. The LSP spec's own
13420
- * enumeration; pyrefly answers semantic token requests without advertising the
13421
- * provider and numbers its tokens this way.
13422
- */
13423
- const DEFAULT_TOKEN_TYPES = [
13424
- "namespace",
13425
- "type",
13426
- "class",
13427
- "enum",
13428
- "interface",
13429
- "struct",
13430
- "typeParameter",
13431
- "parameter",
13432
- "variable",
13433
- "property",
13434
- "enumMember",
13435
- "event",
13436
- "function",
13437
- "method",
13438
- "macro",
13439
- "keyword",
13440
- "modifier",
13441
- "comment",
13442
- "string",
13443
- "number",
13444
- "regexp",
13445
- "operator",
13446
- "decorator"
13447
- ];
13766
+ /** The server never advertised the feature, in `initialize` or since. Advisory requests answer nothing instead. */
13767
+ var LspUnsupportedError = class extends LspUnavailableError {};
13768
+ const RegistrationsSchema = object({ registrations: array(object({
13769
+ id: string(),
13770
+ method: string()
13771
+ })) });
13772
+ const UnregistrationsSchema = object({ unregisterations: array(object({ id: string() })) });
13448
13773
  const INIT_TIMEOUT_MS = 6e4;
13449
- /**
13450
- * Navigation opens whatever file a query lands in. Without a cap every path
13451
- * visited in a long review stays open in the server, growing its memory and
13452
- * per-save recheck cost.
13453
- */
13454
- const MAX_UNTRACKED_OPEN = 64;
13455
- /** Concurrent snapshot reads while mapping a result set back to paths. */
13456
- const READ_CONCURRENCY = 8;
13457
- /** Files outside the snapshot remembered for `readExternal`; the least recently named is forgotten first. */
13458
- const MAX_EXTERNAL = 1024;
13459
13774
  /** Graceful shutdown budget. Both together stay under the CLI's hard exit deadline (main.ts). */
13460
13775
  const SHUTDOWN_RPC_MS = 500;
13461
13776
  const SIGKILL_AFTER_MS = 500;
13462
- /** LSP `ContentModified`: the document moved under the request, so the answer would be stale. */
13463
- const CONTENT_MODIFIED = -32801;
13464
- /** Waits before re-asking a request the server refused as stale, one per attempt. */
13465
- const CONTENT_MODIFIED_BACKOFF_MS = [40, 120];
13466
13777
  /**
13467
- * Owns one language server process: the only module that spawns one. Speaks
13468
- * JSON-RPC to it, keeps the queried document in sync from `read` before each
13469
- * request, and maps results back to snapshot paths. Never throws from `start`;
13470
- * failures become a status the UI can show.
13778
+ * `shell: true` runs the command under `sh -c`, so a plain kill would stop the
13779
+ * shell and orphan the language server. On POSIX the child leads its own process
13780
+ * group (`detached`), and shutdown signals the whole group.
13781
+ */
13782
+ const DETACH = process.platform !== "win32";
13783
+ /**
13784
+ * One language server process: the only module that spawns one. Runs the
13785
+ * `initialize` handshake, keeps the status (with what `ServerOutput` reports), and
13786
+ * shuts it down. Speaks no document or query protocol: that is `Documents` and
13787
+ * `LspBridge`. Never throws from the constructor; failures become a status the UI
13788
+ * can show.
13471
13789
  */
13472
- var LspBridge = class LspBridge {
13790
+ var LspProcess = class {
13473
13791
  opts;
13474
13792
  child = null;
13475
13793
  rpc = null;
13476
13794
  current;
13477
13795
  ready;
13478
- stderr = "";
13479
- progress = /* @__PURE__ */ new Map();
13480
- /** Open documents, least recently synced first. */
13481
- open = /* @__PURE__ */ new Map();
13482
- /** Syncs in flight by path, so a track pass and a query racing on one file send a single didOpen. */
13483
- syncing = /* @__PURE__ */ new Map();
13484
- /** Paths `track` opened on the snapshot's behalf; closed again when they leave the snapshot. */
13485
- tracked = /* @__PURE__ */ new Set();
13486
- tracking = Promise.resolve();
13487
13796
  closing = false;
13488
- ownsGroup = false;
13489
- /** Symlink-free root; servers often canonicalize, so URIs may not share the root's spelling. */
13490
- realRoot;
13491
- /** Semantic token legend from the server's capabilities, else the spec's default numbering. */
13492
- tokenTypes = DEFAULT_TOKEN_TYPES;
13493
- /** Absolute paths of files outside the snapshot that results named, least recently first. */
13494
- external = /* @__PURE__ */ new Set();
13495
- constructor(opts) {
13496
- this.opts = opts;
13497
- this.realRoot = safeRealpathSync(opts.root);
13498
- this.current = {
13499
- name: programName(opts.command),
13500
- command: opts.command,
13501
- state: "starting"
13502
- };
13503
- this.ready = this.launch();
13504
- this.ready.catch(() => {});
13505
- }
13506
- static start(opts) {
13507
- return new LspBridge(opts);
13508
- }
13509
- status() {
13510
- return this.current;
13511
- }
13512
- async definition(pos) {
13513
- const result = await this.query("textDocument/definition", {
13514
- textDocument: { uri: this.uri(pos.path) },
13515
- position: toLsp(pos)
13516
- }, pos.path);
13517
- return this.toLocations(normalizeLocations(result));
13518
- }
13519
- async typeDefinition(pos) {
13520
- const result = await this.query("textDocument/typeDefinition", {
13521
- textDocument: { uri: this.uri(pos.path) },
13522
- position: toLsp(pos)
13523
- }, pos.path);
13524
- return this.toLocations(normalizeLocations(result));
13525
- }
13526
- async hover(pos) {
13527
- const result = await this.query("textDocument/hover", {
13528
- textDocument: { uri: this.uri(pos.path) },
13529
- position: toLsp(pos)
13530
- }, pos.path);
13531
- const md = result ? hoverMarkdown(result.contents) : null;
13532
- const res = { contents: md == null ? null : localizeFileLinks(md, await this.resolveLinks(md)) };
13533
- if (result?.range) {
13534
- const { start, end } = result.range;
13535
- res.range = {
13536
- line: start.line + 1,
13537
- col: start.character,
13538
- endLine: end.line + 1,
13539
- endCol: end.character
13540
- };
13541
- }
13542
- return res;
13543
- }
13544
- /**
13545
- * The semantic token class at a position, via `textDocument/semanticTokens/range`
13546
- * on that one line. A server without semantic tokens, or a position between
13547
- * tokens, yields null, so callers treat null as "no opinion".
13548
- */
13549
- async tokenKind(pos) {
13550
- const line = Math.max(0, pos.line - 1);
13551
- let data;
13552
- try {
13553
- data = (await this.query("textDocument/semanticTokens/range", {
13554
- textDocument: { uri: this.uri(pos.path) },
13555
- range: {
13556
- start: {
13557
- line,
13558
- character: 0
13559
- },
13560
- end: {
13561
- line: line + 1,
13562
- character: 0
13563
- }
13564
- }
13565
- }, pos.path))?.data ?? [];
13566
- } catch (e) {
13567
- if (e instanceof LspUnavailableError) throw e;
13568
- return { kind: null };
13569
- }
13570
- let tokLine = 0;
13571
- let tokStart = 0;
13572
- for (let i = 0; i + 4 < data.length; i += 5) {
13573
- const deltaLine = data[i];
13574
- tokLine += deltaLine;
13575
- tokStart = deltaLine === 0 ? tokStart + data[i + 1] : data[i + 1];
13576
- if (tokLine !== line || pos.col < tokStart || pos.col >= tokStart + data[i + 2]) continue;
13577
- return { kind: this.tokenTypes[data[i + 3]] ?? null };
13578
- }
13579
- return { kind: null };
13580
- }
13581
- /**
13582
- * Where else the symbol at `pos` occurs in its file, via `textDocument/documentHighlight`,
13583
- * so scope and shadowing are the server's call. A server without the method, or a
13584
- * position holding no symbol, yields no occurrences: the answer is advisory.
13585
- */
13586
- async occurrences(pos) {
13587
- let result;
13588
- try {
13589
- result = await this.query("textDocument/documentHighlight", {
13590
- textDocument: { uri: this.uri(pos.path) },
13591
- position: toLsp(pos)
13592
- }, pos.path);
13593
- } catch (e) {
13594
- if (e instanceof LspUnavailableError) throw e;
13595
- return { occurrences: [] };
13596
- }
13597
- return { occurrences: (result ?? []).map(({ range: { start, end }, kind }) => ({
13598
- line: start.line + 1,
13599
- col: start.character,
13600
- endLine: end.line + 1,
13601
- endCol: end.character,
13602
- kind: kind === 3 ? "write" : kind === 2 ? "read" : "text"
13603
- })) };
13797
+ ownsGroup = false;
13798
+ /** What the server reports; each change lands on the status unless the process is done. */
13799
+ output = new ServerOutput((change) => {
13800
+ if (!this.closing && this.current.state !== "unavailable") this.set({
13801
+ ...this.current,
13802
+ ...change
13803
+ });
13804
+ });
13805
+ /** Features the server offers, from `initialize` and from registrations since. */
13806
+ advertised = /* @__PURE__ */ new Set();
13807
+ /** Dynamic registrations by their id, so an unregistration knows which feature it ends. */
13808
+ registrations = /* @__PURE__ */ new Map();
13809
+ constructor(opts) {
13810
+ this.opts = opts;
13811
+ this.current = {
13812
+ name: programOf(opts.command),
13813
+ command: opts.command,
13814
+ state: "starting",
13815
+ log: this.output.summary()
13816
+ };
13817
+ this.ready = this.launch();
13818
+ this.ready.catch(() => {});
13604
13819
  }
13605
- async references(pos, opts = {}) {
13606
- const result = await this.query("textDocument/references", {
13607
- textDocument: { uri: this.uri(pos.path) },
13608
- position: toLsp(pos),
13609
- context: { includeDeclaration: opts.includeDeclaration ?? true }
13610
- }, pos.path);
13611
- return this.toLocations(normalizeLocations(result));
13820
+ /** How messages name the server: `basedpyright-langserver --stdio` → `basedpyright-langserver`. */
13821
+ get name() {
13822
+ return this.current.name;
13612
13823
  }
13613
- /**
13614
- * Keeps the diff's own files open in the server with their snapshot text.
13615
- * Servers index the workspace lazily and cap how much of it they index
13616
- * (pyrefly: in the background from the first open, 2000 files), so a
13617
- * reference in a changed file that nobody queried yet is otherwise only found
13618
- * once its file happens to be indexed, and from the text on disk. Opening
13619
- * everything at start also warms the index before the first query. Calls are
13620
- * serialized; the last set wins. Never throws: a path the snapshot cannot read
13621
- * is skipped.
13622
- */
13623
- track(paths) {
13624
- const run = this.tracking.then(() => this.trackNow(paths)).catch(() => {});
13625
- this.tracking = run;
13626
- return run;
13824
+ status() {
13825
+ return this.current;
13627
13826
  }
13628
- async trackNow(paths) {
13629
- const rpc = await this.ensureReady();
13630
- const next = new Set(paths);
13631
- for (const path of this.tracked) {
13632
- if (next.has(path)) continue;
13633
- this.tracked.delete(path);
13634
- if (this.open.delete(path)) rpc.notify("textDocument/didClose", { textDocument: { uri: this.uri(path) } });
13635
- }
13636
- for (const path of next) try {
13637
- await this.sync(path);
13638
- this.tracked.add(path);
13639
- } catch (e) {
13640
- if (e instanceof LspUnavailableError) return;
13641
- }
13827
+ /** The retained log lines, oldest first. */
13828
+ entries() {
13829
+ return this.output.entries();
13642
13830
  }
13643
- async documentSymbols(path) {
13644
- const result = await this.query("textDocument/documentSymbol", { textDocument: { uri: this.uri(path) } }, path);
13645
- if (!result) return [];
13646
- const out = [];
13647
- if (isDocumentSymbols(result)) {
13648
- const walk = (syms, container) => {
13649
- for (const s of syms) {
13650
- const start = (s.selectionRange ?? s.range).start;
13651
- const endLine = Math.max(start.line, (s.range ?? s.selectionRange).end.line) + 1;
13652
- out.push({
13653
- name: s.name,
13654
- kind: s.kind,
13655
- container,
13656
- path,
13657
- line: start.line + 1,
13658
- endLine,
13659
- col: start.character
13660
- });
13661
- if (s.children?.length) walk(s.children, s.name);
13662
- }
13663
- };
13664
- walk(result);
13665
- return out;
13666
- }
13667
- for (const s of result) {
13668
- const start = "range" in s.location ? s.location.range.start : {
13669
- line: 0,
13670
- character: 0
13671
- };
13672
- const end = "range" in s.location ? s.location.range.end : start;
13673
- out.push({
13674
- name: s.name,
13675
- kind: s.kind,
13676
- container: s.containerName || void 0,
13677
- path,
13678
- line: start.line + 1,
13679
- endLine: Math.max(start.line, end.line) + 1,
13680
- col: start.character
13681
- });
13682
- }
13683
- return out;
13831
+ /** Whether the server advertised `feature`, at `initialize` or by registering it since. False until initialized. */
13832
+ supports(feature) {
13833
+ return this.advertised.has(feature);
13684
13834
  }
13685
- /**
13686
- * Symbols the server knows for `query`, limited to paths the snapshot
13687
- * exposes, best `fuzzyRank` match first. Membership comes from `has`, never from reading the file: a
13688
- * per-keystroke search must not read every hit's text.
13689
- */
13690
- async workspaceSymbols(query, limit = 200) {
13691
- const result = await this.query("workspace/symbol", { query });
13692
- if (!result) return [];
13693
- const paths = await Promise.all(result.map((s) => this.pathOf(s.location.uri)));
13694
- const inside = await this.membership(paths);
13695
- const out = [];
13696
- const seen = /* @__PURE__ */ new Set();
13697
- result.forEach((s, i) => {
13698
- const path = paths[i];
13699
- if (path == null || !inside.get(path)) return;
13700
- const start = "range" in s.location ? s.location.range.start : {
13701
- line: 0,
13702
- character: 0
13703
- };
13704
- const key = `${path}:${start.line}:${start.character}:${s.name}`;
13705
- if (seen.has(key)) return;
13706
- seen.add(key);
13707
- const end = "range" in s.location ? s.location.range.end : start;
13708
- out.push({
13709
- name: s.name,
13710
- kind: s.kind,
13711
- container: s.containerName || void 0,
13712
- path,
13713
- line: start.line + 1,
13714
- endLine: Math.max(start.line, end.line) + 1,
13715
- col: start.character
13716
- });
13717
- });
13718
- return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
13835
+ features() {
13836
+ return [...this.advertised];
13719
13837
  }
13720
- /**
13721
- * Contents of a file outside the snapshot that a recent result named as
13722
- * `external`, read from disk; null for every other path. This is the only
13723
- * read the bridge does on a client's behalf, so a path the language server
13724
- * never returned stays behind the snapshot allowlist.
13725
- */
13726
- async readExternal(path) {
13727
- if (!this.external.has(path)) return null;
13728
- return readFile(path).catch(() => null);
13838
+ /** The background work the server reports, as a reason an empty answer may be incomplete; null when idle. */
13839
+ busy() {
13840
+ const work = this.current.activity?.length ? this.current.activity.join("; ") : null;
13841
+ return work ? `${this.name} is busy: ${work}; try again when it finishes` : null;
13729
13842
  }
13730
- /** `has` for each distinct path, in parallel. */
13731
- async membership(paths) {
13732
- const distinct = [...new Set(paths.filter((p) => p != null))];
13733
- const flags = await mapLimit(distinct, READ_CONCURRENCY, (p) => this.opts.has(p));
13734
- return new Map(distinct.map((p, i) => [p, flags[i]]));
13843
+ /** The connection once initialized; throws LspUnavailableError when the server is gone. */
13844
+ async connection() {
13845
+ try {
13846
+ await this.ready;
13847
+ } catch {
13848
+ throw new LspUnavailableError(this.current.message ?? "language server unavailable");
13849
+ }
13850
+ if (this.current.state !== "ready" || !this.rpc) throw new LspUnavailableError(this.current.message ?? "language server unavailable");
13851
+ return this.rpc;
13735
13852
  }
13736
13853
  async close() {
13737
13854
  this.closing = true;
@@ -13790,18 +13907,12 @@ var LspBridge = class LspBridge {
13790
13907
  throw e;
13791
13908
  }
13792
13909
  this.child = child;
13793
- child.stderr?.on("data", (d) => {
13794
- const chunk = d.toString("utf8");
13795
- this.stderr = (this.stderr + chunk).slice(-2e3);
13796
- if (!this.closing && this.current.state !== "unavailable") this.set({
13797
- ...this.current,
13798
- stderr: this.stderr.trim()
13799
- });
13800
- });
13910
+ child.stderr?.on("data", (d) => this.output.stderr(d.toString("utf8")));
13801
13911
  child.on("error", (e) => this.fail(`cannot start "${this.opts.command}": ${e.message}`));
13802
13912
  child.on("exit", (code, signal) => {
13803
13913
  if (this.closing) return;
13804
- this.fail(`"${this.opts.command}" exited (${signal ?? code})${this.stderr.trim() ? `: ${lastLine(this.stderr)}` : ""}`);
13914
+ this.output.stderr("\n");
13915
+ this.fail(`"${this.opts.command}" exited (${signal ?? code})`);
13805
13916
  });
13806
13917
  if (!child.stdin || !child.stdout) {
13807
13918
  this.fail(`cannot start "${this.opts.command}": no stdio`);
@@ -13809,11 +13920,15 @@ var LspBridge = class LspBridge {
13809
13920
  }
13810
13921
  const rpc = new JsonRpcConnection(child.stdout, child.stdin);
13811
13922
  this.rpc = rpc;
13812
- rpc.onNotification((method, params) => this.noteWorkspace(method, params));
13923
+ rpc.onMessage((method, params) => {
13924
+ if (!this.closing && this.current.state !== "unavailable") this.output.message(method, params);
13925
+ });
13813
13926
  rpc.onRequest("workspace/configuration", async (params) => configurationItems(await this.opts.settings ?? {}, params));
13927
+ rpc.onRequest("client/registerCapability", (params) => this.register(params));
13928
+ rpc.onRequest("client/unregisterCapability", (params) => this.unregister(params));
13814
13929
  const rootUri = pathToFileURL(this.opts.root).href;
13815
13930
  try {
13816
- const legend = (await rpc.request("initialize", {
13931
+ const capabilities = (await rpc.request("initialize", {
13817
13932
  processId: process.pid,
13818
13933
  clientInfo: { name: "diffle" },
13819
13934
  rootUri,
@@ -13822,31 +13937,10 @@ var LspBridge = class LspBridge {
13822
13937
  uri: rootUri,
13823
13938
  name: "workspace"
13824
13939
  }],
13825
- capabilities: {
13826
- window: { workDoneProgress: true },
13827
- textDocument: {
13828
- synchronization: { didSave: false },
13829
- definition: { linkSupport: true },
13830
- typeDefinition: { linkSupport: true },
13831
- references: {},
13832
- documentHighlight: {},
13833
- hover: { contentFormat: ["markdown", "plaintext"] },
13834
- documentSymbol: { hierarchicalDocumentSymbolSupport: true },
13835
- semanticTokens: {
13836
- requests: { range: true },
13837
- tokenTypes: DEFAULT_TOKEN_TYPES,
13838
- tokenModifiers: [],
13839
- formats: ["relative"]
13840
- }
13841
- },
13842
- workspace: {
13843
- symbol: {},
13844
- workspaceFolders: true,
13845
- configuration: true
13846
- }
13847
- }
13848
- }, INIT_TIMEOUT_MS))?.capabilities?.semanticTokensProvider?.legend?.tokenTypes;
13849
- if (legend?.length) this.tokenTypes = legend;
13940
+ initializationOptions: this.opts.initializationOptions,
13941
+ capabilities: CLIENT_CAPABILITIES
13942
+ }, INIT_TIMEOUT_MS))?.capabilities ?? {};
13943
+ for (const [feature, { provider }] of Object.entries(FEATURES)) if (capabilities[provider]) this.advertised.add(feature);
13850
13944
  rpc.notify("initialized", {});
13851
13945
  if (this.opts.settings) {
13852
13946
  const settings = await this.opts.settings;
@@ -13861,40 +13955,30 @@ var LspBridge = class LspBridge {
13861
13955
  state: "ready"
13862
13956
  });
13863
13957
  }
13864
- noteWorkspace(method, params) {
13865
- if (!params || typeof params !== "object" || this.closing || this.current.state === "unavailable") return;
13866
- const p = params;
13867
- if (method === "window/showMessage" || method === "window/logMessage" || method === "window/showMessageRequest") {
13868
- if (p.type !== 1 && p.type !== 2 || typeof p.message !== "string" || !p.message.trim()) return;
13869
- const notice = {
13870
- severity: p.type === 1 ? "error" : "warning",
13871
- message: p.message
13872
- };
13873
- if (notice.severity !== this.current.notice?.severity || notice.message !== this.current.notice?.message) this.set({
13874
- ...this.current,
13875
- notice
13876
- });
13877
- return;
13958
+ /** A feature the server offers from now on, as an editor takes it: counted like one advertised at `initialize`. */
13959
+ register(params) {
13960
+ const request = RegistrationsSchema.safeParse(params);
13961
+ if (!request.success) throw new JsonRpcError(-32602, "Invalid client/registerCapability parameters");
13962
+ for (const { id, method } of request.data.registrations) {
13963
+ const feature = featureOf(method);
13964
+ if (!feature) continue;
13965
+ this.registrations.set(id, feature);
13966
+ this.advertised.add(feature);
13967
+ }
13968
+ this.opts.onStatus(this.current);
13969
+ return null;
13970
+ }
13971
+ unregister(params) {
13972
+ const request = UnregistrationsSchema.safeParse(params);
13973
+ if (!request.success) throw new JsonRpcError(-32602, "Invalid client/unregisterCapability parameters");
13974
+ for (const { id } of request.data.unregisterations) {
13975
+ const feature = this.registrations.get(id);
13976
+ if (!feature) continue;
13977
+ this.registrations.delete(id);
13978
+ this.advertised.delete(feature);
13878
13979
  }
13879
- if (method !== "$/progress" || typeof p.token !== "string" && typeof p.token !== "number") return;
13880
- if (!p.value || typeof p.value !== "object") return;
13881
- const { kind, title, message, percentage } = p.value;
13882
- const percent = typeof percentage === "number" && percentage >= 0 && percentage <= 100 ? percentage : void 0;
13883
- if (kind === "begin" && typeof title === "string") this.progress.set(p.token, {
13884
- title,
13885
- message: typeof message === "string" ? message : void 0,
13886
- percentage: percent
13887
- });
13888
- else if (kind === "report" && this.progress.has(p.token)) {
13889
- if (typeof message === "string") this.progress.get(p.token).message = message;
13890
- if (percent !== void 0) this.progress.get(p.token).percentage = percent;
13891
- } else if (kind === "end") {
13892
- if (!this.progress.delete(p.token)) return;
13893
- } else return;
13894
- this.set({
13895
- ...this.current,
13896
- activity: [...this.progress.values()].map((p) => `${p.title}${p.percentage == null ? "" : ` (${p.percentage}%)`}${p.message ? `: ${p.message}` : ""}`)
13897
- });
13980
+ this.opts.onStatus(this.current);
13981
+ return null;
13898
13982
  }
13899
13983
  fail(message) {
13900
13984
  if (this.current.state === "unavailable") return;
@@ -13902,75 +13986,94 @@ var LspBridge = class LspBridge {
13902
13986
  name: this.current.name,
13903
13987
  command: this.opts.command,
13904
13988
  state: "unavailable",
13905
- message
13989
+ message,
13990
+ log: this.output.summary()
13906
13991
  });
13907
13992
  this.rpc?.dispose(new LspUnavailableError(message));
13908
13993
  }
13909
13994
  set(status) {
13910
- if (JSON.stringify(status) === JSON.stringify(this.current)) return;
13911
13995
  this.current = status;
13912
13996
  this.opts.onStatus(status);
13913
13997
  }
13914
- /** Waits for initialize; throws LspUnavailableError when the server is gone. */
13915
- async ensureReady() {
13916
- try {
13917
- await this.ready;
13918
- } catch {
13919
- throw new LspUnavailableError(this.current.message ?? "language server unavailable");
13920
- }
13921
- if (this.current.state !== "ready" || !this.rpc) throw new LspUnavailableError(this.current.message ?? "language server unavailable");
13922
- return this.rpc;
13998
+ };
13999
+ //#endregion
14000
+ //#region src/server/lsp/Documents.ts
14001
+ /**
14002
+ * Navigation opens whatever file a query lands in. Without a cap every path
14003
+ * visited in a long review stays open in the server, growing its memory and
14004
+ * per-save recheck cost.
14005
+ */
14006
+ const MAX_UNTRACKED_OPEN = 64;
14007
+ /**
14008
+ * The documents one server has open, kept in step with the snapshot: the diff's own files
14009
+ * for as long as they are in it, and the files queries land in until they fall off the
14010
+ * least-recently-used end. Sends `didOpen`, `didChange` and `didClose`; nothing else.
14011
+ */
14012
+ var Documents = class {
14013
+ opts;
14014
+ /** Open documents, least recently synced first. */
14015
+ open = /* @__PURE__ */ new Map();
14016
+ /** Syncs in flight by path, so a track pass and a query racing on one file send a single didOpen. */
14017
+ syncing = /* @__PURE__ */ new Map();
14018
+ /** Paths `track` opened on the snapshot's behalf; closed again when they leave the snapshot. */
14019
+ tracked = /* @__PURE__ */ new Set();
14020
+ tracking = Promise.resolve();
14021
+ constructor(opts) {
14022
+ this.opts = opts;
13923
14023
  }
13924
- /**
13925
- * One request against a synced document, re-asked while the server answers
13926
- * `ContentModified`: something moved the document under it — a watcher refresh, or a
13927
- * `track` pass opening the diff — so it refuses to answer from text it no longer holds.
13928
- * Re-syncing and asking again is what an editor does. Giving up reads as an outage, the
13929
- * one thing the reader can act on, rather than as a failed route.
13930
- */
13931
- async query(method, params, path) {
13932
- for (let attempt = 0;; attempt++) {
13933
- const rpc = path == null ? await this.ensureReady() : await this.sync(path);
13934
- try {
13935
- const pending = busyReason(this.current);
13936
- const result = await rpc.request(method, params);
13937
- if (result == null || Array.isArray(result) && !result.length) {
13938
- const busy = busyReason(this.current) ?? pending;
13939
- if (busy) throw new LspUnavailableError(busy);
13940
- if (this.current.notice) {
13941
- const { severity, message } = this.current.notice;
13942
- throw new LspUnavailableError(`${this.current.name} returned no result; last server ${severity}: ${message}`);
13943
- }
13944
- }
13945
- return result;
13946
- } catch (e) {
13947
- if (!(e instanceof JsonRpcError) || e.code !== CONTENT_MODIFIED) throw e;
13948
- const backoff = CONTENT_MODIFIED_BACKOFF_MS[attempt];
13949
- if (backoff == null) throw new LspUnavailableError(`${this.current.name} could not answer while ${path ?? "the repository"} kept changing; try again`);
13950
- await new Promise((r) => setTimeout(r, backoff));
13951
- }
13952
- }
14024
+ /** The text the server holds for an open path. */
14025
+ textOf(path) {
14026
+ return this.open.get(path)?.text;
13953
14027
  }
13954
14028
  /**
13955
- * Opens or updates the document so the server sees the snapshot's text before
13956
- * each request. Concurrent syncs of one path share a single read and
13957
- * notification. Untracked documents beyond `maxOpen` are closed, least
13958
- * recently used first; tracked ones stay open for the snapshot's lifetime.
14029
+ * Opens or updates the document so the server sees the snapshot's text before a
14030
+ * request. Concurrent syncs of one path share a single read and notification.
14031
+ * Untracked documents beyond `maxOpen` are closed, least recently used first;
14032
+ * tracked ones stay open for the snapshot's lifetime.
13959
14033
  */
13960
- async sync(path) {
13961
- const rpc = await this.ensureReady();
14034
+ sync(path) {
13962
14035
  let pending = this.syncing.get(path);
13963
14036
  if (!pending) {
13964
- pending = this.syncNow(rpc, path).finally(() => this.syncing.delete(path));
14037
+ pending = this.syncNow(path).finally(() => this.syncing.delete(path));
13965
14038
  this.syncing.set(path, pending);
13966
14039
  }
13967
- await pending;
13968
- return rpc;
14040
+ return pending;
14041
+ }
14042
+ /**
14043
+ * Keeps the diff's own files open in the server with their snapshot text.
14044
+ * Servers index the workspace lazily and cap how much of it they index
14045
+ * (pyrefly: in the background from the first open, 2000 files), so a
14046
+ * reference in a changed file that nobody queried yet is otherwise only found
14047
+ * once its file happens to be indexed, and from the text on disk. Opening
14048
+ * everything at start also warms the index before the first query. Calls are
14049
+ * serialized; the last set wins. Never throws: a path the snapshot cannot read
14050
+ * is skipped.
14051
+ */
14052
+ track(paths) {
14053
+ const run = this.tracking.then(() => this.trackNow(paths)).catch(() => {});
14054
+ this.tracking = run;
14055
+ return run;
14056
+ }
14057
+ async trackNow(paths) {
14058
+ const rpc = await this.opts.connection();
14059
+ const next = new Set(paths);
14060
+ for (const path of this.tracked) {
14061
+ if (next.has(path)) continue;
14062
+ this.tracked.delete(path);
14063
+ if (this.open.delete(path)) rpc.notify("textDocument/didClose", { textDocument: { uri: this.opts.uri(path) } });
14064
+ }
14065
+ for (const path of next) try {
14066
+ await this.sync(path);
14067
+ this.tracked.add(path);
14068
+ } catch (e) {
14069
+ if (e instanceof LspUnavailableError) return;
14070
+ }
13969
14071
  }
13970
- async syncNow(rpc, path) {
14072
+ async syncNow(path) {
14073
+ const rpc = await this.opts.connection();
13971
14074
  const text = await this.opts.read(path);
13972
14075
  if (text == null) throw new Error(`${path} is not in the snapshot`);
13973
- const uri = this.uri(path);
14076
+ const uri = this.opts.uri(path);
13974
14077
  const doc = this.open.get(path);
13975
14078
  if (!doc) {
13976
14079
  this.open.set(path, {
@@ -14010,31 +14113,72 @@ var LspBridge = class LspBridge {
14010
14113
  if (this.tracked.has(path) || this.syncing.has(path)) continue;
14011
14114
  this.open.delete(path);
14012
14115
  untracked--;
14013
- rpc.notify("textDocument/didClose", { textDocument: { uri: this.uri(path) } });
14116
+ rpc.notify("textDocument/didClose", { textDocument: { uri: this.opts.uri(path) } });
14014
14117
  }
14015
14118
  }
14016
- uri(path) {
14017
- return pathToFileURL(join(this.opts.root, path)).href;
14018
- }
14019
- /**
14020
- * Where each `file:` link in hover text points inside the repository: the repo-relative path and,
14021
- * from the URI's `#line` or `#line,col` fragment, the 1-based line. Links outside the root are
14022
- * left out, so the client never offers a jump it cannot make.
14023
- */
14024
- async resolveLinks(md) {
14025
- const uris = fileLinkUris(md);
14026
- const out = /* @__PURE__ */ new Map();
14027
- await Promise.all(uris.map(async (uri) => {
14028
- const hash = uri.indexOf("#");
14029
- const path = await this.pathOf(hash === -1 ? uri : uri.slice(0, hash));
14030
- if (path == null) return;
14031
- const line = hash === -1 ? null : /^L?(\d+)/.exec(uri.slice(hash + 1))?.[1];
14032
- out.set(uri, line == null ? { path } : {
14033
- path,
14034
- line: Number(line)
14035
- });
14036
- }));
14037
- return out;
14119
+ };
14120
+ //#endregion
14121
+ //#region src/server/lsp/hover.ts
14122
+ /**
14123
+ * One markdown document for the tooltip, or null when the server sent nothing
14124
+ * worth showing. Plaintext and `{language, value}` parts become fenced blocks,
14125
+ * so their text is never re-read as markdown.
14126
+ */
14127
+ function hoverMarkdown(contents) {
14128
+ if (contents == null) return null;
14129
+ const parts = (Array.isArray(contents) ? contents : [contents]).map(partMarkdown).filter((p) => p.trim() !== "");
14130
+ return parts.length ? parts.join("\n\n") : null;
14131
+ }
14132
+ function partMarkdown(part) {
14133
+ if (typeof part === "string") return part;
14134
+ if ("kind" in part) return part.kind === "markdown" ? part.value : fence(part.value, "");
14135
+ return fence(part.value, part.language);
14136
+ }
14137
+ /** A fence long enough that a backtick run inside the code cannot close it early. */
14138
+ function fence(code, lang) {
14139
+ const longest = Math.max(2, ...[...code.matchAll(/`+/g)].map((m) => m[0].length));
14140
+ const ticks = "`".repeat(longest + 1);
14141
+ return `${ticks}${lang}\n${code.replace(/\n$/, "")}\n${ticks}`;
14142
+ }
14143
+ /** An inline markdown link whose target is a `file:` URI, as pyright and pyrefly write "Go to X". */
14144
+ const fileLink = () => /\[([^\]]*)\]\(\s*<?(file:[^\s)>]+)>?\s*\)/g;
14145
+ /** The `file:` URIs the hover text links to, each once. */
14146
+ function fileLinkUris(md) {
14147
+ return [...new Set([...md.matchAll(fileLink())].map((m) => m[2]))];
14148
+ }
14149
+ /**
14150
+ * Rewrites the hover text's `file:` links to `diffle:<path>#L<line>`, which the client follows as an
14151
+ * in-app jump. `resolved` maps each URI to its repo-relative path and line; a URI it omits lies
14152
+ * outside the repository and becomes plain text, since opening a browser tab on a source file is
14153
+ * never what the reader wants.
14154
+ */
14155
+ function localizeFileLinks(md, resolved) {
14156
+ return md.replace(fileLink(), (_m, label, uri) => {
14157
+ const loc = resolved.get(uri);
14158
+ if (!loc) return label;
14159
+ return `[${label}](diffle:${encodeURI(loc.path)}${loc.line != null ? `#L${loc.line}` : ""})`;
14160
+ });
14161
+ }
14162
+ //#endregion
14163
+ //#region src/server/lsp/Locator.ts
14164
+ /** Concurrent snapshot reads while mapping a result set back to paths. */
14165
+ const READ_CONCURRENCY = 8;
14166
+ /** Files outside the snapshot remembered for `readExternal`; the least recently named is forgotten first. */
14167
+ const MAX_EXTERNAL = 1024;
14168
+ /**
14169
+ * Maps the URIs a language server answers with back to snapshot paths, and remembers
14170
+ * the files outside the snapshot it named, so `/api/file` may serve those and nothing
14171
+ * else beyond the allowlist.
14172
+ */
14173
+ var Locator = class {
14174
+ opts;
14175
+ /** Symlink-free root; servers often canonicalize, so URIs may not share the root's spelling. */
14176
+ realRoot;
14177
+ /** Absolute paths of files outside the snapshot that results named, least recently first. */
14178
+ external = /* @__PURE__ */ new Set();
14179
+ constructor(opts) {
14180
+ this.opts = opts;
14181
+ this.realRoot = safeRealpathSync(opts.root);
14038
14182
  }
14039
14183
  /**
14040
14184
  * Repo-relative path for a file URI inside the root; null otherwise. Compares
@@ -14055,13 +14199,13 @@ var LspBridge = class LspBridge {
14055
14199
  return relativeInside(this.realRoot, real);
14056
14200
  }
14057
14201
  /**
14058
- * Maps server locations to paths with the text of each line: a snapshot path,
14059
- * or, for a file the snapshot lacks (stdlib, site-packages, an ignored venv),
14060
- * the file itself read from disk and marked external. Each distinct URI is
14061
- * read once, in parallel with a limit, so a large reference set costs a
14062
- * bounded number of reads. A file readable neither way is dropped.
14202
+ * Server locations as paths with the text of each line: a snapshot path, or, for a
14203
+ * file the snapshot lacks (stdlib, site-packages, an ignored venv), the file itself
14204
+ * read from disk and marked external. Each distinct URI is read once, in parallel
14205
+ * with a limit, so a large reference set costs a bounded number of reads. A file
14206
+ * readable neither way is dropped.
14063
14207
  */
14064
- async toLocations(locs) {
14208
+ async locations(locs) {
14065
14209
  const uris = [...new Set(locs.map((l) => l.uri))];
14066
14210
  const found = await mapLimit(uris, READ_CONCURRENCY, (uri) => this.locate(uri));
14067
14211
  const byUri = new Map(uris.map((uri, i) => [uri, found[i]]));
@@ -14079,12 +14223,48 @@ var LspBridge = class LspBridge {
14079
14223
  if (f.external) loc.external = true;
14080
14224
  out.push(loc);
14081
14225
  }
14082
- return { locations: out };
14226
+ return out;
14227
+ }
14228
+ /**
14229
+ * Where each `file:` link in hover text points inside the repository: the repo-relative path and,
14230
+ * from the URI's `#line` or `#line,col` fragment, the 1-based line. Links outside the root are
14231
+ * left out, so the client never offers a jump it cannot make.
14232
+ */
14233
+ async links(md) {
14234
+ const uris = fileLinkUris(md);
14235
+ const out = /* @__PURE__ */ new Map();
14236
+ await Promise.all(uris.map(async (uri) => {
14237
+ const hash = uri.indexOf("#");
14238
+ const path = await this.pathOf(hash === -1 ? uri : uri.slice(0, hash));
14239
+ if (path == null) return;
14240
+ const line = hash === -1 ? null : /^L?(\d+)/.exec(uri.slice(hash + 1))?.[1];
14241
+ out.set(uri, line == null ? { path } : {
14242
+ path,
14243
+ line: Number(line)
14244
+ });
14245
+ }));
14246
+ return out;
14247
+ }
14248
+ /** `has` for each distinct path, in parallel. */
14249
+ async membership(paths) {
14250
+ const distinct = [...new Set(paths.filter((p) => p != null))];
14251
+ const flags = await mapLimit(distinct, READ_CONCURRENCY, (p) => this.opts.has(p));
14252
+ return new Map(distinct.map((p, i) => [p, flags[i]]));
14253
+ }
14254
+ /**
14255
+ * Contents of a file outside the snapshot that a recent result named as
14256
+ * `external`, read from disk; null for every other path. This is the only
14257
+ * read done on a client's behalf, so a path the language server never
14258
+ * returned stays behind the snapshot allowlist.
14259
+ */
14260
+ async readExternal(path) {
14261
+ if (!this.external.has(path)) return null;
14262
+ return readFile(path).catch(() => null);
14083
14263
  }
14084
14264
  async locate(uri) {
14085
14265
  const path = await this.pathOf(uri);
14086
14266
  if (path != null) {
14087
- const text = this.open.get(path)?.text ?? await this.opts.read(path);
14267
+ const text = this.opts.textOf(path) ?? await this.opts.read(path);
14088
14268
  if (text != null) return {
14089
14269
  path,
14090
14270
  text,
@@ -14121,11 +14301,6 @@ function relativeInside(root, abs) {
14121
14301
  if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
14122
14302
  return rel.split("\\").join("/");
14123
14303
  }
14124
- /** How status messages name a server: `basedpyright-langserver --stdio` → `basedpyright-langserver`. */
14125
- function programName(command) {
14126
- const prog = argv0(command);
14127
- return prog.split(/[\\/]/).pop() || prog;
14128
- }
14129
14304
  /**
14130
14305
  * Canonical `p`, or `p` itself when it cannot be resolved. Uses libuv's
14131
14306
  * realpath, the same one the async `realpath` in `pathOf` uses: the JS
@@ -14139,28 +14314,6 @@ function safeRealpathSync(p) {
14139
14314
  return p;
14140
14315
  }
14141
14316
  }
14142
- function toLsp(pos) {
14143
- return {
14144
- line: Math.max(0, pos.line - 1),
14145
- character: Math.max(0, pos.col)
14146
- };
14147
- }
14148
- function normalizeLocations(r) {
14149
- if (!r) return [];
14150
- return (Array.isArray(r) ? r : [r]).map((x) => "targetUri" in x ? {
14151
- uri: x.targetUri,
14152
- range: x.targetSelectionRange ?? x.targetRange
14153
- } : x);
14154
- }
14155
- function isDocumentSymbols(r) {
14156
- return r.length > 0 && !("location" in r[0]);
14157
- }
14158
- /**
14159
- * `shell: true` runs the command under `sh -c`, so a plain kill would stop the
14160
- * shell and orphan the language server. On POSIX the child leads its own process
14161
- * group (`detached`), and shutdown signals the whole group.
14162
- */
14163
- const DETACH = process.platform !== "win32";
14164
14317
  function lineAt(text, line) {
14165
14318
  let start = 0;
14166
14319
  for (let i = 1; i < line; i++) {
@@ -14171,17 +14324,338 @@ function lineAt(text, line) {
14171
14324
  const end = text.indexOf("\n", start);
14172
14325
  return (end === -1 ? text.slice(start) : text.slice(start, end)).replace(/\r$/, "");
14173
14326
  }
14174
- function lastLine(s) {
14175
- const lines = s.trim().split("\n");
14176
- return lines[lines.length - 1] ?? "";
14327
+ //#endregion
14328
+ //#region src/server/lsp/LspBridge.ts
14329
+ /** LSP `ContentModified`: the document moved under the request, so the answer would be stale. */
14330
+ const CONTENT_MODIFIED = -32801;
14331
+ /** Waits before re-asking a request the server refused as stale, one per attempt. */
14332
+ const CONTENT_MODIFIED_BACKOFF_MS = [40, 120];
14333
+ /**
14334
+ * One language server as diffle asks it: each LSP request against a document synced
14335
+ * from the snapshot, with the answer mapped back to snapshot paths. The process, the
14336
+ * open documents and the path mapping are `LspProcess`, `Documents` and `Locator`;
14337
+ * this is where the requests and their result shapes live.
14338
+ */
14339
+ var LspBridge = class {
14340
+ opts;
14341
+ process;
14342
+ documents;
14343
+ locator;
14344
+ constructor(opts) {
14345
+ this.opts = opts;
14346
+ this.process = new LspProcess(opts);
14347
+ this.documents = new Documents({
14348
+ connection: () => this.process.connection(),
14349
+ read: opts.read,
14350
+ uri: (path) => this.uri(path),
14351
+ maxOpen: opts.maxOpen
14352
+ });
14353
+ this.locator = new Locator({
14354
+ root: opts.root,
14355
+ read: opts.read,
14356
+ has: opts.has,
14357
+ textOf: (path) => this.documents.textOf(path)
14358
+ });
14359
+ }
14360
+ status() {
14361
+ return this.process.status();
14362
+ }
14363
+ /** The retained log lines, oldest first. */
14364
+ entries() {
14365
+ return this.process.entries();
14366
+ }
14367
+ /** What the server's `initialize` result advertised; empty while starting. */
14368
+ features() {
14369
+ return this.process.features();
14370
+ }
14371
+ async definition(pos) {
14372
+ return this.locations("definition", "textDocument/definition", pos);
14373
+ }
14374
+ async typeDefinition(pos) {
14375
+ return this.locations("typeDefinition", "textDocument/typeDefinition", pos);
14376
+ }
14377
+ async references(pos) {
14378
+ return this.locations("references", "textDocument/references", pos, { context: { includeDeclaration: true } });
14379
+ }
14380
+ async hover(pos) {
14381
+ const { result, busy } = await this.query("textDocument/hover", {
14382
+ textDocument: { uri: this.uri(pos.path) },
14383
+ position: toLsp(pos)
14384
+ }, pos.path, "hover");
14385
+ const md = result ? hoverMarkdown(result.contents) : null;
14386
+ if (md == null) return inconclusive({ contents: null }, true, busy);
14387
+ const res = { contents: localizeFileLinks(md, await this.locator.links(md)) };
14388
+ if (result?.range) res.range = fromLsp(result.range);
14389
+ return res;
14390
+ }
14391
+ /**
14392
+ * Where else the symbol at `pos` occurs in its file, via `textDocument/documentHighlight`,
14393
+ * so scope and shadowing are the server's call. A server not offering the request, or a
14394
+ * position holding no symbol, yields no occurrences: the answer is advisory.
14395
+ */
14396
+ async occurrences(pos) {
14397
+ const { result } = await this.query("textDocument/documentHighlight", {
14398
+ textDocument: { uri: this.uri(pos.path) },
14399
+ position: toLsp(pos)
14400
+ }, pos.path, "documentHighlight").catch(unsupported(null));
14401
+ return { occurrences: (result ?? []).map(({ range, kind }) => ({
14402
+ ...fromLsp(range),
14403
+ kind: kind === 3 ? "write" : kind === 2 ? "read" : "text"
14404
+ })) };
14405
+ }
14406
+ /** Keeps the diff's own files open in the server (see `Documents.track`). Never throws. */
14407
+ track(paths) {
14408
+ return this.documents.track(paths);
14409
+ }
14410
+ async documentSymbols(path) {
14411
+ const { result, busy } = await this.query("textDocument/documentSymbol", { textDocument: { uri: this.uri(path) } }, path, "documentSymbol");
14412
+ const symbols = result ? flattenSymbols(result, path) : [];
14413
+ return inconclusive({ symbols }, !symbols.length, busy);
14414
+ }
14415
+ /**
14416
+ * Symbols the server knows for `query`, limited to paths the snapshot exposes, in the
14417
+ * server's order: the pool ranks the union. Membership comes from `has`, never from
14418
+ * reading the file: a per-keystroke search must not read every hit's text.
14419
+ */
14420
+ async workspaceSymbols(query) {
14421
+ const { result, busy } = await this.query("workspace/symbol", { query }, void 0, "workspaceSymbol");
14422
+ if (!result) return inconclusive({ symbols: [] }, true, busy);
14423
+ const paths = await Promise.all(result.map((s) => this.locator.pathOf(s.location.uri)));
14424
+ const inside = await this.locator.membership(paths);
14425
+ const out = [];
14426
+ const seen = /* @__PURE__ */ new Set();
14427
+ result.forEach((s, i) => {
14428
+ const path = paths[i];
14429
+ if (path == null || !inside.get(path)) return;
14430
+ const symbol = symbolInformation(s, path);
14431
+ const key = `${path}:${symbol.line}:${symbol.col}:${symbol.name}`;
14432
+ if (seen.has(key)) return;
14433
+ seen.add(key);
14434
+ out.push(symbol);
14435
+ });
14436
+ return inconclusive({ symbols: out }, !out.length, busy);
14437
+ }
14438
+ /** Reads a file outside the snapshot only if a result named it (see `Locator.readExternal`). */
14439
+ readExternal(path) {
14440
+ return this.locator.readExternal(path);
14441
+ }
14442
+ close() {
14443
+ return this.process.close();
14444
+ }
14445
+ async locations(feature, method, pos, extra = {}) {
14446
+ const { result, busy } = await this.query(method, {
14447
+ textDocument: { uri: this.uri(pos.path) },
14448
+ position: toLsp(pos),
14449
+ ...extra
14450
+ }, pos.path, feature);
14451
+ const locations = await this.locator.locations(normalizeLocations(result));
14452
+ return inconclusive({ locations }, !locations.length, busy);
14453
+ }
14454
+ /**
14455
+ * One request against a synced document, re-asked while the server answers
14456
+ * `ContentModified`: something moved the document under it — a watcher refresh, or a
14457
+ * `track` pass opening the diff — so it refuses to answer from text it no longer holds.
14458
+ * Re-syncing and asking again is what an editor does. Giving up reads as an outage, the
14459
+ * one thing the reader can act on, rather than as a failed route. A `feature` the
14460
+ * server never advertised is not asked for. `busy` is why an empty `result` may be
14461
+ * incomplete: background work the server reported before or during the request.
14462
+ */
14463
+ async query(method, params, path, feature) {
14464
+ for (let attempt = 0;; attempt++) {
14465
+ if (path != null) await this.documents.sync(path);
14466
+ const rpc = await this.process.connection();
14467
+ if (feature && !this.process.supports(feature)) throw new LspUnsupportedError(`${this.process.name} offers no ${LSP_FEATURE_LABELS[feature]}`);
14468
+ try {
14469
+ const pending = this.process.busy();
14470
+ return {
14471
+ result: await rpc.request(method, params),
14472
+ busy: this.process.busy() ?? pending
14473
+ };
14474
+ } catch (e) {
14475
+ if (!(e instanceof JsonRpcError) || e.code !== CONTENT_MODIFIED) throw e;
14476
+ const backoff = CONTENT_MODIFIED_BACKOFF_MS[attempt];
14477
+ if (backoff == null) throw new LspUnavailableError(`${this.process.name} could not answer while ${path ?? "the repository"} kept changing; try again`);
14478
+ await new Promise((r) => setTimeout(r, backoff));
14479
+ }
14480
+ }
14481
+ }
14482
+ uri(path) {
14483
+ return pathToFileURL(join(this.opts.root, path)).href;
14484
+ }
14485
+ };
14486
+ /** A catch handler answering `result` for a request the server does not offer; anything else propagates. */
14487
+ function unsupported(result) {
14488
+ return (e) => {
14489
+ if (e instanceof LspUnsupportedError) return {
14490
+ result,
14491
+ busy: null
14492
+ };
14493
+ throw e;
14494
+ };
14495
+ }
14496
+ //#endregion
14497
+ //#region src/server/lsp/merge.ts
14498
+ /**
14499
+ * The union of what several servers answered. A server that fails drops its share;
14500
+ * every one failing is the first failure. When the union is `empty`, the first reason it
14501
+ * may be incomplete stands: a server's own, else a failed server's message, so an
14502
+ * owner still starting reads as such rather than as silence.
14503
+ */
14504
+ async function union(asked, merge, empty) {
14505
+ const results = await Promise.allSettled(asked);
14506
+ const answers = results.flatMap((r) => r.status === "fulfilled" ? [r.value] : []);
14507
+ const failed = results.find((r) => r.status === "rejected");
14508
+ if (!answers.length && failed) throw failed.reason;
14509
+ const merged = merge(answers);
14510
+ if (empty(merged)) {
14511
+ const reason = answers.find((a) => a.inconclusive)?.inconclusive ?? (failed ? messageOf(failed.reason) : void 0);
14512
+ if (reason) merged.inconclusive = reason;
14513
+ }
14514
+ return merged;
14515
+ }
14516
+ function messageOf(e) {
14517
+ return e instanceof Error ? e.message : String(e);
14518
+ }
14519
+ /** Every server's hover, owner first, as one tooltip; the span is the first one offered. */
14520
+ function mergeHovers(answers) {
14521
+ const said = answers.filter((a) => a.contents != null);
14522
+ if (!said.length) return { contents: null };
14523
+ const range = said.find((a) => a.range)?.range;
14524
+ return {
14525
+ contents: said.map((a) => a.contents).join("\n\n---\n\n"),
14526
+ ...range ? { range } : {}
14527
+ };
14528
+ }
14529
+ /** The union of every server's locations, owner first, each place once. */
14530
+ function mergeLocations(answers) {
14531
+ const seen = /* @__PURE__ */ new Set();
14532
+ return { locations: answers.flatMap((a) => a.locations.filter((l) => {
14533
+ const key = `${l.path}\0${l.line}\0${l.col}`;
14534
+ if (seen.has(key)) return false;
14535
+ seen.add(key);
14536
+ return true;
14537
+ })) };
14538
+ }
14539
+ //#endregion
14540
+ //#region src/server/lsp/tsdk.ts
14541
+ /**
14542
+ * The TypeScript `lib` directory a Volar server such as astro-ls loads as its
14543
+ * `typescript.tsdk`, or null when none is found. The repository's own `typescript`
14544
+ * wins, so the server reads code with the project's version; after it, the one
14545
+ * installed beside the first of `executables` that has one. Node's resolution from an
14546
+ * executable's real location reaches the package in the layouts of npm (hoisted under
14547
+ * `lib/node_modules`), a Windows `.cmd` shim (beside `node_modules`) and nixpkgs (the
14548
+ * pnpm store the symlink's target sits in). A conda or pixi environment runs a launcher
14549
+ * in `bin`, so each package under its `lib/node_modules` is tried as well, where the
14550
+ * TypeScript is nested or hoisted. TypeScript 7 ships no `typescript.js`, so it is
14551
+ * passed over: Volar cannot load the Go port.
14552
+ */
14553
+ function tsdkLocation(root, executables) {
14554
+ const bases = [root];
14555
+ for (const executable of executables) {
14556
+ if (executable == null) continue;
14557
+ const bin = dirname(realExecutable(executable));
14558
+ bases.push(bin, ...globalPackages(resolve(bin, "..", "lib", "node_modules")));
14559
+ }
14560
+ for (const base of bases) {
14561
+ const lib = typescriptLib(base);
14562
+ if (lib) return lib;
14563
+ }
14564
+ return null;
14565
+ }
14566
+ /** The package directories under a global `node_modules`, scoped ones included; none when it is not there. */
14567
+ function globalPackages(modules) {
14568
+ const entries = (dir) => {
14569
+ try {
14570
+ return readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory() || e.isSymbolicLink()).map((e) => e.name);
14571
+ } catch {
14572
+ return [];
14573
+ }
14574
+ };
14575
+ return entries(modules).flatMap((name) => name.startsWith("@") ? entries(join(modules, name)).map((sub) => join(modules, name, sub)) : [join(modules, name)]);
14576
+ }
14577
+ /** `typescript/lib` as resolved from `base`, when it holds a loadable TypeScript. */
14578
+ function typescriptLib(base) {
14579
+ try {
14580
+ const lib = join(dirname(createRequire(join(base, "package.json")).resolve("typescript/package.json")), "lib");
14581
+ return ["typescript.js", "tsserverlibrary.js"].some((file) => existsSync(join(lib, file))) ? lib : null;
14582
+ } catch {
14583
+ return null;
14584
+ }
14585
+ }
14586
+ //#endregion
14587
+ //#region src/server/lsp/vue.ts
14588
+ /** The tsserver plugin that turns .vue files into TypeScript documents. */
14589
+ const VUE_PLUGIN = "@vue/typescript-plugin";
14590
+ /**
14591
+ * Where tsserver loads the Vue plugin from, given the `vue-language-server` executable,
14592
+ * or null when no package holding the plugin is found beside it. Vue language tools 3
14593
+ * serve .vue files through this plugin inside tsserver; the standalone server only
14594
+ * relays to a tsserver the editor must bridge, so diffle never runs it. The package is
14595
+ * reached through the layouts of npm (a symlink into the package, or a `.cmd` shim
14596
+ * beside `node_modules` on Windows), conda and pixi (a launcher in `bin` beside
14597
+ * `lib/node_modules`, behind a pixi global trampoline), and nixpkgs (a wrapper beside
14598
+ * `lib/language-tools`).
14599
+ */
14600
+ function vuePluginLocation(executable) {
14601
+ const real = realExecutable(executable);
14602
+ const candidates = [
14603
+ resolve(dirname(real), ".."),
14604
+ join(dirname(executable), "node_modules", "@vue", "language-server"),
14605
+ resolve(dirname(real), "..", "lib", "node_modules", "@vue", "language-server"),
14606
+ resolve(dirname(real), "..", "lib", "language-tools", "packages", "language-server")
14607
+ ];
14608
+ for (const dir of candidates) try {
14609
+ createRequire(join(dir, "package.json")).resolve(VUE_PLUGIN);
14610
+ return dir;
14611
+ } catch {}
14612
+ return null;
14177
14613
  }
14178
- /** Why an empty result may be incomplete while the server reports background work. */
14179
- function busyReason(s) {
14180
- const work = s.activity?.length ? s.activity.join("; ") : null;
14181
- return work ? `${s.name} is busy: ${work}; try again when it finishes` : null;
14614
+ /**
14615
+ * The tsserver plugin entry for .vue files when `vue-language-server` is on PATH, else
14616
+ * null. Loaded whenever a TypeScript server starts, not only when the diff holds a .vue
14617
+ * file: a process serving .ts files first and .vue files after a refresh cannot load it
14618
+ * later, and .ts files importing components resolve them. `lookup` is the PATH probe.
14619
+ */
14620
+ function vuePlugin(lookup) {
14621
+ const executable = lookup("vue-language-server");
14622
+ const location = executable == null ? null : vuePluginLocation(executable);
14623
+ return location == null ? null : {
14624
+ name: VUE_PLUGIN,
14625
+ location,
14626
+ languages: ["vue"]
14627
+ };
14182
14628
  }
14183
14629
  //#endregion
14184
14630
  //#region src/server/lsp/registry.ts
14631
+ const TYPESCRIPT = [
14632
+ "typescript",
14633
+ "typescriptreact",
14634
+ "javascript",
14635
+ "javascriptreact"
14636
+ ];
14637
+ const TSSERVER = {
14638
+ command: "typescript-language-server --stdio",
14639
+ initializationOptions: ({ lookup }) => {
14640
+ const plugin = vuePlugin(lookup);
14641
+ return plugin ? { plugins: [plugin] } : void 0;
14642
+ }
14643
+ };
14644
+ const VTSLS = {
14645
+ command: "vtsls --stdio",
14646
+ settings: ({ lookup }) => {
14647
+ const plugin = vuePlugin(lookup);
14648
+ return plugin ? { vtsls: { tsserver: { globalPlugins: [{
14649
+ ...plugin,
14650
+ configNamespace: "typescript"
14651
+ }] } } } : {};
14652
+ }
14653
+ };
14654
+ const VUE = {
14655
+ program: "vue-language-server",
14656
+ what: VUE_PLUGIN,
14657
+ locate: vuePluginLocation
14658
+ };
14185
14659
  /**
14186
14660
  * Built-in servers, in preference order per language: the first one whose program is
14187
14661
  * on PATH is used. Entries serving several languages become one process, which is how
@@ -14213,22 +14687,34 @@ const CANDIDATES = [
14213
14687
  command: "jedi-language-server"
14214
14688
  },
14215
14689
  {
14216
- languages: [
14217
- "typescript",
14218
- "typescriptreact",
14219
- "javascript",
14220
- "javascriptreact"
14221
- ],
14222
- command: "typescript-language-server --stdio"
14690
+ languages: TYPESCRIPT,
14691
+ ...TSSERVER
14223
14692
  },
14224
14693
  {
14225
- languages: [
14226
- "typescript",
14227
- "typescriptreact",
14228
- "javascript",
14229
- "javascriptreact"
14230
- ],
14231
- command: "vtsls --stdio"
14694
+ languages: TYPESCRIPT,
14695
+ ...VTSLS
14696
+ },
14697
+ {
14698
+ languages: ["vue"],
14699
+ ...TSSERVER,
14700
+ requires: VUE
14701
+ },
14702
+ {
14703
+ languages: ["vue"],
14704
+ ...VTSLS,
14705
+ requires: VUE
14706
+ },
14707
+ {
14708
+ languages: ["astro"],
14709
+ command: "astro-ls --stdio",
14710
+ initializationOptions: ({ root, lookup }) => {
14711
+ const tsdk = tsdkLocation(root, [
14712
+ "astro-ls",
14713
+ "typescript-language-server",
14714
+ "vtsls"
14715
+ ].map((c) => lookup(c)));
14716
+ return tsdk ? { typescript: { tsdk } } : void 0;
14717
+ }
14232
14718
  },
14233
14719
  {
14234
14720
  languages: ["rust"],
@@ -14254,6 +14740,18 @@ const CANDIDATES = [
14254
14740
  languages: ["java"],
14255
14741
  command: "jdtls"
14256
14742
  },
14743
+ {
14744
+ languages: ["csharp"],
14745
+ command: "roslyn-language-server --stdio --autoLoadProjects"
14746
+ },
14747
+ {
14748
+ languages: ["csharp"],
14749
+ command: "Microsoft.CodeAnalysis.LanguageServer --stdio --autoLoadProjects"
14750
+ },
14751
+ {
14752
+ languages: ["csharp"],
14753
+ command: "csharp-ls"
14754
+ },
14257
14755
  {
14258
14756
  languages: ["lua"],
14259
14757
  command: "lua-language-server"
@@ -14300,15 +14798,24 @@ const CANDIDATES = [
14300
14798
  },
14301
14799
  {
14302
14800
  languages: ["json", "jsonc"],
14303
- command: "vscode-json-language-server --stdio"
14801
+ command: "vscode-json-language-server --stdio",
14802
+ settings: async () => ({ json: { schemas: await loadSchemaCatalog() } })
14304
14803
  },
14305
14804
  {
14306
14805
  languages: ["json", "jsonc"],
14307
- command: "vscode-json-languageserver --stdio"
14806
+ command: "vscode-json-languageserver --stdio",
14807
+ settings: async () => ({ json: { schemas: await loadSchemaCatalog() } })
14308
14808
  },
14309
14809
  {
14310
14810
  languages: ["yaml"],
14311
- command: "yaml-language-server --stdio"
14811
+ command: "yaml-language-server --stdio",
14812
+ settings: () => ({ yaml: {
14813
+ hover: true,
14814
+ schemaStore: {
14815
+ enable: true,
14816
+ url: SCHEMA_CATALOG
14817
+ }
14818
+ } })
14312
14819
  },
14313
14820
  {
14314
14821
  languages: ["toml"],
@@ -14316,35 +14823,117 @@ const CANDIDATES = [
14316
14823
  },
14317
14824
  {
14318
14825
  languages: ["toml"],
14319
- command: "taplo lsp stdio"
14826
+ command: "taplo lsp stdio",
14827
+ settings: () => ({ evenBetterToml: { schema: {
14828
+ enabled: true,
14829
+ catalogs: [SCHEMA_CATALOG]
14830
+ } } })
14831
+ },
14832
+ {
14833
+ languages: ["css"],
14834
+ command: "vscode-css-language-server --stdio",
14835
+ settings: () => ({
14836
+ css: { validate: false },
14837
+ less: { validate: false },
14838
+ scss: { validate: false }
14839
+ })
14840
+ },
14841
+ {
14842
+ languages: ["html"],
14843
+ command: "vscode-html-language-server --stdio"
14844
+ },
14845
+ {
14846
+ languages: [],
14847
+ joins: [
14848
+ "astro",
14849
+ "css",
14850
+ "html",
14851
+ "javascript",
14852
+ "javascriptreact",
14853
+ "typescript",
14854
+ "typescriptreact",
14855
+ "vue"
14856
+ ],
14857
+ command: "tailwindcss-language-server --stdio"
14320
14858
  }
14321
14859
  ];
14322
- /** The candidate commands for a language, in the order they are tried. */
14323
- function candidatesFor(language) {
14324
- return CANDIDATES.filter((c) => c.languages.includes(language)).map((c) => c.command);
14860
+ /**
14861
+ * How a command line's program is configured, by the built-in definition for that program:
14862
+ * an override naming a known server with other flags or a full path still gets its
14863
+ * settings. A program diffle does not know takes none.
14864
+ */
14865
+ function definitionOf(command) {
14866
+ const program = programOf(command);
14867
+ const known = CANDIDATES.find((c) => programOf(c.command) === program);
14868
+ return {
14869
+ command,
14870
+ settings: known?.settings,
14871
+ initializationOptions: known?.initializationOptions
14872
+ };
14325
14873
  }
14326
14874
  /**
14327
14875
  * Which servers to start for `languages`. A config override replaces the candidate
14328
14876
  * list for its language and is never probed: the user named a command, so a typo
14329
14877
  * should surface as the server failing to start, not as silence. An override of `''`
14330
14878
  * turns the language off. Languages resolving to the same command line share one
14331
- * process. `lookup` is a test seam for the PATH probe.
14879
+ * process. A missing language's `tried` names the programs that were not on PATH, so
14880
+ * a candidate needing two programs lists only the absent one; a program on PATH that
14881
+ * lacks what the candidate loads from beside it is as good as absent, and the first
14882
+ * such finding is the language's `why`. A candidate joining a requested language
14883
+ * starts too, when on PATH, whatever owns it, and joins only the requested ones,
14884
+ * growing like an owner does as later snapshots ask for more; its absence is no one's
14885
+ * missing server. `lookup` is a test seam for the PATH probe.
14332
14886
  */
14333
14887
  function resolveServers(languages, overrides = {}, lookup = (c) => findOnPath(c)) {
14334
- const probe = /* @__PURE__ */ new Map();
14335
- const found = (command) => {
14336
- let hit = probe.get(command);
14337
- if (hit == null) probe.set(command, hit = lookup(command) != null);
14888
+ const probed = /* @__PURE__ */ new Map();
14889
+ const where = (command) => {
14890
+ let hit = probed.get(command);
14891
+ if (hit === void 0) probed.set(command, hit = lookup(command));
14338
14892
  return hit;
14339
14893
  };
14894
+ /** What keeps a candidate from starting: programs not on PATH, or one on PATH without what it loads from beside it. */
14895
+ const probe = (candidate) => {
14896
+ const absent = [];
14897
+ if (where(candidate.command) == null) absent.push(argv0(candidate.command));
14898
+ const { requires } = candidate;
14899
+ if (!requires) return { absent };
14900
+ const executable = where(requires.program);
14901
+ if (executable == null) absent.push(requires.program);
14902
+ else if (requires.locate(executable) == null) return {
14903
+ absent,
14904
+ why: `${requires.program} is on PATH but ${requires.what} was not found beside it`
14905
+ };
14906
+ return { absent };
14907
+ };
14908
+ const startable = (candidate) => {
14909
+ const { absent, why } = probe(candidate);
14910
+ return !absent.length && !why;
14911
+ };
14340
14912
  const servers = /* @__PURE__ */ new Map();
14341
14913
  const missing = [];
14342
- for (const language of [...new Set(languages)].sort()) {
14343
- const command = overrides[language]?.trim() ?? candidatesFor(language).find(found);
14914
+ const asked = [...new Set(languages)].sort();
14915
+ for (const language of asked) {
14916
+ let command = overrides[language]?.trim();
14917
+ const tried = [];
14918
+ let why;
14919
+ if (command == null) for (const candidate of CANDIDATES) {
14920
+ if (!candidate.languages.includes(language)) continue;
14921
+ const { absent, why: broken } = probe(candidate);
14922
+ if (!absent.length && !broken) {
14923
+ command = candidate.command;
14924
+ break;
14925
+ }
14926
+ why ??= broken;
14927
+ for (const program of absent) if (!tried.includes(program)) tried.push(program);
14928
+ }
14344
14929
  if (command == null || command === "") {
14345
- missing.push({
14930
+ missing.push(why ? {
14346
14931
  language,
14347
- tried: command === "" ? [] : candidatesFor(language).map(argv0)
14932
+ tried,
14933
+ why
14934
+ } : {
14935
+ language,
14936
+ tried
14348
14937
  });
14349
14938
  continue;
14350
14939
  }
@@ -14352,9 +14941,21 @@ function resolveServers(languages, overrides = {}, lookup = (c) => findOnPath(c)
14352
14941
  if (server) server.languages.push(language);
14353
14942
  else servers.set(command, {
14354
14943
  command,
14355
- languages: [language]
14944
+ languages: [language],
14945
+ joins: []
14356
14946
  });
14357
14947
  }
14948
+ for (const candidate of CANDIDATES) {
14949
+ const joins = (candidate.joins ?? []).filter((l) => asked.includes(l));
14950
+ if (!joins.length || !startable(candidate)) continue;
14951
+ const server = servers.get(candidate.command) ?? {
14952
+ command: candidate.command,
14953
+ languages: [],
14954
+ joins: []
14955
+ };
14956
+ servers.set(candidate.command, server);
14957
+ for (const l of joins) if (!server.languages.includes(l) && !server.joins.includes(l)) server.joins.push(l);
14958
+ }
14358
14959
  return {
14359
14960
  servers: [...servers.values()],
14360
14961
  missing
@@ -14363,21 +14964,31 @@ function resolveServers(languages, overrides = {}, lookup = (c) => findOnPath(c)
14363
14964
  //#endregion
14364
14965
  //#region src/server/lsp/LspPool.ts
14365
14966
  /**
14366
- * One language server per language in the diff, started the first time a snapshot
14367
- * names that language and kept for the run. Routes every request by the path's
14368
- * language, fans repository-wide queries out to all of them, and reports them as one
14369
- * status. Owns no protocol logic: each server is an `LspBridge`.
14967
+ * A status change goes out at once when the pool has been quiet, and later ones within
14968
+ * this window ride on one trailing publish: a server logging a hundred stderr lines
14969
+ * while indexing is a few broadcasts, not a hundred.
14970
+ */
14971
+ const PUBLISH_MS = 20;
14972
+ /**
14973
+ * The language servers of one run, started the first time a snapshot names a language
14974
+ * they serve and kept for the run. Routes like an editor: every server for a file's
14975
+ * language is asked at once and their hovers, definitions and references are merged,
14976
+ * owner first; highlights and outlines come from the owner alone, as two
14977
+ * servers' highlight sets would only clash. Repository-wide queries fan out to all
14978
+ * servers, and all of them report as one status. Owns no protocol logic: each server
14979
+ * is an `LspBridge`.
14370
14980
  */
14371
14981
  var LspPool = class {
14372
14982
  opts;
14373
14983
  /** Servers by command line: languages that resolve to the same command share one process. */
14374
14984
  entries = /* @__PURE__ */ new Map();
14375
- byLanguage = /* @__PURE__ */ new Map();
14376
14985
  missing = [];
14377
14986
  /** Languages already resolved, served or not, so the PATH probe runs once each. */
14378
14987
  resolved = /* @__PURE__ */ new Set();
14379
14988
  closing = false;
14380
14989
  closed;
14990
+ publishTimer = null;
14991
+ publishDue = false;
14381
14992
  constructor(opts) {
14382
14993
  this.opts = opts;
14383
14994
  this.ensure(opts.preload ?? []);
@@ -14387,11 +14998,17 @@ var LspPool = class {
14387
14998
  enabled: true,
14388
14999
  servers: [...this.entries.values()].map((e) => ({
14389
15000
  ...e.bridge.status(),
14390
- languages: [...e.languages]
15001
+ languages: [...e.languages],
15002
+ joins: [...e.joins],
15003
+ supports: e.bridge.features()
14391
15004
  })),
14392
15005
  missing: this.missing
14393
15006
  };
14394
15007
  }
15008
+ /** The retained log lines of the server started by `command`, or null when none was. */
15009
+ log(command) {
15010
+ return this.entries.get(command)?.bridge.entries() ?? null;
15011
+ }
14395
15012
  /** Reads an external file only if a running server named it. */
14396
15013
  async readExternal(path) {
14397
15014
  for (const { bridge } of this.entries.values()) {
@@ -14400,47 +15017,44 @@ var LspPool = class {
14400
15017
  }
14401
15018
  return null;
14402
15019
  }
14403
- async definition(pos) {
14404
- return this.bridgeFor(pos.path).definition(pos);
14405
- }
14406
- async typeDefinition(pos) {
14407
- return this.bridgeFor(pos.path).typeDefinition(pos);
15020
+ definition(pos) {
15021
+ return this.locations(pos, (b) => b.definition(pos));
14408
15022
  }
14409
- async hover(pos) {
14410
- return this.bridgeFor(pos.path).hover(pos);
15023
+ typeDefinition(pos) {
15024
+ return this.locations(pos, (b) => b.typeDefinition(pos));
14411
15025
  }
14412
- async tokenKind(pos) {
14413
- return this.bridgeFor(pos.path).tokenKind(pos);
15026
+ references(pos) {
15027
+ return this.locations(pos, (b) => b.references(pos));
14414
15028
  }
14415
- async references(pos) {
14416
- return this.bridgeFor(pos.path).references(pos);
15029
+ hover(pos) {
15030
+ return this.fanOut(pos.path, (b) => b.hover(pos), mergeHovers, (r) => r.contents == null);
14417
15031
  }
14418
15032
  async occurrences(pos) {
14419
- return this.bridgeFor(pos.path).occurrences(pos);
15033
+ return this.ownerFor(pos.path).occurrences(pos);
14420
15034
  }
14421
15035
  async documentSymbols(path) {
14422
- return this.bridgeFor(path).documentSymbols(path);
15036
+ return this.ownerFor(path).documentSymbols(path);
14423
15037
  }
14424
15038
  /**
14425
- * Symbols matching `query` from every running server, best `fuzzyRank` match first. One server
14426
- * failing drops its share; only an outage everywhere is an error.
15039
+ * Symbols matching `query` from every running server, best `fuzzyRank` match first.
15040
+ * Ranked before the cap: servers list loose matches in their own order.
14427
15041
  */
14428
15042
  async workspaceSymbols(query, limit = 200) {
15043
+ if (this.closing) throw new LspUnavailableError("the language servers are shutting down");
14429
15044
  const bridges = [...this.entries.values()].map((e) => e.bridge);
14430
- if (!bridges.length) throw new LspUnavailableError(this.why());
14431
- const results = await Promise.allSettled(bridges.map((b) => b.workspaceSymbols(query, limit)));
14432
- const out = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
14433
- const failed = results.find((r) => r.status === "rejected");
14434
- if (!out.length && failed) throw failed.reason;
14435
- return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
15045
+ if (!bridges.length) throw new LspUnavailableError("no language server is running");
15046
+ return union(bridges.map((b) => b.workspaceSymbols(query)), (answers) => ({ symbols: fuzzyRank(answers.flatMap((a) => a.symbols), query, (s) => s.name).slice(0, limit) }), (r) => !r.symbols.length);
14436
15047
  }
14437
15048
  /**
14438
- * Keeps the diff's files open in the server for their language, starting one for each
15049
+ * Keeps the diff's files open in every server for their language, starting one for each
14439
15050
  * language the diff has newly grown. A server whose language left the diff stays up
14440
- * with nothing tracked, so its index is warm if the reader switches back.
15051
+ * with nothing tracked, so its index is warm if the reader switches back. Nothing is
15052
+ * tracked when the new side is not the checkout: a server would see text that
15053
+ * contradicts the disk it indexes, and `lspBlocker` refuses queries there anyway.
14441
15054
  */
14442
- track(paths) {
15055
+ track(snap) {
14443
15056
  if (this.closing) return Promise.resolve();
15057
+ const paths = followsCheckout(snap) ? snap.changed.filter((f) => f.status !== "D" && !f.binary).map((f) => f.path) : [];
14444
15058
  const byLanguage = /* @__PURE__ */ new Map();
14445
15059
  for (const path of paths) {
14446
15060
  const language = languageOf(path);
@@ -14451,23 +15065,38 @@ var LspPool = class {
14451
15065
  }
14452
15066
  this.ensure(byLanguage.keys());
14453
15067
  const entries = [...this.entries.values()];
14454
- return Promise.all(entries.map((e) => e.bridge.track(e.languages.flatMap((l) => byLanguage.get(l) ?? [])))).then(() => {});
15068
+ return Promise.all(entries.map((e) => e.bridge.track([...e.languages, ...e.joins].flatMap((l) => byLanguage.get(l) ?? [])))).then(() => {});
14455
15069
  }
14456
15070
  /** Idempotent: the CLI's dispose can run beside a signal handler's. */
14457
15071
  close() {
14458
15072
  this.closing = true;
15073
+ if (this.publishTimer) clearTimeout(this.publishTimer);
15074
+ this.publishTimer = null;
14459
15075
  return this.closed ??= Promise.all([...this.entries.values()].map((e) => e.bridge.close())).then(() => {});
14460
15076
  }
14461
- /** The server for `path`, or an unavailable error naming what is missing. */
14462
- bridgeFor(path) {
15077
+ /**
15078
+ * Every server for `path`, owner first. A language nothing owns is still served by the
15079
+ * servers joining it. The route has already asked `lspBlocker` why none would answer,
15080
+ * so an empty list here is a race with a snapshot or a shutdown, not a message to word.
15081
+ */
15082
+ bridgesFor(path) {
14463
15083
  if (this.closing) throw new LspUnavailableError("the language servers are shutting down");
14464
15084
  const language = languageOf(path);
14465
- const bridge = language ? this.byLanguage.get(language) : void 0;
14466
- if (!bridge) throw new LspUnavailableError(this.why(path));
14467
- return bridge;
15085
+ const entries = [...this.entries.values()];
15086
+ const bridges = language ? [...entries.filter((e) => e.languages.includes(language)), ...entries.filter((e) => e.joins.includes(language))].map((e) => e.bridge) : [];
15087
+ if (!bridges.length) throw new LspUnavailableError(`no language server for ${path}`);
15088
+ return bridges;
15089
+ }
15090
+ ownerFor(path) {
15091
+ return this.bridgesFor(path)[0];
15092
+ }
15093
+ /** Every server's locations for `pos`, owner first, each place once. */
15094
+ locations(pos, ask) {
15095
+ return this.fanOut(pos.path, ask, mergeLocations, (r) => !r.locations.length);
14468
15096
  }
14469
- why(path) {
14470
- return lspBlocker(this.status(), path) ?? "no language server available";
15097
+ /** Asks every server for `path` at once and merges what they answered (see `union`). */
15098
+ async fanOut(path, ask, merge, empty) {
15099
+ return union(this.bridgesFor(path).map(ask), merge, empty);
14471
15100
  }
14472
15101
  /** Resolves and starts servers for languages not seen before. Cheap and idempotent per language. */
14473
15102
  ensure(languages) {
@@ -14478,19 +15107,24 @@ var LspPool = class {
14478
15107
  const { servers, missing } = resolveServers(fresh, this.opts.overrides, this.opts.lookup);
14479
15108
  this.missing.push(...missing);
14480
15109
  for (const server of servers) {
14481
- const entry = this.entries.get(server.command) ?? this.spawn(server.command, server.languages);
14482
- for (const l of server.languages) {
14483
- if (!entry.languages.includes(l)) entry.languages.push(l);
14484
- this.byLanguage.set(l, entry.bridge);
14485
- }
15110
+ const entry = this.entries.get(server.command) ?? this.spawn(server.command);
15111
+ for (const l of server.languages) if (!entry.languages.includes(l)) entry.languages.push(l);
15112
+ for (const l of server.joins) if (!entry.joins.includes(l)) entry.joins.push(l);
14486
15113
  }
14487
15114
  if (servers.length || missing.length) this.publish();
14488
15115
  }
14489
- spawn(command, languages) {
15116
+ spawn(command) {
15117
+ const definition = definitionOf(command);
15118
+ const ctx = {
15119
+ root: this.opts.root,
15120
+ lookup: this.opts.lookup ?? findOnPath
15121
+ };
14490
15122
  const entry = {
14491
- bridge: LspBridge.start({
15123
+ command,
15124
+ bridge: new LspBridge({
14492
15125
  command,
14493
- settings: settingsFor(languages, command),
15126
+ settings: definition.settings ? Promise.resolve(definition.settings(ctx)) : void 0,
15127
+ initializationOptions: definition.initializationOptions?.(ctx),
14494
15128
  root: this.opts.root,
14495
15129
  read: this.opts.read,
14496
15130
  has: this.opts.has,
@@ -14498,17 +15132,30 @@ var LspPool = class {
14498
15132
  spawnProcess: this.opts.spawnProcess,
14499
15133
  maxOpen: this.opts.maxOpen
14500
15134
  }),
14501
- languages: []
15135
+ languages: [],
15136
+ joins: []
14502
15137
  };
14503
15138
  this.entries.set(command, entry);
14504
15139
  return entry;
14505
15140
  }
14506
15141
  publish() {
14507
- if (!this.closing) this.opts.onStatus(this.status());
15142
+ if (this.closing) return;
15143
+ if (this.publishTimer) {
15144
+ this.publishDue = true;
15145
+ return;
15146
+ }
15147
+ this.opts.onStatus(this.status());
15148
+ this.publishTimer = setTimeout(() => {
15149
+ this.publishTimer = null;
15150
+ if (!this.publishDue || this.closing) return;
15151
+ this.publishDue = false;
15152
+ this.publish();
15153
+ }, PUBLISH_MS);
15154
+ this.publishTimer.unref();
14508
15155
  }
14509
15156
  };
14510
15157
  //#endregion
14511
- //#region src/server/revspec.ts
15158
+ //#region src/server/mode/revspec.ts
14512
15159
  /** The uncommitted tree, accepted on either side of a comparison. */
14513
15160
  const WORKTREE = "worktree";
14514
15161
  /**
@@ -17922,7 +18569,7 @@ function imageType(buf) {
17922
18569
  return null;
17923
18570
  }
17924
18571
  //#endregion
17925
- //#region src/server/routes.ts
18572
+ //#region src/server/routes/body.ts
17926
18573
  /** Invalid JSON is a request error; failures after parsing still propagate. */
17927
18574
  async function readJson(c) {
17928
18575
  try {
@@ -17931,110 +18578,10 @@ async function readJson(c) {
17931
18578
  throw new HTTPException(400, { message: "invalid JSON body" });
17932
18579
  }
17933
18580
  }
17934
- function createApi(deps) {
17935
- const { session, hub } = deps;
17936
- const app = new Hono();
17937
- app.onError((err, c) => {
17938
- if (err instanceof HTTPException) return c.json({ error: err.message }, err.status);
17939
- if (err instanceof ZodError) return c.json({ error: prettifyError(err) }, 400);
17940
- if (err instanceof NotFoundError) return c.json({ error: "not found" }, 404);
17941
- if (err instanceof RevspecError || err instanceof GitError) return c.json({ error: err.message }, 400);
17942
- if (err instanceof ImportError || err instanceof UnquotableError) return c.json({ error: err.message }, 400);
17943
- if (err instanceof LspUnavailableError) return c.json({ error: err.message }, 409);
17944
- if (err instanceof GithubError) return c.json({ error: err.message }, err.status);
17945
- console.error(err);
17946
- return c.json({ error: err.message }, 500);
17947
- });
17948
- app.use("/api/*", async (c, next) => {
17949
- if (c.req.path !== "/api/config") await session.ready();
17950
- await next();
17951
- });
17952
- app.get("/api/snapshot", async (c) => c.json(await session.snapshotter.current()));
17953
- app.get("/api/moved", (c) => c.json(session.moved));
17954
- app.post("/api/reload", async (c) => c.json(await session.reload()));
17955
- app.delete("/api/iterations", async (c) => c.json(await session.clearIterations()));
17956
- app.delete("/api/iterations/:n", async (c) => {
17957
- const n = Number(c.req.param("n"));
17958
- if (!Number.isInteger(n) || n < 1) throw new RevspecError(`not an iteration number: ${c.req.param("n")}`);
17959
- return c.json(await session.deleteIteration(n));
17960
- });
17961
- app.post("/api/mode", async (c) => {
17962
- const req = ModeRequestSchema.parse(await readJson(c));
17963
- return c.json(await session.switchMode(req));
17964
- });
17965
- app.get("/api/github", async (c) => {
17966
- const snap = await session.snapshotter.current();
17967
- return c.json(await session.github(snap));
17968
- });
17969
- app.get("/api/refs", async (c) => c.json(await session.repo.refs()));
17970
- app.get("/api/last-commits-preview", async (c) => {
17971
- const { oldOffset, newOffset } = LastCommitsQuerySchema.parse(c.req.query());
17972
- return c.json(await session.repo.lastCommitsPreview(oldOffset, newOffset));
17973
- });
17974
- app.get("/api/patch", async (c) => {
17975
- const { path } = PatchQuerySchema.parse(c.req.query());
17976
- if (!path) return c.text(await session.snapshotter.patchAll());
17977
- const patch = await session.snapshotter.patch(path);
17978
- if (patch == null) return c.json({ error: "not a changed file" }, 404);
17979
- return c.text(patch);
17980
- });
17981
- app.post("/api/patch", async (c) => {
17982
- const { paths } = PatchRequestSchema.parse(await readJson(c));
17983
- return c.text(await session.snapshotter.patchMany(paths));
17984
- });
17985
- app.get("/api/file", async (c) => {
17986
- const { path, rev } = FileQuerySchema.parse(c.req.query());
17987
- const snap = await session.snapshotter.current();
17988
- const buf = await session.readSide(snap, path, rev) ?? (rev === "new" ? await deps.lsp?.readExternal(path) : null);
17989
- if (buf == null) return c.json({ error: "absent on that side" }, 404);
17990
- const binary = isBinary(buf);
17991
- const body = {
17992
- path,
17993
- contents: binary ? "" : buf.toString("utf8"),
17994
- binary
17995
- };
17996
- return c.json(body);
17997
- });
17998
- app.get("/api/image", async (c) => {
17999
- const { path, rev, key } = ImageQuerySchema.parse(c.req.query());
18000
- const buf = await session.readImage(await session.snapshotter.current(), path, rev, key);
18001
- const type = buf && imageType(buf);
18002
- if (!type) return c.json({ error: "no image under that key" }, 404);
18003
- return c.body(new Uint8Array(buf), 200, {
18004
- "content-type": type,
18005
- "cache-control": "private, max-age=31536000, immutable",
18006
- "x-content-type-options": "nosniff",
18007
- "cross-origin-resource-policy": "same-origin"
18008
- });
18009
- });
18010
- app.get("/api/search", async (c) => {
18011
- const query = SearchQuerySchema.parse(c.req.query());
18012
- const { q, scope } = query;
18013
- const snap = await session.snapshotter.current();
18014
- let paths = scope === "repo" ? void 0 : scope === "file" ? snap.tree.filter((p) => p === query.path) : snap.changed.filter((f) => f.status !== "D").map((f) => f.path);
18015
- let ranges;
18016
- if (query.content === "diff") {
18017
- const entries = await mapLimit(snap.changed.filter((f) => f.status !== "D" && !f.binary && (!paths || paths.includes(f.path))), 8, async (file) => {
18018
- const patch = await session.repo.patch(snap.oldSha, snap.newSha, file, snap.context);
18019
- return [file.path, shownRanges(patch, "new")];
18020
- });
18021
- ranges = new Map(entries);
18022
- paths = entries.filter(([, spans]) => spans.length > 0).map(([path]) => path);
18023
- }
18024
- const { matches, truncated } = await session.repo.grep(q, snap.newSha, 500, {
18025
- word: query.word,
18026
- ignoreCase: query.i,
18027
- regex: query.re,
18028
- paths,
18029
- ranges
18030
- });
18031
- const body = {
18032
- query: q,
18033
- matches,
18034
- truncated
18035
- };
18036
- return c.json(body);
18037
- });
18581
+ //#endregion
18582
+ //#region src/server/routes/comments.ts
18583
+ /** Comment threads, their export to a pending GitHub review, and viewed marks. Each write broadcasts its invalidation. */
18584
+ function commentRoutes(app, { session, hub }) {
18038
18585
  app.get("/api/threads", (c) => {
18039
18586
  const q = ThreadQuerySchema.parse(c.req.query());
18040
18587
  q.state ??= "all";
@@ -18114,57 +18661,202 @@ function createApi(deps) {
18114
18661
  hub.broadcast({ type: "viewed" });
18115
18662
  return c.json(list);
18116
18663
  });
18664
+ }
18665
+ //#endregion
18666
+ //#region src/server/routes/lsp.ts
18667
+ /** Symbol navigation, answered only while the new side is the checkout the language servers read. */
18668
+ function lspRoutes(app, deps) {
18669
+ const { session } = deps;
18117
18670
  app.get("/api/lsp/status", (c) => c.json(lspStatus(deps)));
18118
- /** The pool, or a 409 reason: the language server reads the checkout, so the new side must be it. */
18119
- const lspFor = async (path) => {
18120
- if (!deps.lsp) return { error: lspBlocker(lspStatus(deps)) ?? "language servers are off" };
18671
+ app.get("/api/lsp/log", (c) => {
18672
+ const { server } = LspLogQuerySchema.parse(c.req.query());
18673
+ const entries = deps.lsp?.log(server);
18674
+ if (entries == null) return c.json({ error: "no such language server" }, 404);
18675
+ return c.json({ entries });
18676
+ });
18677
+ /**
18678
+ * The pool, or the 409 reason `lspBlocker` gives: off, the wrong side, no server, or
18679
+ * not that feature. An advisory query names no feature: a server not offering it
18680
+ * answers nothing, which is no reason to refuse.
18681
+ */
18682
+ const lspFor = async ({ path, feature }) => {
18121
18683
  const snap = await session.snapshotter.current();
18122
- if (!followsCheckout(snap)) return { error: "Symbol navigation needs the new side to be the worktree or the checked-out commit" };
18684
+ const error = lspBlocker(lspStatus(deps), snap, {
18685
+ path,
18686
+ feature,
18687
+ patient: true
18688
+ });
18689
+ if (error != null || !deps.lsp) return { error: error ?? "language servers are off" };
18123
18690
  if (path != null && !snap.tree.includes(path)) return { error: `${path} is not in the snapshot` };
18124
18691
  return { lsp: deps.lsp };
18125
18692
  };
18126
18693
  app.post("/api/lsp/definition", async (c) => {
18127
18694
  const pos = LspPositionSchema.parse(await readJson(c));
18128
- const r = await lspFor(pos.path);
18695
+ const r = await lspFor({
18696
+ path: pos.path,
18697
+ feature: "definition"
18698
+ });
18129
18699
  if ("error" in r) return c.json({ error: r.error }, 409);
18130
18700
  return c.json(await r.lsp.definition(pos));
18131
18701
  });
18132
18702
  app.post("/api/lsp/type-definition", async (c) => {
18133
18703
  const pos = LspPositionSchema.parse(await readJson(c));
18134
- const r = await lspFor(pos.path);
18704
+ const r = await lspFor({
18705
+ path: pos.path,
18706
+ feature: "typeDefinition"
18707
+ });
18135
18708
  if ("error" in r) return c.json({ error: r.error }, 409);
18136
18709
  return c.json(await r.lsp.typeDefinition(pos));
18137
18710
  });
18138
18711
  app.post("/api/lsp/hover", async (c) => {
18139
18712
  const pos = LspPositionSchema.parse(await readJson(c));
18140
- const r = await lspFor(pos.path);
18713
+ const r = await lspFor({
18714
+ path: pos.path,
18715
+ feature: "hover"
18716
+ });
18141
18717
  if ("error" in r) return c.json({ error: r.error }, 409);
18142
18718
  return c.json(await r.lsp.hover(pos));
18143
18719
  });
18144
- app.post("/api/lsp/token-kind", async (c) => {
18145
- const pos = LspPositionSchema.parse(await readJson(c));
18146
- const r = await lspFor(pos.path);
18147
- if ("error" in r) return c.json({ error: r.error }, 409);
18148
- return c.json(await r.lsp.tokenKind(pos));
18149
- });
18150
18720
  app.post("/api/lsp/references", async (c) => {
18151
18721
  const pos = LspPositionSchema.parse(await readJson(c));
18152
- const r = await lspFor(pos.path);
18722
+ const r = await lspFor({
18723
+ path: pos.path,
18724
+ feature: "references"
18725
+ });
18153
18726
  if ("error" in r) return c.json({ error: r.error }, 409);
18154
18727
  return c.json(await r.lsp.references(pos));
18155
18728
  });
18156
18729
  app.post("/api/lsp/occurrences", async (c) => {
18157
18730
  const pos = LspPositionSchema.parse(await readJson(c));
18158
- const r = await lspFor(pos.path);
18731
+ const r = await lspFor({ path: pos.path });
18159
18732
  if ("error" in r) return c.json({ error: r.error }, 409);
18160
18733
  return c.json(await r.lsp.occurrences(pos));
18161
18734
  });
18162
18735
  app.get("/api/lsp/symbols", async (c) => {
18163
18736
  const { path, q: query } = SymbolsQuerySchema.parse(c.req.query());
18164
- const r = await lspFor(path);
18737
+ const r = await lspFor({
18738
+ path,
18739
+ feature: path != null ? "documentSymbol" : "workspaceSymbol"
18740
+ });
18165
18741
  if ("error" in r) return c.json({ error: r.error }, 409);
18166
18742
  return c.json(path != null ? await r.lsp.documentSymbols(path) : await r.lsp.workspaceSymbols(query ?? ""));
18167
18743
  });
18744
+ }
18745
+ function lspStatus(deps) {
18746
+ return deps.lsp?.status() ?? {
18747
+ enabled: false,
18748
+ servers: [],
18749
+ missing: []
18750
+ };
18751
+ }
18752
+ //#endregion
18753
+ //#region src/server/routes/api.ts
18754
+ function createApi(deps) {
18755
+ const { session, hub } = deps;
18756
+ const app = new Hono();
18757
+ app.onError((err, c) => {
18758
+ if (err instanceof HTTPException) return c.json({ error: err.message }, err.status);
18759
+ if (err instanceof ZodError) return c.json({ error: prettifyError(err) }, 400);
18760
+ if (err instanceof NotFoundError) return c.json({ error: "not found" }, 404);
18761
+ if (err instanceof RevspecError || err instanceof GitError) return c.json({ error: err.message }, 400);
18762
+ if (err instanceof ImportError || err instanceof UnquotableError) return c.json({ error: err.message }, 400);
18763
+ if (err instanceof LspUnavailableError) return c.json({ error: err.message }, 409);
18764
+ if (err instanceof GithubError) return c.json({ error: err.message }, err.status);
18765
+ console.error(err);
18766
+ return c.json({ error: err.message }, 500);
18767
+ });
18768
+ app.use("/api/*", async (c, next) => {
18769
+ if (c.req.path !== "/api/config") await session.ready();
18770
+ await next();
18771
+ });
18772
+ app.get("/api/snapshot", async (c) => c.json(await session.snapshotter.current()));
18773
+ app.get("/api/moved", (c) => c.json(session.moved));
18774
+ app.post("/api/reload", async (c) => c.json(await session.reload()));
18775
+ app.delete("/api/iterations", async (c) => c.json(await session.clearIterations()));
18776
+ app.delete("/api/iterations/:n", async (c) => {
18777
+ const n = Number(c.req.param("n"));
18778
+ if (!Number.isInteger(n) || n < 1) throw new RevspecError(`not an iteration number: ${c.req.param("n")}`);
18779
+ return c.json(await session.deleteIteration(n));
18780
+ });
18781
+ app.post("/api/mode", async (c) => {
18782
+ const req = ModeRequestSchema.parse(await readJson(c));
18783
+ return c.json(await session.switchMode(req));
18784
+ });
18785
+ app.get("/api/github", async (c) => {
18786
+ const snap = await session.snapshotter.current();
18787
+ return c.json(await session.github(snap));
18788
+ });
18789
+ app.get("/api/refs", async (c) => c.json(await session.repo.refs()));
18790
+ app.get("/api/last-commits-preview", async (c) => {
18791
+ const { oldOffset, newOffset } = LastCommitsQuerySchema.parse(c.req.query());
18792
+ return c.json(await session.repo.lastCommitsPreview(oldOffset, newOffset));
18793
+ });
18794
+ app.get("/api/patch", async (c) => {
18795
+ const { path } = PatchQuerySchema.parse(c.req.query());
18796
+ if (!path) return c.text(await session.snapshotter.patchAll());
18797
+ const patch = await session.snapshotter.patch(path);
18798
+ if (patch == null) return c.json({ error: "not a changed file" }, 404);
18799
+ return c.text(patch);
18800
+ });
18801
+ app.post("/api/patch", async (c) => {
18802
+ const { paths } = PatchRequestSchema.parse(await readJson(c));
18803
+ return c.text(await session.snapshotter.patchMany(paths));
18804
+ });
18805
+ app.get("/api/file", async (c) => {
18806
+ const { path, rev } = FileQuerySchema.parse(c.req.query());
18807
+ const snap = await session.snapshotter.current();
18808
+ const buf = await session.readSide(snap, path, rev) ?? (rev === "new" ? await deps.lsp?.readExternal(path) : null);
18809
+ if (buf == null) return c.json({ error: "absent on that side" }, 404);
18810
+ const binary = isBinary(buf);
18811
+ const body = {
18812
+ path,
18813
+ contents: binary ? "" : buf.toString("utf8"),
18814
+ binary
18815
+ };
18816
+ return c.json(body);
18817
+ });
18818
+ app.get("/api/image", async (c) => {
18819
+ const { path, rev, key } = ImageQuerySchema.parse(c.req.query());
18820
+ const buf = await session.readImage(await session.snapshotter.current(), path, rev, key);
18821
+ const type = buf && imageType(buf);
18822
+ if (!type) return c.json({ error: "no image under that key" }, 404);
18823
+ return c.body(new Uint8Array(buf), 200, {
18824
+ "content-type": type,
18825
+ "cache-control": "private, max-age=31536000, immutable",
18826
+ "x-content-type-options": "nosniff",
18827
+ "cross-origin-resource-policy": "same-origin"
18828
+ });
18829
+ });
18830
+ app.get("/api/search", async (c) => {
18831
+ const query = SearchQuerySchema.parse(c.req.query());
18832
+ const { q, scope } = query;
18833
+ const snap = await session.snapshotter.current();
18834
+ let paths = scope === "repo" ? void 0 : scope === "file" ? snap.tree.filter((p) => p === query.path) : snap.changed.filter((f) => f.status !== "D").map((f) => f.path);
18835
+ let ranges;
18836
+ if (query.content === "diff") {
18837
+ const entries = await mapLimit(snap.changed.filter((f) => f.status !== "D" && !f.binary && (!paths || paths.includes(f.path))), 8, async (file) => {
18838
+ const patch = await session.repo.patch(snap.oldSha, snap.newSha, file, snap.context);
18839
+ return [file.path, shownRanges(patch, "new")];
18840
+ });
18841
+ ranges = new Map(entries);
18842
+ paths = entries.filter(([, spans]) => spans.length > 0).map(([path]) => path);
18843
+ }
18844
+ const { matches, truncated } = await session.repo.grep(q, snap.newSha, 500, {
18845
+ word: query.word,
18846
+ ignoreCase: query.i,
18847
+ regex: query.re,
18848
+ paths,
18849
+ ranges
18850
+ });
18851
+ const body = {
18852
+ query: q,
18853
+ matches,
18854
+ truncated
18855
+ };
18856
+ return c.json(body);
18857
+ });
18858
+ commentRoutes(app, deps);
18859
+ lspRoutes(app, deps);
18168
18860
  app.get("/api/config", (c) => c.json(effectiveConfig(deps)));
18169
18861
  app.put("/api/config", async (c) => {
18170
18862
  const body = ConfigUpdateSchema.parse(await readJson(c));
@@ -18182,14 +18874,7 @@ function effectiveConfig(deps) {
18182
18874
  return {
18183
18875
  ...c,
18184
18876
  autoViewed: [.../* @__PURE__ */ new Set([...c.autoViewed, ...deps.extraAutoViewed])],
18185
- contextLines: deps.session.context
18186
- };
18187
- }
18188
- function lspStatus(deps) {
18189
- return deps.lsp?.status() ?? {
18190
- enabled: false,
18191
- servers: [],
18192
- missing: []
18877
+ contextLines: deps.session.context
18193
18878
  };
18194
18879
  }
18195
18880
  //#endregion
@@ -18492,7 +19177,7 @@ function parsePullRequest(value) {
18492
19177
  };
18493
19178
  }
18494
19179
  //#endregion
18495
- //#region src/server/GithubMetadata.ts
19180
+ //#region src/server/github/metadata.ts
18496
19181
  const LOOKUP_TIMEOUT_MS = 5e3;
18497
19182
  /**
18498
19183
  * Enriches a comparison without fetching commits, changing its endpoints, or changing comment
@@ -18558,10 +19243,142 @@ async function discoverGithub(repo, snap, { github, prUrl } = {}) {
18558
19243
  }
18559
19244
  }
18560
19245
  //#endregion
18561
- //#region src/server/github/review.ts
19246
+ //#region src/server/github/pendingReview.ts
18562
19247
  function isFileComment(c) {
18563
19248
  return "subjectType" in c;
18564
19249
  }
19250
+ /** The anchor must still match before we rewrite an exported thread. */
19251
+ function anchorKey(c) {
19252
+ if (isFileComment(c)) return [c.path, "FILE"].join("\0");
19253
+ return [
19254
+ c.path,
19255
+ c.side,
19256
+ c.startLine ?? c.line,
19257
+ c.line
19258
+ ].join("\0");
19259
+ }
19260
+ const PENDING_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
19261
+ viewer{login}
19262
+ repository(owner:$owner,name:$repo){pullRequest(number:$number){id reviews(last:20,states:[PENDING]){nodes{id author{login}}}}}
19263
+ }`;
19264
+ /**
19265
+ * The PR's node id and the viewer's own pending review on it, or null. GitHub hides other
19266
+ * people's pending reviews, and the login check makes sure of it: appending to someone
19267
+ * else's draft would put our comments in their review.
19268
+ */
19269
+ async function findPendingReview(github, pr) {
19270
+ const { owner, repo } = repoOfPrUrl(pr.url);
19271
+ const data = await github.graphql(PENDING_QUERY, {
19272
+ owner,
19273
+ repo,
19274
+ number: pr.number
19275
+ });
19276
+ const pullRequestId = data.repository?.pullRequest?.id;
19277
+ if (typeof pullRequestId !== "string") throw new GithubError(`cannot read pull request ${pr.url} from GitHub`, 502);
19278
+ const login = data.viewer?.login;
19279
+ for (const node of data.repository?.pullRequest?.reviews?.nodes ?? []) if (node?.id && node.author?.login === login) return {
19280
+ pullRequestId,
19281
+ pending: { id: node.id }
19282
+ };
19283
+ return {
19284
+ pullRequestId,
19285
+ pending: null
19286
+ };
19287
+ }
19288
+ const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
19289
+ repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
19290
+ pageInfo{hasNextPage endCursor}
19291
+ nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
19292
+ }}}
19293
+ }`;
19294
+ /**
19295
+ * The pending review's own comments, by anchor. It has to be `reviewThreads`: it is the only view
19296
+ * that reports a *pending* comment's real position — REST's review-comment list leaves `line`,
19297
+ * `side` and `start_line` null for one, and the GraphQL comment type has no side field at all.
19298
+ * A failed or incomplete lookup aborts the export: absence is safe to infer only from a complete read.
19299
+ */
19300
+ async function pendingComments(github, pr, reviewId) {
19301
+ const by = /* @__PURE__ */ new Map();
19302
+ const { owner, repo } = repoOfPrUrl(pr.url);
19303
+ let after = null;
19304
+ for (let page = 0; page < 10; page++) {
19305
+ const threads = (await github.graphql(THREADS_QUERY, {
19306
+ owner,
19307
+ repo,
19308
+ number: pr.number,
19309
+ after
19310
+ })).repository?.pullRequest?.reviewThreads;
19311
+ if (!threads?.nodes || typeof threads.pageInfo?.hasNextPage !== "boolean") throw new GithubError("cannot read the complete pending review", 502);
19312
+ for (const t of threads.nodes) {
19313
+ const c = t?.comments?.nodes?.[0];
19314
+ if (!t?.path || !c?.id || c.pullRequestReview?.id !== reviewId) continue;
19315
+ const key = pendingKey(t);
19316
+ if (key == null) continue;
19317
+ const at = by.get(key);
19318
+ if (at) at.push({
19319
+ nodeId: c.id,
19320
+ body: c.body ?? ""
19321
+ });
19322
+ else by.set(key, [{
19323
+ nodeId: c.id,
19324
+ body: c.body ?? ""
19325
+ }]);
19326
+ }
19327
+ if (!threads.pageInfo.hasNextPage) return by;
19328
+ if (!threads.pageInfo.endCursor || threads.pageInfo.endCursor === after) throw new GithubError("cannot read the next page of the pending review", 502);
19329
+ after = threads.pageInfo.endCursor;
19330
+ }
19331
+ throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
19332
+ }
19333
+ /** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
19334
+ function pendingKey(t) {
19335
+ const path = t.path;
19336
+ if (t.subjectType === "FILE") return anchorKey({
19337
+ path,
19338
+ subjectType: "FILE",
19339
+ body: ""
19340
+ });
19341
+ if (t.line == null) return null;
19342
+ return anchorKey({
19343
+ path,
19344
+ side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
19345
+ line: t.line,
19346
+ startLine: t.startLine ?? void 0,
19347
+ body: ""
19348
+ });
19349
+ }
19350
+ const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
19351
+ /** Rewrites one comment where it already sits in the pending review. */
19352
+ async function updateComment(github, nodeId, body) {
19353
+ await github.graphql(UPDATE_COMMENT, { input: {
19354
+ pullRequestReviewCommentId: nodeId,
19355
+ body
19356
+ } });
19357
+ }
19358
+ const CREATE_REVIEW = `mutation($input:AddPullRequestReviewInput!){addPullRequestReview(input:$input){pullRequestReview{id}}}`;
19359
+ /**
19360
+ * No `event` in the input, so GitHub keeps the new review pending with all of its threads.
19361
+ * Resolves the review's node id, for the comments a new review cannot carry.
19362
+ */
19363
+ async function createPendingReview(github, pullRequestId, commitOID, threads) {
19364
+ const id = (await github.graphql(CREATE_REVIEW, { input: {
19365
+ pullRequestId,
19366
+ commitOID,
19367
+ threads
19368
+ } })).addPullRequestReview?.pullRequestReview?.id;
19369
+ if (typeof id !== "string") throw new GithubError("GitHub did not return the created review", 502);
19370
+ return { id };
19371
+ }
19372
+ const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
19373
+ /** Appends one thread to the pending review; the only way to extend one, or to place a file comment. */
19374
+ async function addToPendingReview(github, reviewId, c) {
19375
+ await github.graphql(ADD_THREAD, { input: {
19376
+ pullRequestReviewId: reviewId,
19377
+ ...c
19378
+ } });
19379
+ }
19380
+ //#endregion
19381
+ //#region src/server/github/review.ts
18565
19382
  /**
18566
19383
  * Threads → the comments of one pending GitHub review, one per thread. Stale threads are skipped:
18567
19384
  * their lines no longer sit in the diff, and GitHub would refuse them anyway. With
@@ -18697,10 +19514,7 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, githu
18697
19514
  });
18698
19515
  continue;
18699
19516
  }
18700
- await github.graphql(UPDATE_COMMENT, { input: {
18701
- pullRequestReviewCommentId: hit.nodeId,
18702
- body: c.body
18703
- } });
19517
+ await updateComment(github, hit.nodeId, c.body);
18704
19518
  updated++;
18705
19519
  }
18706
19520
  for (const c of add) {
@@ -18723,129 +19537,6 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, githu
18723
19537
  function threadMarker(id) {
18724
19538
  return `<!-- diffle-thread:${encodeURIComponent(id)} -->`;
18725
19539
  }
18726
- /** The anchor must still match before we rewrite an exported thread. */
18727
- function anchorKey(c) {
18728
- if (isFileComment(c)) return [c.path, "FILE"].join("\0");
18729
- return [
18730
- c.path,
18731
- c.side,
18732
- c.startLine ?? c.line,
18733
- c.line
18734
- ].join("\0");
18735
- }
18736
- const PENDING_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
18737
- viewer{login}
18738
- repository(owner:$owner,name:$repo){pullRequest(number:$number){id reviews(last:20,states:[PENDING]){nodes{id author{login}}}}}
18739
- }`;
18740
- /**
18741
- * The PR's node id and the viewer's own pending review on it, or null. GitHub hides other
18742
- * people's pending reviews, and the login check makes sure of it: appending to someone
18743
- * else's draft would put our comments in their review.
18744
- */
18745
- async function findPendingReview(github, pr) {
18746
- const { owner, repo } = repoOfPrUrl(pr.url);
18747
- const data = await github.graphql(PENDING_QUERY, {
18748
- owner,
18749
- repo,
18750
- number: pr.number
18751
- });
18752
- const pullRequestId = data.repository?.pullRequest?.id;
18753
- if (typeof pullRequestId !== "string") throw new GithubError(`cannot read pull request ${pr.url} from GitHub`, 502);
18754
- const login = data.viewer?.login;
18755
- for (const node of data.repository?.pullRequest?.reviews?.nodes ?? []) if (node?.id && node.author?.login === login) return {
18756
- pullRequestId,
18757
- pending: { id: node.id }
18758
- };
18759
- return {
18760
- pullRequestId,
18761
- pending: null
18762
- };
18763
- }
18764
- const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
18765
- repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
18766
- pageInfo{hasNextPage endCursor}
18767
- nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
18768
- }}}
18769
- }`;
18770
- /**
18771
- * The pending review's own comments, by anchor. It has to be `reviewThreads`: it is the only view
18772
- * that reports a *pending* comment's real position — REST's review-comment list leaves `line`,
18773
- * `side` and `start_line` null for one, and the GraphQL comment type has no side field at all.
18774
- * A failed or incomplete lookup aborts the export: absence is safe to infer only from a complete read.
18775
- */
18776
- async function pendingComments(github, pr, reviewId) {
18777
- const by = /* @__PURE__ */ new Map();
18778
- const { owner, repo } = repoOfPrUrl(pr.url);
18779
- let after = null;
18780
- for (let page = 0; page < 10; page++) {
18781
- const threads = (await github.graphql(THREADS_QUERY, {
18782
- owner,
18783
- repo,
18784
- number: pr.number,
18785
- after
18786
- })).repository?.pullRequest?.reviewThreads;
18787
- if (!threads?.nodes || typeof threads.pageInfo?.hasNextPage !== "boolean") throw new GithubError("cannot read the complete pending review", 502);
18788
- for (const t of threads.nodes) {
18789
- const c = t?.comments?.nodes?.[0];
18790
- if (!t?.path || !c?.id || c.pullRequestReview?.id !== reviewId) continue;
18791
- const key = pendingKey(t);
18792
- if (key == null) continue;
18793
- const at = by.get(key);
18794
- if (at) at.push({
18795
- nodeId: c.id,
18796
- body: c.body ?? ""
18797
- });
18798
- else by.set(key, [{
18799
- nodeId: c.id,
18800
- body: c.body ?? ""
18801
- }]);
18802
- }
18803
- if (!threads.pageInfo.hasNextPage) return by;
18804
- if (!threads.pageInfo.endCursor || threads.pageInfo.endCursor === after) throw new GithubError("cannot read the next page of the pending review", 502);
18805
- after = threads.pageInfo.endCursor;
18806
- }
18807
- throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
18808
- }
18809
- /** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
18810
- function pendingKey(t) {
18811
- const path = t.path;
18812
- if (t.subjectType === "FILE") return anchorKey({
18813
- path,
18814
- subjectType: "FILE",
18815
- body: ""
18816
- });
18817
- if (t.line == null) return null;
18818
- return anchorKey({
18819
- path,
18820
- side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
18821
- line: t.line,
18822
- startLine: t.startLine ?? void 0,
18823
- body: ""
18824
- });
18825
- }
18826
- const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
18827
- const CREATE_REVIEW = `mutation($input:AddPullRequestReviewInput!){addPullRequestReview(input:$input){pullRequestReview{id}}}`;
18828
- /**
18829
- * No `event` in the input, so GitHub keeps the new review pending with all of its threads.
18830
- * Resolves the review's node id, for the comments a new review cannot carry.
18831
- */
18832
- async function createPendingReview(github, pullRequestId, commitOID, threads) {
18833
- const id = (await github.graphql(CREATE_REVIEW, { input: {
18834
- pullRequestId,
18835
- commitOID,
18836
- threads
18837
- } })).addPullRequestReview?.pullRequestReview?.id;
18838
- if (typeof id !== "string") throw new GithubError("GitHub did not return the created review", 502);
18839
- return { id };
18840
- }
18841
- const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
18842
- /** Appends one thread to the pending review; the only way to extend one, or to place a file comment. */
18843
- async function addToPendingReview(github, reviewId, c) {
18844
- await github.graphql(ADD_THREAD, { input: {
18845
- pullRequestReviewId: reviewId,
18846
- ...c
18847
- } });
18848
- }
18849
19540
  function describe(skipped) {
18850
19541
  const counts = /* @__PURE__ */ new Map();
18851
19542
  for (const s of skipped) counts.set(s.reason, (counts.get(s.reason) ?? 0) + 1);
@@ -18948,7 +19639,7 @@ async function read(file) {
18948
19639
  }
18949
19640
  }
18950
19641
  //#endregion
18951
- //#region src/server/rangediff.ts
19642
+ //#region src/server/mode/rangediff.ts
18952
19643
  /**
18953
19644
  * The mode that compares `older` with `newer`: `older`'s head replayed onto `newer`'s base against
18954
19645
  * `newer`'s head, so only what the series itself changed in between shows, with the two ranges'
@@ -19009,7 +19700,7 @@ async function pairRanges(repo, older, newer) {
19009
19700
  }));
19010
19701
  }
19011
19702
  //#endregion
19012
- //#region src/server/mode.ts
19703
+ //#region src/server/mode/resolve.ts
19013
19704
  /**
19014
19705
  * Resolves a comparison to Git endpoints for initial mode setup, PR setup after
19015
19706
  * fetching, and snapshot refreshes. Revisions become full hashes; "worktree"
@@ -20108,6 +20799,7 @@ function normalize(c) {
20108
20799
  autoViewed: Array.isArray(c.autoViewed) ? c.autoViewed.filter((p) => typeof p === "string").map((p) => p.trim()).filter(Boolean) : DEFAULT_USER_CONFIG.autoViewed,
20109
20800
  contextLines: typeof c.contextLines === "number" && Number.isFinite(c.contextLines) ? Math.max(0, Math.min(1e4, Math.floor(c.contextLines))) : DEFAULT_USER_CONFIG.contextLines,
20110
20801
  followRefs: FollowRefsSchema.safeParse(c.followRefs).data ?? DEFAULT_USER_CONFIG.followRefs,
20802
+ lineWrap: typeof c.lineWrap === "boolean" ? c.lineWrap : DEFAULT_USER_CONFIG.lineWrap,
20111
20803
  lspCommands: lspCommands(c)
20112
20804
  };
20113
20805
  }
@@ -24059,7 +24751,8 @@ program.command("lsp").description("show the language server each language would
24059
24751
  const { servers, missing } = resolveServers(LANGUAGE_IDS, lspCommands);
24060
24752
  const answer = /* @__PURE__ */ new Map();
24061
24753
  for (const s of servers) for (const language of s.languages) answer.set(language, `${s.command}${lspCommands[language] == null ? "" : c.dim(" (config)")}`);
24062
- for (const m of missing) answer.set(m.language, c.dim(m.tried.length ? `not on PATH (tried ${m.tried.join(", ")})` : "off in config"));
24754
+ for (const m of missing) answer.set(m.language, c.dim(m.why ?? (m.tried.length ? `not on PATH (tried ${m.tried.join(", ")})` : "off in config")));
24755
+ for (const s of servers) for (const language of s.joins) answer.set(language, `${answer.get(language) ?? ""} ${c.dim("+")} ${s.command}`);
24063
24756
  const width = Math.max(...LANGUAGE_IDS.map((l) => l.length));
24064
24757
  for (const language of LANGUAGE_IDS) console.log(`${language.padEnd(width)} ${answer.get(language) ?? ""}`);
24065
24758
  });
@@ -24176,7 +24869,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
24176
24869
  if (snap.mode.live !== "none") console.error(`👀 ${c.dim(snap.mode.live === "worktree" ? "watching the worktree" : session.follows ? "watching refs" : "watching refs; moved refs show a Reload button")}${opts.watch ? "" : c.dim(" (disabled with --no-watch)")}`);
24177
24870
  if (lsp) {
24178
24871
  const { servers, missing } = lsp.status();
24179
- const names = servers.map((s) => `${s.name} ${c.dim(`(${s.languages.join(", ")})`)}`).join(", ");
24872
+ const names = servers.map((s) => `${s.name} ${c.dim(`(${languagesLabel(s)})`)}`).join(", ");
24180
24873
  if (!followsCheckout(snap)) console.error(`🧭 ${c.dim("no language server: symbol navigation needs the new side to be the checkout")}`);
24181
24874
  else console.error(`🧭 ${c.dim("lsp")} ${names || c.dim("no language server for this diff")}`);
24182
24875
  for (const m of missing.filter((m) => m.tried.length)) console.error(`${c.yellow("!")} no ${m.language} language server ${c.dim(`(tried ${m.tried.join(", ")})`)}`);
@@ -24202,17 +24895,11 @@ async function connectGithub() {
24202
24895
  }
24203
24896
  /**
24204
24897
  * The pool reads new-side text through the session, so the snapshot allowlist applies to
24205
- * every LSP path. Each snapshot decides which languages run: it re-opens the diff's files
24206
- * in the server for their language, so references in them are found from the reviewed text
24207
- * (see LspPool.track). Nothing starts when the new side is not the checkout: the client
24208
- * refuses symbol navigation there, and a server would otherwise see text that contradicts
24209
- * the disk it indexes.
24898
+ * every LSP path. Each snapshot decides which languages run and which files the servers
24899
+ * hold open (see LspPool.track).
24210
24900
  */
24211
24901
  function startLsp(repo, session, hub, overrides, preload) {
24212
- session.onSnapshot((snap) => {
24213
- const paths = followsCheckout(snap) ? snap.changed.filter((f) => f.status !== "D" && !f.binary).map((f) => f.path) : [];
24214
- lsp.track(paths);
24215
- });
24902
+ session.onSnapshot((snap) => void lsp.track(snap));
24216
24903
  const reported = /* @__PURE__ */ new Map();
24217
24904
  const lsp = new LspPool({
24218
24905
  root: repo.root,