@moritzwilksch/diffle 0.2.6 → 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.
- package/README.md +2 -1
- package/dist/client/assets/{angular-html-CzMCD3ug.js → angular-html-DosOYpNl.js} +1 -1
- package/dist/client/assets/{angular-ts-B4fGDbxk.js → angular-ts-DkqBGDar.js} +1 -1
- package/dist/client/assets/{apl-DstBvCsT.js → apl-DhBUfBJf.js} +1 -1
- package/dist/client/assets/{astro-Ch1WfPah.js → astro-C9ZTzAue.js} +1 -1
- package/dist/client/assets/{blade-D34zPWLP.js → blade-B1PWKc0N.js} +1 -1
- package/dist/client/assets/{c-Bcc_roq3.js → c-DBA56zb-.js} +1 -1
- package/dist/client/assets/{chapel-BByeN8QU.js → chapel-ChRt-4dY.js} +1 -1
- package/dist/client/assets/{cobol-Ca20rU-b.js → cobol-B4Ub-iW1.js} +1 -1
- package/dist/client/assets/{coffee-CXtg5K0F.js → coffee-x1BuBd_Q.js} +1 -1
- package/dist/client/assets/{cpp-CIgWYSWA.js → cpp-w_0aDvpB.js} +1 -1
- package/dist/client/assets/{crystal-Cvucn1bp.js → crystal-ClQ0qP1s.js} +1 -1
- package/dist/client/assets/{css-C-FrtmJ2.js → css-DC2faGEc.js} +1 -1
- package/dist/client/assets/{edge-N4n59nRs.js → edge-DmJBQ2gv.js} +1 -1
- package/dist/client/assets/{elixir-fLydv49O.js → elixir-DCLVXjiT.js} +1 -1
- package/dist/client/assets/{elm-DEt1_raw.js → elm-DlbDR8QF.js} +1 -1
- package/dist/client/assets/{erb-CXoBWDEj.js → erb-DMRo3oW4.js} +1 -1
- package/dist/client/assets/{git-rebase-UnEGihJk.js → git-rebase-DWIcl-q4.js} +1 -1
- package/dist/client/assets/{glimmer-js-CVeQnRUI.js → glimmer-js-DUaEeoFR.js} +1 -1
- package/dist/client/assets/{glimmer-ts-Be2k-DeC.js → glimmer-ts-6tsiEBq-.js} +1 -1
- package/dist/client/assets/{glsl-DKUxGHtC.js → glsl-DdOwn0FP.js} +1 -1
- package/dist/client/assets/{graphql-Cb3D6qRD.js → graphql-IJK_VTno.js} +1 -1
- package/dist/client/assets/{hack-BHtVN7HG.js → hack-DifbMrOT.js} +1 -1
- package/dist/client/assets/{haml-Hfo6O9dJ.js → haml-DeQ6UFH5.js} +1 -1
- package/dist/client/assets/{handlebars-czSGLDAW.js → handlebars-BKpjBAcW.js} +1 -1
- package/dist/client/assets/{html-DMfBwZ5l.js → html-CTtX_fVs.js} +1 -1
- package/dist/client/assets/{html-derivative-SGFVbZeF.js → html-derivative-C6nfxO2F.js} +1 -1
- package/dist/client/assets/{http-KLNzQU87.js → http-eQW7tfW5.js} +1 -1
- package/dist/client/assets/{hurl-rTG5Buo3.js → hurl-DVJuWXn_.js} +1 -1
- package/dist/client/assets/index-CqE8hk_s.js +1833 -0
- package/dist/client/assets/index-DZQ9igGc.css +2 -0
- package/dist/client/assets/{java-CGOl4FSF.js → java-ClwPIFd_.js} +1 -1
- package/dist/client/assets/{javascript-IxUZMOKr.js → javascript-D-3TtS2j.js} +1 -1
- package/dist/client/assets/{jinja-DoLfUB5A.js → jinja-B-6OnCvz.js} +1 -1
- package/dist/client/assets/{jison-CQkv2cgt.js → jison-BmwL1UCC.js} +1 -1
- package/dist/client/assets/{json-BC6uvolx.js → json-D8CIZ6_Z.js} +1 -1
- package/dist/client/assets/{jsx-BS2KAiUd.js → jsx-hHZI2uuq.js} +1 -1
- package/dist/client/assets/{julia-xFhbhDrw.js → julia-CePjwP87.js} +1 -1
- package/dist/client/assets/{just-hNb_ruRh.js → just-BPRk2cx_.js} +1 -1
- package/dist/client/assets/{latex-BbAEB88r.js → latex-lrY1zFXa.js} +1 -1
- package/dist/client/assets/{liquid-DE-NBvlU.js → liquid-5ePjgMO9.js} +1 -1
- package/dist/client/assets/{lua-Bqx7pIbU.js → lua-1rnc87r4.js} +1 -1
- package/dist/client/assets/{marko-BOK8OLAP.js → marko-IKwBj9dr.js} +1 -1
- package/dist/client/assets/{mdc-DEM_bq87.js → mdc-o7rCfvqw.js} +1 -1
- package/dist/client/assets/{nginx-Bk0QSOHA.js → nginx-BsG6mAEE.js} +1 -1
- package/dist/client/assets/{nim-DUumV1cp.js → nim-v9Re54Nf.js} +1 -1
- package/dist/client/assets/{org-BhTourfS.js → org-QPi2LyaR.js} +1 -1
- package/dist/client/assets/{perl-sJcNQ5Aw.js → perl-Bp-K4EZR.js} +1 -1
- package/dist/client/assets/{php-B1RGxWTp.js → php-9fs1O83G.js} +1 -1
- package/dist/client/assets/{pug-Cz6pb7Lc.js → pug-BObrWPFa.js} +1 -1
- package/dist/client/assets/{qml-DRbzpWGX.js → qml-CtVRO65R.js} +1 -1
- package/dist/client/assets/{r-BIKQf2_o.js → r-BhAilYXn.js} +1 -1
- package/dist/client/assets/{razor-VS7QcRBz.js → razor-r2okdCFg.js} +1 -1
- package/dist/client/assets/{regexp-BGXlBPe-.js → regexp-Du1vBRb3.js} +1 -1
- package/dist/client/assets/{rst-CtnBHA3G.js → rst-Bh3lie48.js} +1 -1
- package/dist/client/assets/{ruby-DfBqK5Ka.js → ruby-vs3FXmpi.js} +1 -1
- package/dist/client/assets/{sas-DwMmN1kw.js → sas-DkxBsIWw.js} +1 -1
- package/dist/client/assets/{scss-7FiaCmfG.js → scss-dEqn8th9.js} +1 -1
- package/dist/client/assets/{shellscript-CxQE9DrY.js → shellscript-DuK80VLO.js} +1 -1
- package/dist/client/assets/{shellsession-i6NLX_zx.js → shellsession-K16miZZV.js} +1 -1
- package/dist/client/assets/{soy-Zg81ZOBM.js → soy-DseiXgsm.js} +1 -1
- package/dist/client/assets/{sql-DvHjiiM0.js → sql-aXIOcbM3.js} +1 -1
- package/dist/client/assets/{stata-bzQmI9sO.js → stata-CEH4kh2r.js} +1 -1
- package/dist/client/assets/{surrealql-7bfvOoBP.js → surrealql-COLqIpYI.js} +1 -1
- package/dist/client/assets/{svelte-BC4Wb6zY.js → svelte-Bv_6pJZf.js} +1 -1
- package/dist/client/assets/{templ-BlRvXkRB.js → templ-BK2D_AFL.js} +1 -1
- package/dist/client/assets/{tex-BZ-VT_2k.js → tex--R7BXLUz.js} +1 -1
- package/dist/client/assets/{ts-tags-B45Vdn_H.js → ts-tags-C_tPUBdU.js} +1 -1
- package/dist/client/assets/{tsx-brdxK9JX.js → tsx-CkHIsN3A.js} +1 -1
- package/dist/client/assets/{twig-QAA4qNIj.js → twig-Br2AoIs8.js} +1 -1
- package/dist/client/assets/{typescript-DdycJttK.js → typescript-C733jxER.js} +1 -1
- package/dist/client/assets/{typst-FttrfCDx.js → typst-1qUjIU5z.js} +1 -1
- package/dist/client/assets/{vue-BLYzpymV.js → vue-DtvZUYyv.js} +1 -1
- package/dist/client/assets/{vue-html-CDSxsViu.js → vue-html-Db1X0s7Z.js} +1 -1
- package/dist/client/assets/{vue-vine-HAh5UYYh.js → vue-vine-DQulvS_V.js} +1 -1
- package/dist/client/assets/{xml-Dd9eo5Rj.js → xml-DY9-QC9A.js} +1 -1
- package/dist/client/assets/{xsl-Bj_JN_p0.js → xsl-LICYc3rB.js} +1 -1
- package/dist/client/assets/{yaml-D5cDfBF2.js → yaml-DmBUKFWe.js} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/server/main.js +1803 -1121
- package/package.json +6 -1
- package/dist/client/assets/index-DLiMd279.css +0 -2
- package/dist/client/assets/index-Wim0Hx7Y.js +0 -1797
package/dist/server/main.js
CHANGED
|
@@ -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.
|
|
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
|
]);
|
|
@@ -12713,8 +12718,8 @@ const LspLocationSchema = LspPositionSchema.extend({
|
|
|
12713
12718
|
*/
|
|
12714
12719
|
external: literal(true).optional()
|
|
12715
12720
|
});
|
|
12716
|
-
/**
|
|
12717
|
-
const
|
|
12721
|
+
/** One language server process, as its bridge sees it. */
|
|
12722
|
+
const LspProcessStatusSchema = object({
|
|
12718
12723
|
/** Program name, for messages: the basename of the command's first word, e.g. `pyrefly`. */
|
|
12719
12724
|
name: string(),
|
|
12720
12725
|
/** The command line it was started from. */
|
|
@@ -12728,19 +12733,83 @@ const LspServerStatusSchema = object({
|
|
|
12728
12733
|
message: string().optional(),
|
|
12729
12734
|
/** Active work reported through LSP work-done progress; absent when none is reported. */
|
|
12730
12735
|
activity: string().array().optional(),
|
|
12731
|
-
/**
|
|
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
|
+
*/
|
|
12732
12740
|
notice: object({
|
|
12733
12741
|
severity: _enum(["warning", "error"]),
|
|
12734
12742
|
message: string()
|
|
12735
12743
|
}).optional(),
|
|
12736
|
-
/**
|
|
12737
|
-
|
|
12738
|
-
|
|
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
|
+
});
|
|
12739
12803
|
/** A language in the diff with no server behind it. */
|
|
12740
12804
|
const LspMissingSchema = object({
|
|
12741
12805
|
language: LanguageIdSchema,
|
|
12742
12806
|
/** Programs looked for on PATH, so the UI can name what to install. Empty when config turned the language off. */
|
|
12743
|
-
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()
|
|
12744
12813
|
});
|
|
12745
12814
|
const LspStatusSchema = object({
|
|
12746
12815
|
/** False with `--no-lsp`: no server will start for this run. */
|
|
@@ -12749,7 +12818,16 @@ const LspStatusSchema = object({
|
|
|
12749
12818
|
servers: LspServerStatusSchema.array(),
|
|
12750
12819
|
missing: LspMissingSchema.array()
|
|
12751
12820
|
});
|
|
12752
|
-
|
|
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
|
+
});
|
|
12753
12831
|
object({
|
|
12754
12832
|
/** Markdown; null when the server has nothing for the position. */
|
|
12755
12833
|
contents: string().nullable(),
|
|
@@ -12759,11 +12837,9 @@ object({
|
|
|
12759
12837
|
col: number(),
|
|
12760
12838
|
endLine: number(),
|
|
12761
12839
|
endCol: number()
|
|
12762
|
-
}).optional()
|
|
12840
|
+
}).optional(),
|
|
12841
|
+
inconclusive: inconclusive$1
|
|
12763
12842
|
});
|
|
12764
|
-
object({
|
|
12765
|
-
/** Null when the position is not inside a token or the server does not classify tokens. */
|
|
12766
|
-
kind: string().nullable() });
|
|
12767
12843
|
object({ occurrences: object({
|
|
12768
12844
|
line: number().int().positive(),
|
|
12769
12845
|
col: number().int().nonnegative(),
|
|
@@ -12777,16 +12853,19 @@ object({ occurrences: object({
|
|
|
12777
12853
|
])
|
|
12778
12854
|
}).array() });
|
|
12779
12855
|
object({
|
|
12780
|
-
|
|
12781
|
-
|
|
12782
|
-
|
|
12783
|
-
|
|
12784
|
-
|
|
12785
|
-
|
|
12786
|
-
|
|
12787
|
-
|
|
12788
|
-
|
|
12789
|
-
|
|
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
|
|
12790
12869
|
});
|
|
12791
12870
|
_enum([
|
|
12792
12871
|
"unviewed",
|
|
@@ -12816,11 +12895,14 @@ function followsCheckout(snap) {
|
|
|
12816
12895
|
}
|
|
12817
12896
|
/** Extension (no dot, lowercase) → language. `.h` goes to c because clangd serves both. */
|
|
12818
12897
|
const LANGUAGE_BY_EXT = {
|
|
12898
|
+
astro: "astro",
|
|
12819
12899
|
bash: "shellscript",
|
|
12820
12900
|
c: "c",
|
|
12821
12901
|
cc: "cpp",
|
|
12822
12902
|
cjs: "javascript",
|
|
12823
12903
|
cpp: "cpp",
|
|
12904
|
+
cs: "csharp",
|
|
12905
|
+
css: "css",
|
|
12824
12906
|
cts: "typescript",
|
|
12825
12907
|
cxx: "cpp",
|
|
12826
12908
|
go: "go",
|
|
@@ -12828,6 +12910,8 @@ const LANGUAGE_BY_EXT = {
|
|
|
12828
12910
|
hh: "cpp",
|
|
12829
12911
|
hpp: "cpp",
|
|
12830
12912
|
hs: "haskell",
|
|
12913
|
+
htm: "html",
|
|
12914
|
+
html: "html",
|
|
12831
12915
|
hxx: "cpp",
|
|
12832
12916
|
java: "java",
|
|
12833
12917
|
js: "javascript",
|
|
@@ -12852,6 +12936,7 @@ const LANGUAGE_BY_EXT = {
|
|
|
12852
12936
|
toml: "toml",
|
|
12853
12937
|
ts: "typescript",
|
|
12854
12938
|
tsx: "typescriptreact",
|
|
12939
|
+
vue: "vue",
|
|
12855
12940
|
yaml: "yaml",
|
|
12856
12941
|
yml: "yaml",
|
|
12857
12942
|
zig: "zig"
|
|
@@ -12863,35 +12948,6 @@ function languageOf(path) {
|
|
|
12863
12948
|
const ext = /\.([^./\\]+)$/.exec(path)?.[1]?.toLowerCase();
|
|
12864
12949
|
return ext && LANGUAGE_BY_EXT[ext] || null;
|
|
12865
12950
|
}
|
|
12866
|
-
/**
|
|
12867
|
-
* Why the language server cannot answer for `path` — or, with no path, for a
|
|
12868
|
-
* repository-wide request — and null when it can. Shared so the client's flash and the
|
|
12869
|
-
* API's 409 say the same thing. The caller checks `followsCheckout` itself.
|
|
12870
|
-
*/
|
|
12871
|
-
function lspBlocker(lsp, path) {
|
|
12872
|
-
if (!lsp.enabled) return "Language servers are off for this run (--no-lsp)";
|
|
12873
|
-
if (path == null) {
|
|
12874
|
-
if (!lsp.servers.length) return noServer(lsp);
|
|
12875
|
-
return lsp.servers.some((s) => s.state === "ready") ? null : notReady(lsp.servers[0]);
|
|
12876
|
-
}
|
|
12877
|
-
const language = languageOf(path);
|
|
12878
|
-
if (!language) return `No language server for ${/(\.[^./\\]+)$/.exec(path)?.[1] ?? "these"} files`;
|
|
12879
|
-
const server = lsp.servers.find((s) => s.languages.includes(language));
|
|
12880
|
-
if (!server) return noServer(lsp, language);
|
|
12881
|
-
return server.state === "ready" ? null : notReady(server);
|
|
12882
|
-
}
|
|
12883
|
-
function notReady(s) {
|
|
12884
|
-
return s.state === "starting" ? `${s.name} is starting…` : `${s.name} is unavailable: ${s.message ?? "unknown error"}`;
|
|
12885
|
-
}
|
|
12886
|
-
/** No server serves `language` (or nothing runs at all): say what the reader can do about it. */
|
|
12887
|
-
function noServer(lsp, language) {
|
|
12888
|
-
const missing = language == null ? lsp.missing : lsp.missing.filter((m) => m.language === language);
|
|
12889
|
-
const absent = missing.filter((m) => m.tried.length > 0);
|
|
12890
|
-
const off = missing.filter((m) => m.tried.length === 0).map((m) => m.language);
|
|
12891
|
-
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(", ")}`;
|
|
12892
|
-
if (off.length) return `The language server for ${off.join(", ")} is off in your diffle config`;
|
|
12893
|
-
return language == null ? "No language server runs for this diff" : `No language server runs for ${language}`;
|
|
12894
|
-
}
|
|
12895
12951
|
/** Writable HTTP config fields; unknown keys, including CLI-only lspCommands, are stripped. */
|
|
12896
12952
|
const ConfigUpdateSchema = object({
|
|
12897
12953
|
autoViewed: string().array().optional(),
|
|
@@ -13013,46 +13069,158 @@ function fuzzyRank(items, query, key) {
|
|
|
13013
13069
|
return [...matched, ...items.filter((item) => !kept.has(item))];
|
|
13014
13070
|
}
|
|
13015
13071
|
//#endregion
|
|
13016
|
-
//#region src/
|
|
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
|
+
};
|
|
13017
13083
|
/**
|
|
13018
|
-
*
|
|
13019
|
-
*
|
|
13020
|
-
*
|
|
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.
|
|
13021
13087
|
*/
|
|
13022
|
-
function
|
|
13023
|
-
if (
|
|
13024
|
-
|
|
13025
|
-
|
|
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(", ");
|
|
13026
13110
|
}
|
|
13027
|
-
function
|
|
13028
|
-
|
|
13029
|
-
if ("kind" in part) return part.kind === "markdown" ? part.value : fence(part.value, "");
|
|
13030
|
-
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"}`;
|
|
13031
13113
|
}
|
|
13032
|
-
/**
|
|
13033
|
-
function
|
|
13034
|
-
const
|
|
13035
|
-
const
|
|
13036
|
-
return
|
|
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}`;
|
|
13037
13124
|
}
|
|
13038
|
-
|
|
13039
|
-
|
|
13040
|
-
/**
|
|
13041
|
-
|
|
13042
|
-
|
|
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
|
+
}
|
|
13043
13152
|
}
|
|
13153
|
+
const inProcess = /* @__PURE__ */ new Map();
|
|
13044
13154
|
/**
|
|
13045
|
-
*
|
|
13046
|
-
*
|
|
13047
|
-
*
|
|
13048
|
-
*
|
|
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.
|
|
13049
13159
|
*/
|
|
13050
|
-
function
|
|
13051
|
-
|
|
13052
|
-
|
|
13053
|
-
|
|
13054
|
-
|
|
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
|
|
13055
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
|
+
}
|
|
13056
13224
|
}
|
|
13057
13225
|
//#endregion
|
|
13058
13226
|
//#region src/server/lsp/JsonRpc.ts
|
|
@@ -13110,8 +13278,8 @@ var JsonRpcConnection = class {
|
|
|
13110
13278
|
params
|
|
13111
13279
|
});
|
|
13112
13280
|
}
|
|
13113
|
-
/** Observes server
|
|
13114
|
-
|
|
13281
|
+
/** Observes every message the server sends: notifications and requests alike. */
|
|
13282
|
+
onMessage(handler) {
|
|
13115
13283
|
this.handlers.add(handler);
|
|
13116
13284
|
}
|
|
13117
13285
|
/** Handles a server request and returns its result, including asynchronous results. */
|
|
@@ -13150,10 +13318,10 @@ var JsonRpcConnection = class {
|
|
|
13150
13318
|
if (this.buffer.length < start + length) return;
|
|
13151
13319
|
const body = this.buffer.subarray(start, start + length).toString("utf8");
|
|
13152
13320
|
this.buffer = this.buffer.subarray(start + length);
|
|
13153
|
-
this.
|
|
13321
|
+
this.receive(body);
|
|
13154
13322
|
}
|
|
13155
13323
|
}
|
|
13156
|
-
|
|
13324
|
+
receive(body) {
|
|
13157
13325
|
let msg;
|
|
13158
13326
|
try {
|
|
13159
13327
|
msg = JSON.parse(body);
|
|
@@ -13190,552 +13358,497 @@ var JsonRpcConnection = class {
|
|
|
13190
13358
|
}
|
|
13191
13359
|
};
|
|
13192
13360
|
//#endregion
|
|
13193
|
-
//#region src/server/lsp/
|
|
13194
|
-
|
|
13195
|
-
|
|
13196
|
-
|
|
13197
|
-
|
|
13198
|
-
|
|
13199
|
-
|
|
13200
|
-
|
|
13201
|
-
|
|
13202
|
-
|
|
13203
|
-
|
|
13204
|
-
|
|
13205
|
-
|
|
13206
|
-
|
|
13207
|
-
|
|
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
|
+
});
|
|
13208
13378
|
}
|
|
13209
|
-
/**
|
|
13210
|
-
|
|
13211
|
-
|
|
13212
|
-
|
|
13213
|
-
|
|
13214
|
-
|
|
13215
|
-
|
|
13216
|
-
|
|
13217
|
-
|
|
13218
|
-
if (!
|
|
13219
|
-
const
|
|
13220
|
-
if (
|
|
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;
|
|
13221
13395
|
}
|
|
13222
|
-
return null;
|
|
13223
13396
|
}
|
|
13224
13397
|
/**
|
|
13225
|
-
*
|
|
13226
|
-
*
|
|
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.
|
|
13227
13402
|
*/
|
|
13228
|
-
function
|
|
13229
|
-
|
|
13230
|
-
if (
|
|
13231
|
-
|
|
13232
|
-
|
|
13233
|
-
|
|
13234
|
-
|
|
13235
|
-
|
|
13236
|
-
|
|
13237
|
-
|
|
13238
|
-
|
|
13239
|
-
|
|
13240
|
-
} catch {
|
|
13241
|
-
return false;
|
|
13242
|
-
}
|
|
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
|
+
});
|
|
13243
13415
|
}
|
|
13244
13416
|
//#endregion
|
|
13245
|
-
//#region src/server/
|
|
13246
|
-
/**
|
|
13247
|
-
const
|
|
13248
|
-
|
|
13249
|
-
|
|
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
|
+
};
|
|
13250
13427
|
/**
|
|
13251
|
-
*
|
|
13252
|
-
*
|
|
13253
|
-
*
|
|
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.
|
|
13254
13432
|
*/
|
|
13255
|
-
|
|
13256
|
-
|
|
13257
|
-
|
|
13258
|
-
|
|
13259
|
-
|
|
13260
|
-
|
|
13261
|
-
|
|
13262
|
-
|
|
13263
|
-
|
|
13264
|
-
|
|
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
|
|
13265
13525
|
});
|
|
13266
|
-
|
|
13267
|
-
|
|
13268
|
-
|
|
13269
|
-
|
|
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}` : ""}`) });
|
|
13270
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;
|
|
13271
13575
|
}
|
|
13272
|
-
const inProcess = /* @__PURE__ */ new Map();
|
|
13273
13576
|
/**
|
|
13274
|
-
*
|
|
13275
|
-
*
|
|
13276
|
-
* than 30s, is taken over; waiting longer than 10s throws. The lock is
|
|
13277
|
-
* 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.
|
|
13278
13579
|
*/
|
|
13279
|
-
|
|
13280
|
-
|
|
13281
|
-
|
|
13282
|
-
|
|
13283
|
-
|
|
13284
|
-
|
|
13285
|
-
|
|
13286
|
-
|
|
13287
|
-
|
|
13288
|
-
|
|
13289
|
-
|
|
13290
|
-
}
|
|
13291
|
-
|
|
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
|
|
13292
13601
|
}
|
|
13293
|
-
}
|
|
13294
|
-
|
|
13295
|
-
|
|
13602
|
+
},
|
|
13603
|
+
workspace: {
|
|
13604
|
+
symbol: { dynamicRegistration: true },
|
|
13605
|
+
workspaceFolders: true,
|
|
13606
|
+
configuration: true
|
|
13296
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;
|
|
13297
13615
|
}
|
|
13298
|
-
|
|
13299
|
-
|
|
13300
|
-
|
|
13301
|
-
|
|
13302
|
-
}
|
|
13303
|
-
|
|
13304
|
-
|
|
13305
|
-
|
|
13306
|
-
|
|
13307
|
-
|
|
13308
|
-
|
|
13309
|
-
|
|
13310
|
-
|
|
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);
|
|
13311
13656
|
}
|
|
13312
|
-
|
|
13313
|
-
|
|
13314
|
-
|
|
13315
|
-
}
|
|
13316
|
-
if (await isStale(lock)) {
|
|
13317
|
-
await unlink(lock).catch(() => {});
|
|
13318
|
-
continue;
|
|
13319
|
-
}
|
|
13320
|
-
if (Date.now() > deadline) throw new Error(`timed out waiting for ${lock}`);
|
|
13321
|
-
await new Promise((r) => setTimeout(r, 10 + Math.random() * 20));
|
|
13657
|
+
};
|
|
13658
|
+
walk(result);
|
|
13659
|
+
return out;
|
|
13322
13660
|
}
|
|
13661
|
+
return result.map((s) => symbolInformation(s, path));
|
|
13323
13662
|
}
|
|
13324
|
-
|
|
13325
|
-
|
|
13326
|
-
|
|
13327
|
-
|
|
13328
|
-
|
|
13329
|
-
|
|
13330
|
-
|
|
13331
|
-
|
|
13332
|
-
|
|
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);
|
|
13333
13696
|
}
|
|
13334
|
-
|
|
13335
|
-
|
|
13336
|
-
|
|
13337
|
-
|
|
13338
|
-
|
|
13339
|
-
|
|
13340
|
-
|
|
13341
|
-
|
|
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;
|
|
13342
13719
|
}
|
|
13720
|
+
return null;
|
|
13343
13721
|
}
|
|
13344
|
-
|
|
13345
|
-
|
|
13346
|
-
|
|
13347
|
-
|
|
13348
|
-
|
|
13349
|
-
|
|
13350
|
-
|
|
13351
|
-
|
|
13352
|
-
|
|
13353
|
-
const ConfigurationParamsSchema = object({ items: array(object({ section: string().optional() })) });
|
|
13354
|
-
/** Valid catalog entries, in the association format understood by the JSON server. */
|
|
13355
|
-
function schemaAssociations(value) {
|
|
13356
|
-
const catalog = SchemaCatalogSchema.safeParse(value);
|
|
13357
|
-
if (!catalog.success) return [];
|
|
13358
|
-
return catalog.data.schemas.flatMap((entry) => {
|
|
13359
|
-
const association = AssociationSchema.safeParse(entry);
|
|
13360
|
-
return association.success ? [association.data] : [];
|
|
13361
|
-
});
|
|
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;
|
|
13362
13731
|
}
|
|
13363
|
-
|
|
13364
|
-
async function loadSchemaCatalog(cacheFile = join(process.env.XDG_CACHE_HOME || join(homedir(), ".cache"), "diffle", "schemastore.json"), request = fetch) {
|
|
13365
|
-
let cached = [];
|
|
13366
|
-
try {
|
|
13367
|
-
cached = schemaAssociations(JSON.parse(await readFile(cacheFile, "utf8")));
|
|
13368
|
-
if (cached.length && Date.now() - (await stat$1(cacheFile)).mtimeMs < MAX_AGE) return cached;
|
|
13369
|
-
} catch {}
|
|
13732
|
+
function runnable(file) {
|
|
13370
13733
|
try {
|
|
13371
|
-
|
|
13372
|
-
if (
|
|
13373
|
-
|
|
13374
|
-
|
|
13375
|
-
await writeFileAtomic(cacheFile, JSON.stringify({ schemas }) + "\n").catch(() => {});
|
|
13376
|
-
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;
|
|
13377
13738
|
} catch {
|
|
13378
|
-
return
|
|
13739
|
+
return false;
|
|
13379
13740
|
}
|
|
13380
13741
|
}
|
|
13381
|
-
/** Default schema settings for the languages routed to a process. */
|
|
13382
|
-
async function settingsFor(languages, command) {
|
|
13383
|
-
const settings = {};
|
|
13384
|
-
if (languages.includes("yaml")) settings.yaml = {
|
|
13385
|
-
hover: true,
|
|
13386
|
-
schemaStore: {
|
|
13387
|
-
enable: true,
|
|
13388
|
-
url: SCHEMA_CATALOG
|
|
13389
|
-
}
|
|
13390
|
-
};
|
|
13391
|
-
const program = argv0(command).split(/[\\/]/).pop()?.replace(/\.(exe|cmd|bat)$/i, "");
|
|
13392
|
-
if (languages.includes("toml") && program === "taplo") settings.evenBetterToml = { schema: {
|
|
13393
|
-
enabled: true,
|
|
13394
|
-
catalogs: [SCHEMA_CATALOG]
|
|
13395
|
-
} };
|
|
13396
|
-
if (languages.some((l) => l === "json" || l === "jsonc")) settings.json = { schemas: await loadSchemaCatalog() };
|
|
13397
|
-
return settings;
|
|
13398
|
-
}
|
|
13399
13742
|
/**
|
|
13400
|
-
*
|
|
13401
|
-
*
|
|
13402
|
-
*
|
|
13403
|
-
*
|
|
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.
|
|
13404
13748
|
*/
|
|
13405
|
-
function
|
|
13406
|
-
|
|
13407
|
-
|
|
13408
|
-
|
|
13409
|
-
|
|
13410
|
-
|
|
13411
|
-
|
|
13412
|
-
|
|
13413
|
-
|
|
13414
|
-
|
|
13415
|
-
return
|
|
13416
|
-
}
|
|
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
|
+
}
|
|
13417
13761
|
}
|
|
13418
13762
|
//#endregion
|
|
13419
|
-
//#region src/server/lsp/
|
|
13420
|
-
/** The server is off, still starting, or
|
|
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. */
|
|
13421
13765
|
var LspUnavailableError = class extends Error {};
|
|
13422
|
-
/**
|
|
13423
|
-
|
|
13424
|
-
|
|
13425
|
-
|
|
13426
|
-
|
|
13427
|
-
|
|
13428
|
-
|
|
13429
|
-
"type",
|
|
13430
|
-
"class",
|
|
13431
|
-
"enum",
|
|
13432
|
-
"interface",
|
|
13433
|
-
"struct",
|
|
13434
|
-
"typeParameter",
|
|
13435
|
-
"parameter",
|
|
13436
|
-
"variable",
|
|
13437
|
-
"property",
|
|
13438
|
-
"enumMember",
|
|
13439
|
-
"event",
|
|
13440
|
-
"function",
|
|
13441
|
-
"method",
|
|
13442
|
-
"macro",
|
|
13443
|
-
"keyword",
|
|
13444
|
-
"modifier",
|
|
13445
|
-
"comment",
|
|
13446
|
-
"string",
|
|
13447
|
-
"number",
|
|
13448
|
-
"regexp",
|
|
13449
|
-
"operator",
|
|
13450
|
-
"decorator"
|
|
13451
|
-
];
|
|
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() })) });
|
|
13452
13773
|
const INIT_TIMEOUT_MS = 6e4;
|
|
13453
|
-
/**
|
|
13454
|
-
* Navigation opens whatever file a query lands in. Without a cap every path
|
|
13455
|
-
* visited in a long review stays open in the server, growing its memory and
|
|
13456
|
-
* per-save recheck cost.
|
|
13457
|
-
*/
|
|
13458
|
-
const MAX_UNTRACKED_OPEN = 64;
|
|
13459
|
-
/** Concurrent snapshot reads while mapping a result set back to paths. */
|
|
13460
|
-
const READ_CONCURRENCY = 8;
|
|
13461
|
-
/** Files outside the snapshot remembered for `readExternal`; the least recently named is forgotten first. */
|
|
13462
|
-
const MAX_EXTERNAL = 1024;
|
|
13463
13774
|
/** Graceful shutdown budget. Both together stay under the CLI's hard exit deadline (main.ts). */
|
|
13464
13775
|
const SHUTDOWN_RPC_MS = 500;
|
|
13465
13776
|
const SIGKILL_AFTER_MS = 500;
|
|
13466
|
-
/** LSP `ContentModified`: the document moved under the request, so the answer would be stale. */
|
|
13467
|
-
const CONTENT_MODIFIED = -32801;
|
|
13468
|
-
/** Waits before re-asking a request the server refused as stale, one per attempt. */
|
|
13469
|
-
const CONTENT_MODIFIED_BACKOFF_MS = [40, 120];
|
|
13470
13777
|
/**
|
|
13471
|
-
*
|
|
13472
|
-
*
|
|
13473
|
-
*
|
|
13474
|
-
|
|
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.
|
|
13475
13789
|
*/
|
|
13476
|
-
var
|
|
13790
|
+
var LspProcess = class {
|
|
13477
13791
|
opts;
|
|
13478
13792
|
child = null;
|
|
13479
13793
|
rpc = null;
|
|
13480
13794
|
current;
|
|
13481
13795
|
ready;
|
|
13482
|
-
stderr = "";
|
|
13483
|
-
progress = /* @__PURE__ */ new Map();
|
|
13484
|
-
/** Open documents, least recently synced first. */
|
|
13485
|
-
open = /* @__PURE__ */ new Map();
|
|
13486
|
-
/** Syncs in flight by path, so a track pass and a query racing on one file send a single didOpen. */
|
|
13487
|
-
syncing = /* @__PURE__ */ new Map();
|
|
13488
|
-
/** Paths `track` opened on the snapshot's behalf; closed again when they leave the snapshot. */
|
|
13489
|
-
tracked = /* @__PURE__ */ new Set();
|
|
13490
|
-
tracking = Promise.resolve();
|
|
13491
13796
|
closing = false;
|
|
13492
|
-
ownsGroup = false;
|
|
13493
|
-
/**
|
|
13494
|
-
|
|
13495
|
-
|
|
13496
|
-
|
|
13497
|
-
|
|
13498
|
-
|
|
13499
|
-
|
|
13500
|
-
|
|
13501
|
-
|
|
13502
|
-
|
|
13503
|
-
|
|
13504
|
-
|
|
13505
|
-
|
|
13506
|
-
|
|
13507
|
-
|
|
13508
|
-
|
|
13509
|
-
|
|
13510
|
-
|
|
13511
|
-
|
|
13512
|
-
|
|
13513
|
-
|
|
13514
|
-
return this.current;
|
|
13515
|
-
}
|
|
13516
|
-
async definition(pos) {
|
|
13517
|
-
const result = await this.query("textDocument/definition", {
|
|
13518
|
-
textDocument: { uri: this.uri(pos.path) },
|
|
13519
|
-
position: toLsp(pos)
|
|
13520
|
-
}, pos.path);
|
|
13521
|
-
return this.toLocations(normalizeLocations(result));
|
|
13522
|
-
}
|
|
13523
|
-
async typeDefinition(pos) {
|
|
13524
|
-
const result = await this.query("textDocument/typeDefinition", {
|
|
13525
|
-
textDocument: { uri: this.uri(pos.path) },
|
|
13526
|
-
position: toLsp(pos)
|
|
13527
|
-
}, pos.path);
|
|
13528
|
-
return this.toLocations(normalizeLocations(result));
|
|
13529
|
-
}
|
|
13530
|
-
async hover(pos) {
|
|
13531
|
-
const result = await this.query("textDocument/hover", {
|
|
13532
|
-
textDocument: { uri: this.uri(pos.path) },
|
|
13533
|
-
position: toLsp(pos)
|
|
13534
|
-
}, pos.path);
|
|
13535
|
-
const md = result ? hoverMarkdown(result.contents) : null;
|
|
13536
|
-
const res = { contents: md == null ? null : localizeFileLinks(md, await this.resolveLinks(md)) };
|
|
13537
|
-
if (result?.range) {
|
|
13538
|
-
const { start, end } = result.range;
|
|
13539
|
-
res.range = {
|
|
13540
|
-
line: start.line + 1,
|
|
13541
|
-
col: start.character,
|
|
13542
|
-
endLine: end.line + 1,
|
|
13543
|
-
endCol: end.character
|
|
13544
|
-
};
|
|
13545
|
-
}
|
|
13546
|
-
return res;
|
|
13547
|
-
}
|
|
13548
|
-
/**
|
|
13549
|
-
* The semantic token class at a position, via `textDocument/semanticTokens/range`
|
|
13550
|
-
* on that one line. A server without semantic tokens, or a position between
|
|
13551
|
-
* tokens, yields null, so callers treat null as "no opinion".
|
|
13552
|
-
*/
|
|
13553
|
-
async tokenKind(pos) {
|
|
13554
|
-
const line = Math.max(0, pos.line - 1);
|
|
13555
|
-
let data;
|
|
13556
|
-
try {
|
|
13557
|
-
data = (await this.query("textDocument/semanticTokens/range", {
|
|
13558
|
-
textDocument: { uri: this.uri(pos.path) },
|
|
13559
|
-
range: {
|
|
13560
|
-
start: {
|
|
13561
|
-
line,
|
|
13562
|
-
character: 0
|
|
13563
|
-
},
|
|
13564
|
-
end: {
|
|
13565
|
-
line: line + 1,
|
|
13566
|
-
character: 0
|
|
13567
|
-
}
|
|
13568
|
-
}
|
|
13569
|
-
}, pos.path))?.data ?? [];
|
|
13570
|
-
} catch (e) {
|
|
13571
|
-
if (e instanceof LspUnavailableError) throw e;
|
|
13572
|
-
return { kind: null };
|
|
13573
|
-
}
|
|
13574
|
-
let tokLine = 0;
|
|
13575
|
-
let tokStart = 0;
|
|
13576
|
-
for (let i = 0; i + 4 < data.length; i += 5) {
|
|
13577
|
-
const deltaLine = data[i];
|
|
13578
|
-
tokLine += deltaLine;
|
|
13579
|
-
tokStart = deltaLine === 0 ? tokStart + data[i + 1] : data[i + 1];
|
|
13580
|
-
if (tokLine !== line || pos.col < tokStart || pos.col >= tokStart + data[i + 2]) continue;
|
|
13581
|
-
return { kind: this.tokenTypes[data[i + 3]] ?? null };
|
|
13582
|
-
}
|
|
13583
|
-
return { kind: null };
|
|
13584
|
-
}
|
|
13585
|
-
/**
|
|
13586
|
-
* Where else the symbol at `pos` occurs in its file, via `textDocument/documentHighlight`,
|
|
13587
|
-
* so scope and shadowing are the server's call. A server without the method, or a
|
|
13588
|
-
* position holding no symbol, yields no occurrences: the answer is advisory.
|
|
13589
|
-
*/
|
|
13590
|
-
async occurrences(pos) {
|
|
13591
|
-
let result;
|
|
13592
|
-
try {
|
|
13593
|
-
result = await this.query("textDocument/documentHighlight", {
|
|
13594
|
-
textDocument: { uri: this.uri(pos.path) },
|
|
13595
|
-
position: toLsp(pos)
|
|
13596
|
-
}, pos.path);
|
|
13597
|
-
} catch (e) {
|
|
13598
|
-
if (e instanceof LspUnavailableError) throw e;
|
|
13599
|
-
return { occurrences: [] };
|
|
13600
|
-
}
|
|
13601
|
-
return { occurrences: (result ?? []).map(({ range: { start, end }, kind }) => ({
|
|
13602
|
-
line: start.line + 1,
|
|
13603
|
-
col: start.character,
|
|
13604
|
-
endLine: end.line + 1,
|
|
13605
|
-
endCol: end.character,
|
|
13606
|
-
kind: kind === 3 ? "write" : kind === 2 ? "read" : "text"
|
|
13607
|
-
})) };
|
|
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(() => {});
|
|
13608
13819
|
}
|
|
13609
|
-
|
|
13610
|
-
|
|
13611
|
-
|
|
13612
|
-
position: toLsp(pos),
|
|
13613
|
-
context: { includeDeclaration: opts.includeDeclaration ?? true }
|
|
13614
|
-
}, pos.path);
|
|
13615
|
-
return this.toLocations(normalizeLocations(result));
|
|
13820
|
+
/** How messages name the server: `basedpyright-langserver --stdio` → `basedpyright-langserver`. */
|
|
13821
|
+
get name() {
|
|
13822
|
+
return this.current.name;
|
|
13616
13823
|
}
|
|
13617
|
-
|
|
13618
|
-
|
|
13619
|
-
* Servers index the workspace lazily and cap how much of it they index
|
|
13620
|
-
* (pyrefly: in the background from the first open, 2000 files), so a
|
|
13621
|
-
* reference in a changed file that nobody queried yet is otherwise only found
|
|
13622
|
-
* once its file happens to be indexed, and from the text on disk. Opening
|
|
13623
|
-
* everything at start also warms the index before the first query. Calls are
|
|
13624
|
-
* serialized; the last set wins. Never throws: a path the snapshot cannot read
|
|
13625
|
-
* is skipped.
|
|
13626
|
-
*/
|
|
13627
|
-
track(paths) {
|
|
13628
|
-
const run = this.tracking.then(() => this.trackNow(paths)).catch(() => {});
|
|
13629
|
-
this.tracking = run;
|
|
13630
|
-
return run;
|
|
13824
|
+
status() {
|
|
13825
|
+
return this.current;
|
|
13631
13826
|
}
|
|
13632
|
-
|
|
13633
|
-
|
|
13634
|
-
|
|
13635
|
-
for (const path of this.tracked) {
|
|
13636
|
-
if (next.has(path)) continue;
|
|
13637
|
-
this.tracked.delete(path);
|
|
13638
|
-
if (this.open.delete(path)) rpc.notify("textDocument/didClose", { textDocument: { uri: this.uri(path) } });
|
|
13639
|
-
}
|
|
13640
|
-
for (const path of next) try {
|
|
13641
|
-
await this.sync(path);
|
|
13642
|
-
this.tracked.add(path);
|
|
13643
|
-
} catch (e) {
|
|
13644
|
-
if (e instanceof LspUnavailableError) return;
|
|
13645
|
-
}
|
|
13827
|
+
/** The retained log lines, oldest first. */
|
|
13828
|
+
entries() {
|
|
13829
|
+
return this.output.entries();
|
|
13646
13830
|
}
|
|
13647
|
-
|
|
13648
|
-
|
|
13649
|
-
|
|
13650
|
-
const out = [];
|
|
13651
|
-
if (isDocumentSymbols(result)) {
|
|
13652
|
-
const walk = (syms, container) => {
|
|
13653
|
-
for (const s of syms) {
|
|
13654
|
-
const start = (s.selectionRange ?? s.range).start;
|
|
13655
|
-
const endLine = Math.max(start.line, (s.range ?? s.selectionRange).end.line) + 1;
|
|
13656
|
-
out.push({
|
|
13657
|
-
name: s.name,
|
|
13658
|
-
kind: s.kind,
|
|
13659
|
-
container,
|
|
13660
|
-
path,
|
|
13661
|
-
line: start.line + 1,
|
|
13662
|
-
endLine,
|
|
13663
|
-
col: start.character
|
|
13664
|
-
});
|
|
13665
|
-
if (s.children?.length) walk(s.children, s.name);
|
|
13666
|
-
}
|
|
13667
|
-
};
|
|
13668
|
-
walk(result);
|
|
13669
|
-
return out;
|
|
13670
|
-
}
|
|
13671
|
-
for (const s of result) {
|
|
13672
|
-
const start = "range" in s.location ? s.location.range.start : {
|
|
13673
|
-
line: 0,
|
|
13674
|
-
character: 0
|
|
13675
|
-
};
|
|
13676
|
-
const end = "range" in s.location ? s.location.range.end : start;
|
|
13677
|
-
out.push({
|
|
13678
|
-
name: s.name,
|
|
13679
|
-
kind: s.kind,
|
|
13680
|
-
container: s.containerName || void 0,
|
|
13681
|
-
path,
|
|
13682
|
-
line: start.line + 1,
|
|
13683
|
-
endLine: Math.max(start.line, end.line) + 1,
|
|
13684
|
-
col: start.character
|
|
13685
|
-
});
|
|
13686
|
-
}
|
|
13687
|
-
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);
|
|
13688
13834
|
}
|
|
13689
|
-
|
|
13690
|
-
|
|
13691
|
-
* exposes, best `fuzzyRank` match first. Membership comes from `has`, never from reading the file: a
|
|
13692
|
-
* per-keystroke search must not read every hit's text.
|
|
13693
|
-
*/
|
|
13694
|
-
async workspaceSymbols(query, limit = 200) {
|
|
13695
|
-
const result = await this.query("workspace/symbol", { query });
|
|
13696
|
-
if (!result) return [];
|
|
13697
|
-
const paths = await Promise.all(result.map((s) => this.pathOf(s.location.uri)));
|
|
13698
|
-
const inside = await this.membership(paths);
|
|
13699
|
-
const out = [];
|
|
13700
|
-
const seen = /* @__PURE__ */ new Set();
|
|
13701
|
-
result.forEach((s, i) => {
|
|
13702
|
-
const path = paths[i];
|
|
13703
|
-
if (path == null || !inside.get(path)) return;
|
|
13704
|
-
const start = "range" in s.location ? s.location.range.start : {
|
|
13705
|
-
line: 0,
|
|
13706
|
-
character: 0
|
|
13707
|
-
};
|
|
13708
|
-
const key = `${path}:${start.line}:${start.character}:${s.name}`;
|
|
13709
|
-
if (seen.has(key)) return;
|
|
13710
|
-
seen.add(key);
|
|
13711
|
-
const end = "range" in s.location ? s.location.range.end : start;
|
|
13712
|
-
out.push({
|
|
13713
|
-
name: s.name,
|
|
13714
|
-
kind: s.kind,
|
|
13715
|
-
container: s.containerName || void 0,
|
|
13716
|
-
path,
|
|
13717
|
-
line: start.line + 1,
|
|
13718
|
-
endLine: Math.max(start.line, end.line) + 1,
|
|
13719
|
-
col: start.character
|
|
13720
|
-
});
|
|
13721
|
-
});
|
|
13722
|
-
return fuzzyRank(out, query, (s) => s.name).slice(0, limit);
|
|
13835
|
+
features() {
|
|
13836
|
+
return [...this.advertised];
|
|
13723
13837
|
}
|
|
13724
|
-
/**
|
|
13725
|
-
|
|
13726
|
-
|
|
13727
|
-
|
|
13728
|
-
* never returned stays behind the snapshot allowlist.
|
|
13729
|
-
*/
|
|
13730
|
-
async readExternal(path) {
|
|
13731
|
-
if (!this.external.has(path)) return null;
|
|
13732
|
-
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;
|
|
13733
13842
|
}
|
|
13734
|
-
/**
|
|
13735
|
-
async
|
|
13736
|
-
|
|
13737
|
-
|
|
13738
|
-
|
|
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;
|
|
13739
13852
|
}
|
|
13740
13853
|
async close() {
|
|
13741
13854
|
this.closing = true;
|
|
@@ -13794,18 +13907,12 @@ var LspBridge = class LspBridge {
|
|
|
13794
13907
|
throw e;
|
|
13795
13908
|
}
|
|
13796
13909
|
this.child = child;
|
|
13797
|
-
child.stderr?.on("data", (d) =>
|
|
13798
|
-
const chunk = d.toString("utf8");
|
|
13799
|
-
this.stderr = (this.stderr + chunk).slice(-2e3);
|
|
13800
|
-
if (!this.closing && this.current.state !== "unavailable") this.set({
|
|
13801
|
-
...this.current,
|
|
13802
|
-
stderr: this.stderr.trim()
|
|
13803
|
-
});
|
|
13804
|
-
});
|
|
13910
|
+
child.stderr?.on("data", (d) => this.output.stderr(d.toString("utf8")));
|
|
13805
13911
|
child.on("error", (e) => this.fail(`cannot start "${this.opts.command}": ${e.message}`));
|
|
13806
13912
|
child.on("exit", (code, signal) => {
|
|
13807
13913
|
if (this.closing) return;
|
|
13808
|
-
this.
|
|
13914
|
+
this.output.stderr("\n");
|
|
13915
|
+
this.fail(`"${this.opts.command}" exited (${signal ?? code})`);
|
|
13809
13916
|
});
|
|
13810
13917
|
if (!child.stdin || !child.stdout) {
|
|
13811
13918
|
this.fail(`cannot start "${this.opts.command}": no stdio`);
|
|
@@ -13813,11 +13920,15 @@ var LspBridge = class LspBridge {
|
|
|
13813
13920
|
}
|
|
13814
13921
|
const rpc = new JsonRpcConnection(child.stdout, child.stdin);
|
|
13815
13922
|
this.rpc = rpc;
|
|
13816
|
-
rpc.
|
|
13923
|
+
rpc.onMessage((method, params) => {
|
|
13924
|
+
if (!this.closing && this.current.state !== "unavailable") this.output.message(method, params);
|
|
13925
|
+
});
|
|
13817
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));
|
|
13818
13929
|
const rootUri = pathToFileURL(this.opts.root).href;
|
|
13819
13930
|
try {
|
|
13820
|
-
const
|
|
13931
|
+
const capabilities = (await rpc.request("initialize", {
|
|
13821
13932
|
processId: process.pid,
|
|
13822
13933
|
clientInfo: { name: "diffle" },
|
|
13823
13934
|
rootUri,
|
|
@@ -13826,31 +13937,10 @@ var LspBridge = class LspBridge {
|
|
|
13826
13937
|
uri: rootUri,
|
|
13827
13938
|
name: "workspace"
|
|
13828
13939
|
}],
|
|
13829
|
-
|
|
13830
|
-
|
|
13831
|
-
|
|
13832
|
-
|
|
13833
|
-
definition: { linkSupport: true },
|
|
13834
|
-
typeDefinition: { linkSupport: true },
|
|
13835
|
-
references: {},
|
|
13836
|
-
documentHighlight: {},
|
|
13837
|
-
hover: { contentFormat: ["markdown", "plaintext"] },
|
|
13838
|
-
documentSymbol: { hierarchicalDocumentSymbolSupport: true },
|
|
13839
|
-
semanticTokens: {
|
|
13840
|
-
requests: { range: true },
|
|
13841
|
-
tokenTypes: DEFAULT_TOKEN_TYPES,
|
|
13842
|
-
tokenModifiers: [],
|
|
13843
|
-
formats: ["relative"]
|
|
13844
|
-
}
|
|
13845
|
-
},
|
|
13846
|
-
workspace: {
|
|
13847
|
-
symbol: {},
|
|
13848
|
-
workspaceFolders: true,
|
|
13849
|
-
configuration: true
|
|
13850
|
-
}
|
|
13851
|
-
}
|
|
13852
|
-
}, INIT_TIMEOUT_MS))?.capabilities?.semanticTokensProvider?.legend?.tokenTypes;
|
|
13853
|
-
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);
|
|
13854
13944
|
rpc.notify("initialized", {});
|
|
13855
13945
|
if (this.opts.settings) {
|
|
13856
13946
|
const settings = await this.opts.settings;
|
|
@@ -13865,40 +13955,30 @@ var LspBridge = class LspBridge {
|
|
|
13865
13955
|
state: "ready"
|
|
13866
13956
|
});
|
|
13867
13957
|
}
|
|
13868
|
-
|
|
13869
|
-
|
|
13870
|
-
const
|
|
13871
|
-
if (
|
|
13872
|
-
|
|
13873
|
-
const
|
|
13874
|
-
|
|
13875
|
-
|
|
13876
|
-
|
|
13877
|
-
|
|
13878
|
-
|
|
13879
|
-
|
|
13880
|
-
|
|
13881
|
-
|
|
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);
|
|
13882
13979
|
}
|
|
13883
|
-
|
|
13884
|
-
|
|
13885
|
-
const { kind, title, message, percentage } = p.value;
|
|
13886
|
-
const percent = typeof percentage === "number" && percentage >= 0 && percentage <= 100 ? percentage : void 0;
|
|
13887
|
-
if (kind === "begin" && typeof title === "string") this.progress.set(p.token, {
|
|
13888
|
-
title,
|
|
13889
|
-
message: typeof message === "string" ? message : void 0,
|
|
13890
|
-
percentage: percent
|
|
13891
|
-
});
|
|
13892
|
-
else if (kind === "report" && this.progress.has(p.token)) {
|
|
13893
|
-
if (typeof message === "string") this.progress.get(p.token).message = message;
|
|
13894
|
-
if (percent !== void 0) this.progress.get(p.token).percentage = percent;
|
|
13895
|
-
} else if (kind === "end") {
|
|
13896
|
-
if (!this.progress.delete(p.token)) return;
|
|
13897
|
-
} else return;
|
|
13898
|
-
this.set({
|
|
13899
|
-
...this.current,
|
|
13900
|
-
activity: [...this.progress.values()].map((p) => `${p.title}${p.percentage == null ? "" : ` (${p.percentage}%)`}${p.message ? `: ${p.message}` : ""}`)
|
|
13901
|
-
});
|
|
13980
|
+
this.opts.onStatus(this.current);
|
|
13981
|
+
return null;
|
|
13902
13982
|
}
|
|
13903
13983
|
fail(message) {
|
|
13904
13984
|
if (this.current.state === "unavailable") return;
|
|
@@ -13906,75 +13986,94 @@ var LspBridge = class LspBridge {
|
|
|
13906
13986
|
name: this.current.name,
|
|
13907
13987
|
command: this.opts.command,
|
|
13908
13988
|
state: "unavailable",
|
|
13909
|
-
message
|
|
13989
|
+
message,
|
|
13990
|
+
log: this.output.summary()
|
|
13910
13991
|
});
|
|
13911
13992
|
this.rpc?.dispose(new LspUnavailableError(message));
|
|
13912
13993
|
}
|
|
13913
13994
|
set(status) {
|
|
13914
|
-
if (JSON.stringify(status) === JSON.stringify(this.current)) return;
|
|
13915
13995
|
this.current = status;
|
|
13916
13996
|
this.opts.onStatus(status);
|
|
13917
13997
|
}
|
|
13918
|
-
|
|
13919
|
-
|
|
13920
|
-
|
|
13921
|
-
|
|
13922
|
-
|
|
13923
|
-
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
|
|
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;
|
|
13927
14023
|
}
|
|
13928
|
-
/**
|
|
13929
|
-
|
|
13930
|
-
|
|
13931
|
-
* `track` pass opening the diff — so it refuses to answer from text it no longer holds.
|
|
13932
|
-
* Re-syncing and asking again is what an editor does. Giving up reads as an outage, the
|
|
13933
|
-
* one thing the reader can act on, rather than as a failed route.
|
|
13934
|
-
*/
|
|
13935
|
-
async query(method, params, path) {
|
|
13936
|
-
for (let attempt = 0;; attempt++) {
|
|
13937
|
-
const rpc = path == null ? await this.ensureReady() : await this.sync(path);
|
|
13938
|
-
try {
|
|
13939
|
-
const pending = busyReason(this.current);
|
|
13940
|
-
const result = await rpc.request(method, params);
|
|
13941
|
-
if (result == null || Array.isArray(result) && !result.length) {
|
|
13942
|
-
const busy = busyReason(this.current) ?? pending;
|
|
13943
|
-
if (busy) throw new LspUnavailableError(busy);
|
|
13944
|
-
if (this.current.notice) {
|
|
13945
|
-
const { severity, message } = this.current.notice;
|
|
13946
|
-
throw new LspUnavailableError(`${this.current.name} returned no result; last server ${severity}: ${message}`);
|
|
13947
|
-
}
|
|
13948
|
-
}
|
|
13949
|
-
return result;
|
|
13950
|
-
} catch (e) {
|
|
13951
|
-
if (!(e instanceof JsonRpcError) || e.code !== CONTENT_MODIFIED) throw e;
|
|
13952
|
-
const backoff = CONTENT_MODIFIED_BACKOFF_MS[attempt];
|
|
13953
|
-
if (backoff == null) throw new LspUnavailableError(`${this.current.name} could not answer while ${path ?? "the repository"} kept changing; try again`);
|
|
13954
|
-
await new Promise((r) => setTimeout(r, backoff));
|
|
13955
|
-
}
|
|
13956
|
-
}
|
|
14024
|
+
/** The text the server holds for an open path. */
|
|
14025
|
+
textOf(path) {
|
|
14026
|
+
return this.open.get(path)?.text;
|
|
13957
14027
|
}
|
|
13958
14028
|
/**
|
|
13959
|
-
* Opens or updates the document so the server sees the snapshot's text before
|
|
13960
|
-
*
|
|
13961
|
-
*
|
|
13962
|
-
*
|
|
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.
|
|
13963
14033
|
*/
|
|
13964
|
-
|
|
13965
|
-
const rpc = await this.ensureReady();
|
|
14034
|
+
sync(path) {
|
|
13966
14035
|
let pending = this.syncing.get(path);
|
|
13967
14036
|
if (!pending) {
|
|
13968
|
-
pending = this.syncNow(
|
|
14037
|
+
pending = this.syncNow(path).finally(() => this.syncing.delete(path));
|
|
13969
14038
|
this.syncing.set(path, pending);
|
|
13970
14039
|
}
|
|
13971
|
-
|
|
13972
|
-
|
|
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
|
+
}
|
|
13973
14071
|
}
|
|
13974
|
-
async syncNow(
|
|
14072
|
+
async syncNow(path) {
|
|
14073
|
+
const rpc = await this.opts.connection();
|
|
13975
14074
|
const text = await this.opts.read(path);
|
|
13976
14075
|
if (text == null) throw new Error(`${path} is not in the snapshot`);
|
|
13977
|
-
const uri = this.uri(path);
|
|
14076
|
+
const uri = this.opts.uri(path);
|
|
13978
14077
|
const doc = this.open.get(path);
|
|
13979
14078
|
if (!doc) {
|
|
13980
14079
|
this.open.set(path, {
|
|
@@ -14014,31 +14113,72 @@ var LspBridge = class LspBridge {
|
|
|
14014
14113
|
if (this.tracked.has(path) || this.syncing.has(path)) continue;
|
|
14015
14114
|
this.open.delete(path);
|
|
14016
14115
|
untracked--;
|
|
14017
|
-
rpc.notify("textDocument/didClose", { textDocument: { uri: this.uri(path) } });
|
|
14116
|
+
rpc.notify("textDocument/didClose", { textDocument: { uri: this.opts.uri(path) } });
|
|
14018
14117
|
}
|
|
14019
14118
|
}
|
|
14020
|
-
|
|
14021
|
-
|
|
14022
|
-
|
|
14023
|
-
|
|
14024
|
-
|
|
14025
|
-
|
|
14026
|
-
|
|
14027
|
-
|
|
14028
|
-
|
|
14029
|
-
|
|
14030
|
-
|
|
14031
|
-
|
|
14032
|
-
|
|
14033
|
-
|
|
14034
|
-
|
|
14035
|
-
|
|
14036
|
-
|
|
14037
|
-
|
|
14038
|
-
|
|
14039
|
-
|
|
14040
|
-
|
|
14041
|
-
|
|
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);
|
|
14042
14182
|
}
|
|
14043
14183
|
/**
|
|
14044
14184
|
* Repo-relative path for a file URI inside the root; null otherwise. Compares
|
|
@@ -14059,13 +14199,13 @@ var LspBridge = class LspBridge {
|
|
|
14059
14199
|
return relativeInside(this.realRoot, real);
|
|
14060
14200
|
}
|
|
14061
14201
|
/**
|
|
14062
|
-
*
|
|
14063
|
-
*
|
|
14064
|
-
*
|
|
14065
|
-
*
|
|
14066
|
-
*
|
|
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.
|
|
14067
14207
|
*/
|
|
14068
|
-
async
|
|
14208
|
+
async locations(locs) {
|
|
14069
14209
|
const uris = [...new Set(locs.map((l) => l.uri))];
|
|
14070
14210
|
const found = await mapLimit(uris, READ_CONCURRENCY, (uri) => this.locate(uri));
|
|
14071
14211
|
const byUri = new Map(uris.map((uri, i) => [uri, found[i]]));
|
|
@@ -14083,12 +14223,48 @@ var LspBridge = class LspBridge {
|
|
|
14083
14223
|
if (f.external) loc.external = true;
|
|
14084
14224
|
out.push(loc);
|
|
14085
14225
|
}
|
|
14086
|
-
return
|
|
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);
|
|
14087
14263
|
}
|
|
14088
14264
|
async locate(uri) {
|
|
14089
14265
|
const path = await this.pathOf(uri);
|
|
14090
14266
|
if (path != null) {
|
|
14091
|
-
const text = this.
|
|
14267
|
+
const text = this.opts.textOf(path) ?? await this.opts.read(path);
|
|
14092
14268
|
if (text != null) return {
|
|
14093
14269
|
path,
|
|
14094
14270
|
text,
|
|
@@ -14125,11 +14301,6 @@ function relativeInside(root, abs) {
|
|
|
14125
14301
|
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return null;
|
|
14126
14302
|
return rel.split("\\").join("/");
|
|
14127
14303
|
}
|
|
14128
|
-
/** How status messages name a server: `basedpyright-langserver --stdio` → `basedpyright-langserver`. */
|
|
14129
|
-
function programName(command) {
|
|
14130
|
-
const prog = argv0(command);
|
|
14131
|
-
return prog.split(/[\\/]/).pop() || prog;
|
|
14132
|
-
}
|
|
14133
14304
|
/**
|
|
14134
14305
|
* Canonical `p`, or `p` itself when it cannot be resolved. Uses libuv's
|
|
14135
14306
|
* realpath, the same one the async `realpath` in `pathOf` uses: the JS
|
|
@@ -14143,28 +14314,6 @@ function safeRealpathSync(p) {
|
|
|
14143
14314
|
return p;
|
|
14144
14315
|
}
|
|
14145
14316
|
}
|
|
14146
|
-
function toLsp(pos) {
|
|
14147
|
-
return {
|
|
14148
|
-
line: Math.max(0, pos.line - 1),
|
|
14149
|
-
character: Math.max(0, pos.col)
|
|
14150
|
-
};
|
|
14151
|
-
}
|
|
14152
|
-
function normalizeLocations(r) {
|
|
14153
|
-
if (!r) return [];
|
|
14154
|
-
return (Array.isArray(r) ? r : [r]).map((x) => "targetUri" in x ? {
|
|
14155
|
-
uri: x.targetUri,
|
|
14156
|
-
range: x.targetSelectionRange ?? x.targetRange
|
|
14157
|
-
} : x);
|
|
14158
|
-
}
|
|
14159
|
-
function isDocumentSymbols(r) {
|
|
14160
|
-
return r.length > 0 && !("location" in r[0]);
|
|
14161
|
-
}
|
|
14162
|
-
/**
|
|
14163
|
-
* `shell: true` runs the command under `sh -c`, so a plain kill would stop the
|
|
14164
|
-
* shell and orphan the language server. On POSIX the child leads its own process
|
|
14165
|
-
* group (`detached`), and shutdown signals the whole group.
|
|
14166
|
-
*/
|
|
14167
|
-
const DETACH = process.platform !== "win32";
|
|
14168
14317
|
function lineAt(text, line) {
|
|
14169
14318
|
let start = 0;
|
|
14170
14319
|
for (let i = 1; i < line; i++) {
|
|
@@ -14175,17 +14324,338 @@ function lineAt(text, line) {
|
|
|
14175
14324
|
const end = text.indexOf("\n", start);
|
|
14176
14325
|
return (end === -1 ? text.slice(start) : text.slice(start, end)).replace(/\r$/, "");
|
|
14177
14326
|
}
|
|
14178
|
-
|
|
14179
|
-
|
|
14180
|
-
|
|
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;
|
|
14181
14613
|
}
|
|
14182
|
-
/**
|
|
14183
|
-
|
|
14184
|
-
|
|
14185
|
-
|
|
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
|
+
};
|
|
14186
14628
|
}
|
|
14187
14629
|
//#endregion
|
|
14188
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
|
+
};
|
|
14189
14659
|
/**
|
|
14190
14660
|
* Built-in servers, in preference order per language: the first one whose program is
|
|
14191
14661
|
* on PATH is used. Entries serving several languages become one process, which is how
|
|
@@ -14217,22 +14687,34 @@ const CANDIDATES = [
|
|
|
14217
14687
|
command: "jedi-language-server"
|
|
14218
14688
|
},
|
|
14219
14689
|
{
|
|
14220
|
-
languages:
|
|
14221
|
-
|
|
14222
|
-
"typescriptreact",
|
|
14223
|
-
"javascript",
|
|
14224
|
-
"javascriptreact"
|
|
14225
|
-
],
|
|
14226
|
-
command: "typescript-language-server --stdio"
|
|
14690
|
+
languages: TYPESCRIPT,
|
|
14691
|
+
...TSSERVER
|
|
14227
14692
|
},
|
|
14228
14693
|
{
|
|
14229
|
-
languages:
|
|
14230
|
-
|
|
14231
|
-
|
|
14232
|
-
|
|
14233
|
-
|
|
14234
|
-
|
|
14235
|
-
|
|
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
|
+
}
|
|
14236
14718
|
},
|
|
14237
14719
|
{
|
|
14238
14720
|
languages: ["rust"],
|
|
@@ -14258,6 +14740,18 @@ const CANDIDATES = [
|
|
|
14258
14740
|
languages: ["java"],
|
|
14259
14741
|
command: "jdtls"
|
|
14260
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
|
+
},
|
|
14261
14755
|
{
|
|
14262
14756
|
languages: ["lua"],
|
|
14263
14757
|
command: "lua-language-server"
|
|
@@ -14304,15 +14798,24 @@ const CANDIDATES = [
|
|
|
14304
14798
|
},
|
|
14305
14799
|
{
|
|
14306
14800
|
languages: ["json", "jsonc"],
|
|
14307
|
-
command: "vscode-json-language-server --stdio"
|
|
14801
|
+
command: "vscode-json-language-server --stdio",
|
|
14802
|
+
settings: async () => ({ json: { schemas: await loadSchemaCatalog() } })
|
|
14308
14803
|
},
|
|
14309
14804
|
{
|
|
14310
14805
|
languages: ["json", "jsonc"],
|
|
14311
|
-
command: "vscode-json-languageserver --stdio"
|
|
14806
|
+
command: "vscode-json-languageserver --stdio",
|
|
14807
|
+
settings: async () => ({ json: { schemas: await loadSchemaCatalog() } })
|
|
14312
14808
|
},
|
|
14313
14809
|
{
|
|
14314
14810
|
languages: ["yaml"],
|
|
14315
|
-
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
|
+
} })
|
|
14316
14819
|
},
|
|
14317
14820
|
{
|
|
14318
14821
|
languages: ["toml"],
|
|
@@ -14320,35 +14823,117 @@ const CANDIDATES = [
|
|
|
14320
14823
|
},
|
|
14321
14824
|
{
|
|
14322
14825
|
languages: ["toml"],
|
|
14323
|
-
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"
|
|
14324
14858
|
}
|
|
14325
14859
|
];
|
|
14326
|
-
/**
|
|
14327
|
-
|
|
14328
|
-
|
|
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
|
+
};
|
|
14329
14873
|
}
|
|
14330
14874
|
/**
|
|
14331
14875
|
* Which servers to start for `languages`. A config override replaces the candidate
|
|
14332
14876
|
* list for its language and is never probed: the user named a command, so a typo
|
|
14333
14877
|
* should surface as the server failing to start, not as silence. An override of `''`
|
|
14334
14878
|
* turns the language off. Languages resolving to the same command line share one
|
|
14335
|
-
* process. `
|
|
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.
|
|
14336
14886
|
*/
|
|
14337
14887
|
function resolveServers(languages, overrides = {}, lookup = (c) => findOnPath(c)) {
|
|
14338
|
-
const
|
|
14339
|
-
const
|
|
14340
|
-
let hit =
|
|
14341
|
-
if (hit
|
|
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));
|
|
14342
14892
|
return hit;
|
|
14343
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
|
+
};
|
|
14344
14912
|
const servers = /* @__PURE__ */ new Map();
|
|
14345
14913
|
const missing = [];
|
|
14346
|
-
|
|
14347
|
-
|
|
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
|
+
}
|
|
14348
14929
|
if (command == null || command === "") {
|
|
14349
|
-
missing.push({
|
|
14930
|
+
missing.push(why ? {
|
|
14350
14931
|
language,
|
|
14351
|
-
tried
|
|
14932
|
+
tried,
|
|
14933
|
+
why
|
|
14934
|
+
} : {
|
|
14935
|
+
language,
|
|
14936
|
+
tried
|
|
14352
14937
|
});
|
|
14353
14938
|
continue;
|
|
14354
14939
|
}
|
|
@@ -14356,9 +14941,21 @@ function resolveServers(languages, overrides = {}, lookup = (c) => findOnPath(c)
|
|
|
14356
14941
|
if (server) server.languages.push(language);
|
|
14357
14942
|
else servers.set(command, {
|
|
14358
14943
|
command,
|
|
14359
|
-
languages: [language]
|
|
14944
|
+
languages: [language],
|
|
14945
|
+
joins: []
|
|
14360
14946
|
});
|
|
14361
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
|
+
}
|
|
14362
14959
|
return {
|
|
14363
14960
|
servers: [...servers.values()],
|
|
14364
14961
|
missing
|
|
@@ -14367,21 +14964,31 @@ function resolveServers(languages, overrides = {}, lookup = (c) => findOnPath(c)
|
|
|
14367
14964
|
//#endregion
|
|
14368
14965
|
//#region src/server/lsp/LspPool.ts
|
|
14369
14966
|
/**
|
|
14370
|
-
*
|
|
14371
|
-
*
|
|
14372
|
-
*
|
|
14373
|
-
|
|
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`.
|
|
14374
14980
|
*/
|
|
14375
14981
|
var LspPool = class {
|
|
14376
14982
|
opts;
|
|
14377
14983
|
/** Servers by command line: languages that resolve to the same command share one process. */
|
|
14378
14984
|
entries = /* @__PURE__ */ new Map();
|
|
14379
|
-
byLanguage = /* @__PURE__ */ new Map();
|
|
14380
14985
|
missing = [];
|
|
14381
14986
|
/** Languages already resolved, served or not, so the PATH probe runs once each. */
|
|
14382
14987
|
resolved = /* @__PURE__ */ new Set();
|
|
14383
14988
|
closing = false;
|
|
14384
14989
|
closed;
|
|
14990
|
+
publishTimer = null;
|
|
14991
|
+
publishDue = false;
|
|
14385
14992
|
constructor(opts) {
|
|
14386
14993
|
this.opts = opts;
|
|
14387
14994
|
this.ensure(opts.preload ?? []);
|
|
@@ -14391,11 +14998,17 @@ var LspPool = class {
|
|
|
14391
14998
|
enabled: true,
|
|
14392
14999
|
servers: [...this.entries.values()].map((e) => ({
|
|
14393
15000
|
...e.bridge.status(),
|
|
14394
|
-
languages: [...e.languages]
|
|
15001
|
+
languages: [...e.languages],
|
|
15002
|
+
joins: [...e.joins],
|
|
15003
|
+
supports: e.bridge.features()
|
|
14395
15004
|
})),
|
|
14396
15005
|
missing: this.missing
|
|
14397
15006
|
};
|
|
14398
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
|
+
}
|
|
14399
15012
|
/** Reads an external file only if a running server named it. */
|
|
14400
15013
|
async readExternal(path) {
|
|
14401
15014
|
for (const { bridge } of this.entries.values()) {
|
|
@@ -14404,47 +15017,44 @@ var LspPool = class {
|
|
|
14404
15017
|
}
|
|
14405
15018
|
return null;
|
|
14406
15019
|
}
|
|
14407
|
-
|
|
14408
|
-
return this.
|
|
14409
|
-
}
|
|
14410
|
-
async typeDefinition(pos) {
|
|
14411
|
-
return this.bridgeFor(pos.path).typeDefinition(pos);
|
|
15020
|
+
definition(pos) {
|
|
15021
|
+
return this.locations(pos, (b) => b.definition(pos));
|
|
14412
15022
|
}
|
|
14413
|
-
|
|
14414
|
-
return this.
|
|
15023
|
+
typeDefinition(pos) {
|
|
15024
|
+
return this.locations(pos, (b) => b.typeDefinition(pos));
|
|
14415
15025
|
}
|
|
14416
|
-
|
|
14417
|
-
return this.
|
|
15026
|
+
references(pos) {
|
|
15027
|
+
return this.locations(pos, (b) => b.references(pos));
|
|
14418
15028
|
}
|
|
14419
|
-
|
|
14420
|
-
return this.
|
|
15029
|
+
hover(pos) {
|
|
15030
|
+
return this.fanOut(pos.path, (b) => b.hover(pos), mergeHovers, (r) => r.contents == null);
|
|
14421
15031
|
}
|
|
14422
15032
|
async occurrences(pos) {
|
|
14423
|
-
return this.
|
|
15033
|
+
return this.ownerFor(pos.path).occurrences(pos);
|
|
14424
15034
|
}
|
|
14425
15035
|
async documentSymbols(path) {
|
|
14426
|
-
return this.
|
|
15036
|
+
return this.ownerFor(path).documentSymbols(path);
|
|
14427
15037
|
}
|
|
14428
15038
|
/**
|
|
14429
|
-
* Symbols matching `query` from every running server, best `fuzzyRank` match first.
|
|
14430
|
-
*
|
|
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.
|
|
14431
15041
|
*/
|
|
14432
15042
|
async workspaceSymbols(query, limit = 200) {
|
|
15043
|
+
if (this.closing) throw new LspUnavailableError("the language servers are shutting down");
|
|
14433
15044
|
const bridges = [...this.entries.values()].map((e) => e.bridge);
|
|
14434
|
-
if (!bridges.length) throw new LspUnavailableError(
|
|
14435
|
-
|
|
14436
|
-
const out = results.flatMap((r) => r.status === "fulfilled" ? r.value : []);
|
|
14437
|
-
const failed = results.find((r) => r.status === "rejected");
|
|
14438
|
-
if (!out.length && failed) throw failed.reason;
|
|
14439
|
-
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);
|
|
14440
15047
|
}
|
|
14441
15048
|
/**
|
|
14442
|
-
* Keeps the diff's files open in
|
|
15049
|
+
* Keeps the diff's files open in every server for their language, starting one for each
|
|
14443
15050
|
* language the diff has newly grown. A server whose language left the diff stays up
|
|
14444
|
-
* 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.
|
|
14445
15054
|
*/
|
|
14446
|
-
track(
|
|
15055
|
+
track(snap) {
|
|
14447
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) : [];
|
|
14448
15058
|
const byLanguage = /* @__PURE__ */ new Map();
|
|
14449
15059
|
for (const path of paths) {
|
|
14450
15060
|
const language = languageOf(path);
|
|
@@ -14455,23 +15065,38 @@ var LspPool = class {
|
|
|
14455
15065
|
}
|
|
14456
15066
|
this.ensure(byLanguage.keys());
|
|
14457
15067
|
const entries = [...this.entries.values()];
|
|
14458
|
-
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(() => {});
|
|
14459
15069
|
}
|
|
14460
15070
|
/** Idempotent: the CLI's dispose can run beside a signal handler's. */
|
|
14461
15071
|
close() {
|
|
14462
15072
|
this.closing = true;
|
|
15073
|
+
if (this.publishTimer) clearTimeout(this.publishTimer);
|
|
15074
|
+
this.publishTimer = null;
|
|
14463
15075
|
return this.closed ??= Promise.all([...this.entries.values()].map((e) => e.bridge.close())).then(() => {});
|
|
14464
15076
|
}
|
|
14465
|
-
/**
|
|
14466
|
-
|
|
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) {
|
|
14467
15083
|
if (this.closing) throw new LspUnavailableError("the language servers are shutting down");
|
|
14468
15084
|
const language = languageOf(path);
|
|
14469
|
-
const
|
|
14470
|
-
|
|
14471
|
-
|
|
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);
|
|
14472
15096
|
}
|
|
14473
|
-
|
|
14474
|
-
|
|
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);
|
|
14475
15100
|
}
|
|
14476
15101
|
/** Resolves and starts servers for languages not seen before. Cheap and idempotent per language. */
|
|
14477
15102
|
ensure(languages) {
|
|
@@ -14482,19 +15107,24 @@ var LspPool = class {
|
|
|
14482
15107
|
const { servers, missing } = resolveServers(fresh, this.opts.overrides, this.opts.lookup);
|
|
14483
15108
|
this.missing.push(...missing);
|
|
14484
15109
|
for (const server of servers) {
|
|
14485
|
-
const entry = this.entries.get(server.command) ?? this.spawn(server.command
|
|
14486
|
-
for (const l of server.languages)
|
|
14487
|
-
|
|
14488
|
-
this.byLanguage.set(l, entry.bridge);
|
|
14489
|
-
}
|
|
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);
|
|
14490
15113
|
}
|
|
14491
15114
|
if (servers.length || missing.length) this.publish();
|
|
14492
15115
|
}
|
|
14493
|
-
spawn(command
|
|
15116
|
+
spawn(command) {
|
|
15117
|
+
const definition = definitionOf(command);
|
|
15118
|
+
const ctx = {
|
|
15119
|
+
root: this.opts.root,
|
|
15120
|
+
lookup: this.opts.lookup ?? findOnPath
|
|
15121
|
+
};
|
|
14494
15122
|
const entry = {
|
|
14495
|
-
|
|
15123
|
+
command,
|
|
15124
|
+
bridge: new LspBridge({
|
|
14496
15125
|
command,
|
|
14497
|
-
settings:
|
|
15126
|
+
settings: definition.settings ? Promise.resolve(definition.settings(ctx)) : void 0,
|
|
15127
|
+
initializationOptions: definition.initializationOptions?.(ctx),
|
|
14498
15128
|
root: this.opts.root,
|
|
14499
15129
|
read: this.opts.read,
|
|
14500
15130
|
has: this.opts.has,
|
|
@@ -14502,17 +15132,30 @@ var LspPool = class {
|
|
|
14502
15132
|
spawnProcess: this.opts.spawnProcess,
|
|
14503
15133
|
maxOpen: this.opts.maxOpen
|
|
14504
15134
|
}),
|
|
14505
|
-
languages: []
|
|
15135
|
+
languages: [],
|
|
15136
|
+
joins: []
|
|
14506
15137
|
};
|
|
14507
15138
|
this.entries.set(command, entry);
|
|
14508
15139
|
return entry;
|
|
14509
15140
|
}
|
|
14510
15141
|
publish() {
|
|
14511
|
-
if (
|
|
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();
|
|
14512
15155
|
}
|
|
14513
15156
|
};
|
|
14514
15157
|
//#endregion
|
|
14515
|
-
//#region src/server/revspec.ts
|
|
15158
|
+
//#region src/server/mode/revspec.ts
|
|
14516
15159
|
/** The uncommitted tree, accepted on either side of a comparison. */
|
|
14517
15160
|
const WORKTREE = "worktree";
|
|
14518
15161
|
/**
|
|
@@ -17926,7 +18569,7 @@ function imageType(buf) {
|
|
|
17926
18569
|
return null;
|
|
17927
18570
|
}
|
|
17928
18571
|
//#endregion
|
|
17929
|
-
//#region src/server/routes.ts
|
|
18572
|
+
//#region src/server/routes/body.ts
|
|
17930
18573
|
/** Invalid JSON is a request error; failures after parsing still propagate. */
|
|
17931
18574
|
async function readJson(c) {
|
|
17932
18575
|
try {
|
|
@@ -17935,110 +18578,10 @@ async function readJson(c) {
|
|
|
17935
18578
|
throw new HTTPException(400, { message: "invalid JSON body" });
|
|
17936
18579
|
}
|
|
17937
18580
|
}
|
|
17938
|
-
|
|
17939
|
-
|
|
17940
|
-
|
|
17941
|
-
|
|
17942
|
-
if (err instanceof HTTPException) return c.json({ error: err.message }, err.status);
|
|
17943
|
-
if (err instanceof ZodError) return c.json({ error: prettifyError(err) }, 400);
|
|
17944
|
-
if (err instanceof NotFoundError) return c.json({ error: "not found" }, 404);
|
|
17945
|
-
if (err instanceof RevspecError || err instanceof GitError) return c.json({ error: err.message }, 400);
|
|
17946
|
-
if (err instanceof ImportError || err instanceof UnquotableError) return c.json({ error: err.message }, 400);
|
|
17947
|
-
if (err instanceof LspUnavailableError) return c.json({ error: err.message }, 409);
|
|
17948
|
-
if (err instanceof GithubError) return c.json({ error: err.message }, err.status);
|
|
17949
|
-
console.error(err);
|
|
17950
|
-
return c.json({ error: err.message }, 500);
|
|
17951
|
-
});
|
|
17952
|
-
app.use("/api/*", async (c, next) => {
|
|
17953
|
-
if (c.req.path !== "/api/config") await session.ready();
|
|
17954
|
-
await next();
|
|
17955
|
-
});
|
|
17956
|
-
app.get("/api/snapshot", async (c) => c.json(await session.snapshotter.current()));
|
|
17957
|
-
app.get("/api/moved", (c) => c.json(session.moved));
|
|
17958
|
-
app.post("/api/reload", async (c) => c.json(await session.reload()));
|
|
17959
|
-
app.delete("/api/iterations", async (c) => c.json(await session.clearIterations()));
|
|
17960
|
-
app.delete("/api/iterations/:n", async (c) => {
|
|
17961
|
-
const n = Number(c.req.param("n"));
|
|
17962
|
-
if (!Number.isInteger(n) || n < 1) throw new RevspecError(`not an iteration number: ${c.req.param("n")}`);
|
|
17963
|
-
return c.json(await session.deleteIteration(n));
|
|
17964
|
-
});
|
|
17965
|
-
app.post("/api/mode", async (c) => {
|
|
17966
|
-
const req = ModeRequestSchema.parse(await readJson(c));
|
|
17967
|
-
return c.json(await session.switchMode(req));
|
|
17968
|
-
});
|
|
17969
|
-
app.get("/api/github", async (c) => {
|
|
17970
|
-
const snap = await session.snapshotter.current();
|
|
17971
|
-
return c.json(await session.github(snap));
|
|
17972
|
-
});
|
|
17973
|
-
app.get("/api/refs", async (c) => c.json(await session.repo.refs()));
|
|
17974
|
-
app.get("/api/last-commits-preview", async (c) => {
|
|
17975
|
-
const { oldOffset, newOffset } = LastCommitsQuerySchema.parse(c.req.query());
|
|
17976
|
-
return c.json(await session.repo.lastCommitsPreview(oldOffset, newOffset));
|
|
17977
|
-
});
|
|
17978
|
-
app.get("/api/patch", async (c) => {
|
|
17979
|
-
const { path } = PatchQuerySchema.parse(c.req.query());
|
|
17980
|
-
if (!path) return c.text(await session.snapshotter.patchAll());
|
|
17981
|
-
const patch = await session.snapshotter.patch(path);
|
|
17982
|
-
if (patch == null) return c.json({ error: "not a changed file" }, 404);
|
|
17983
|
-
return c.text(patch);
|
|
17984
|
-
});
|
|
17985
|
-
app.post("/api/patch", async (c) => {
|
|
17986
|
-
const { paths } = PatchRequestSchema.parse(await readJson(c));
|
|
17987
|
-
return c.text(await session.snapshotter.patchMany(paths));
|
|
17988
|
-
});
|
|
17989
|
-
app.get("/api/file", async (c) => {
|
|
17990
|
-
const { path, rev } = FileQuerySchema.parse(c.req.query());
|
|
17991
|
-
const snap = await session.snapshotter.current();
|
|
17992
|
-
const buf = await session.readSide(snap, path, rev) ?? (rev === "new" ? await deps.lsp?.readExternal(path) : null);
|
|
17993
|
-
if (buf == null) return c.json({ error: "absent on that side" }, 404);
|
|
17994
|
-
const binary = isBinary(buf);
|
|
17995
|
-
const body = {
|
|
17996
|
-
path,
|
|
17997
|
-
contents: binary ? "" : buf.toString("utf8"),
|
|
17998
|
-
binary
|
|
17999
|
-
};
|
|
18000
|
-
return c.json(body);
|
|
18001
|
-
});
|
|
18002
|
-
app.get("/api/image", async (c) => {
|
|
18003
|
-
const { path, rev, key } = ImageQuerySchema.parse(c.req.query());
|
|
18004
|
-
const buf = await session.readImage(await session.snapshotter.current(), path, rev, key);
|
|
18005
|
-
const type = buf && imageType(buf);
|
|
18006
|
-
if (!type) return c.json({ error: "no image under that key" }, 404);
|
|
18007
|
-
return c.body(new Uint8Array(buf), 200, {
|
|
18008
|
-
"content-type": type,
|
|
18009
|
-
"cache-control": "private, max-age=31536000, immutable",
|
|
18010
|
-
"x-content-type-options": "nosniff",
|
|
18011
|
-
"cross-origin-resource-policy": "same-origin"
|
|
18012
|
-
});
|
|
18013
|
-
});
|
|
18014
|
-
app.get("/api/search", async (c) => {
|
|
18015
|
-
const query = SearchQuerySchema.parse(c.req.query());
|
|
18016
|
-
const { q, scope } = query;
|
|
18017
|
-
const snap = await session.snapshotter.current();
|
|
18018
|
-
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);
|
|
18019
|
-
let ranges;
|
|
18020
|
-
if (query.content === "diff") {
|
|
18021
|
-
const entries = await mapLimit(snap.changed.filter((f) => f.status !== "D" && !f.binary && (!paths || paths.includes(f.path))), 8, async (file) => {
|
|
18022
|
-
const patch = await session.repo.patch(snap.oldSha, snap.newSha, file, snap.context);
|
|
18023
|
-
return [file.path, shownRanges(patch, "new")];
|
|
18024
|
-
});
|
|
18025
|
-
ranges = new Map(entries);
|
|
18026
|
-
paths = entries.filter(([, spans]) => spans.length > 0).map(([path]) => path);
|
|
18027
|
-
}
|
|
18028
|
-
const { matches, truncated } = await session.repo.grep(q, snap.newSha, 500, {
|
|
18029
|
-
word: query.word,
|
|
18030
|
-
ignoreCase: query.i,
|
|
18031
|
-
regex: query.re,
|
|
18032
|
-
paths,
|
|
18033
|
-
ranges
|
|
18034
|
-
});
|
|
18035
|
-
const body = {
|
|
18036
|
-
query: q,
|
|
18037
|
-
matches,
|
|
18038
|
-
truncated
|
|
18039
|
-
};
|
|
18040
|
-
return c.json(body);
|
|
18041
|
-
});
|
|
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 }) {
|
|
18042
18585
|
app.get("/api/threads", (c) => {
|
|
18043
18586
|
const q = ThreadQuerySchema.parse(c.req.query());
|
|
18044
18587
|
q.state ??= "all";
|
|
@@ -18118,57 +18661,202 @@ function createApi(deps) {
|
|
|
18118
18661
|
hub.broadcast({ type: "viewed" });
|
|
18119
18662
|
return c.json(list);
|
|
18120
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;
|
|
18121
18670
|
app.get("/api/lsp/status", (c) => c.json(lspStatus(deps)));
|
|
18122
|
-
|
|
18123
|
-
|
|
18124
|
-
|
|
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 }) => {
|
|
18125
18683
|
const snap = await session.snapshotter.current();
|
|
18126
|
-
|
|
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" };
|
|
18127
18690
|
if (path != null && !snap.tree.includes(path)) return { error: `${path} is not in the snapshot` };
|
|
18128
18691
|
return { lsp: deps.lsp };
|
|
18129
18692
|
};
|
|
18130
18693
|
app.post("/api/lsp/definition", async (c) => {
|
|
18131
18694
|
const pos = LspPositionSchema.parse(await readJson(c));
|
|
18132
|
-
const r = await lspFor(
|
|
18695
|
+
const r = await lspFor({
|
|
18696
|
+
path: pos.path,
|
|
18697
|
+
feature: "definition"
|
|
18698
|
+
});
|
|
18133
18699
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18134
18700
|
return c.json(await r.lsp.definition(pos));
|
|
18135
18701
|
});
|
|
18136
18702
|
app.post("/api/lsp/type-definition", async (c) => {
|
|
18137
18703
|
const pos = LspPositionSchema.parse(await readJson(c));
|
|
18138
|
-
const r = await lspFor(
|
|
18704
|
+
const r = await lspFor({
|
|
18705
|
+
path: pos.path,
|
|
18706
|
+
feature: "typeDefinition"
|
|
18707
|
+
});
|
|
18139
18708
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18140
18709
|
return c.json(await r.lsp.typeDefinition(pos));
|
|
18141
18710
|
});
|
|
18142
18711
|
app.post("/api/lsp/hover", async (c) => {
|
|
18143
18712
|
const pos = LspPositionSchema.parse(await readJson(c));
|
|
18144
|
-
const r = await lspFor(
|
|
18713
|
+
const r = await lspFor({
|
|
18714
|
+
path: pos.path,
|
|
18715
|
+
feature: "hover"
|
|
18716
|
+
});
|
|
18145
18717
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18146
18718
|
return c.json(await r.lsp.hover(pos));
|
|
18147
18719
|
});
|
|
18148
|
-
app.post("/api/lsp/token-kind", async (c) => {
|
|
18149
|
-
const pos = LspPositionSchema.parse(await readJson(c));
|
|
18150
|
-
const r = await lspFor(pos.path);
|
|
18151
|
-
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18152
|
-
return c.json(await r.lsp.tokenKind(pos));
|
|
18153
|
-
});
|
|
18154
18720
|
app.post("/api/lsp/references", async (c) => {
|
|
18155
18721
|
const pos = LspPositionSchema.parse(await readJson(c));
|
|
18156
|
-
const r = await lspFor(
|
|
18722
|
+
const r = await lspFor({
|
|
18723
|
+
path: pos.path,
|
|
18724
|
+
feature: "references"
|
|
18725
|
+
});
|
|
18157
18726
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18158
18727
|
return c.json(await r.lsp.references(pos));
|
|
18159
18728
|
});
|
|
18160
18729
|
app.post("/api/lsp/occurrences", async (c) => {
|
|
18161
18730
|
const pos = LspPositionSchema.parse(await readJson(c));
|
|
18162
|
-
const r = await lspFor(pos.path);
|
|
18731
|
+
const r = await lspFor({ path: pos.path });
|
|
18163
18732
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18164
18733
|
return c.json(await r.lsp.occurrences(pos));
|
|
18165
18734
|
});
|
|
18166
18735
|
app.get("/api/lsp/symbols", async (c) => {
|
|
18167
18736
|
const { path, q: query } = SymbolsQuerySchema.parse(c.req.query());
|
|
18168
|
-
const r = await lspFor(
|
|
18737
|
+
const r = await lspFor({
|
|
18738
|
+
path,
|
|
18739
|
+
feature: path != null ? "documentSymbol" : "workspaceSymbol"
|
|
18740
|
+
});
|
|
18169
18741
|
if ("error" in r) return c.json({ error: r.error }, 409);
|
|
18170
18742
|
return c.json(path != null ? await r.lsp.documentSymbols(path) : await r.lsp.workspaceSymbols(query ?? ""));
|
|
18171
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);
|
|
18172
18860
|
app.get("/api/config", (c) => c.json(effectiveConfig(deps)));
|
|
18173
18861
|
app.put("/api/config", async (c) => {
|
|
18174
18862
|
const body = ConfigUpdateSchema.parse(await readJson(c));
|
|
@@ -18186,14 +18874,7 @@ function effectiveConfig(deps) {
|
|
|
18186
18874
|
return {
|
|
18187
18875
|
...c,
|
|
18188
18876
|
autoViewed: [.../* @__PURE__ */ new Set([...c.autoViewed, ...deps.extraAutoViewed])],
|
|
18189
|
-
contextLines: deps.session.context
|
|
18190
|
-
};
|
|
18191
|
-
}
|
|
18192
|
-
function lspStatus(deps) {
|
|
18193
|
-
return deps.lsp?.status() ?? {
|
|
18194
|
-
enabled: false,
|
|
18195
|
-
servers: [],
|
|
18196
|
-
missing: []
|
|
18877
|
+
contextLines: deps.session.context
|
|
18197
18878
|
};
|
|
18198
18879
|
}
|
|
18199
18880
|
//#endregion
|
|
@@ -18496,7 +19177,7 @@ function parsePullRequest(value) {
|
|
|
18496
19177
|
};
|
|
18497
19178
|
}
|
|
18498
19179
|
//#endregion
|
|
18499
|
-
//#region src/server/
|
|
19180
|
+
//#region src/server/github/metadata.ts
|
|
18500
19181
|
const LOOKUP_TIMEOUT_MS = 5e3;
|
|
18501
19182
|
/**
|
|
18502
19183
|
* Enriches a comparison without fetching commits, changing its endpoints, or changing comment
|
|
@@ -18562,10 +19243,142 @@ async function discoverGithub(repo, snap, { github, prUrl } = {}) {
|
|
|
18562
19243
|
}
|
|
18563
19244
|
}
|
|
18564
19245
|
//#endregion
|
|
18565
|
-
//#region src/server/github/
|
|
19246
|
+
//#region src/server/github/pendingReview.ts
|
|
18566
19247
|
function isFileComment(c) {
|
|
18567
19248
|
return "subjectType" in c;
|
|
18568
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
|
|
18569
19382
|
/**
|
|
18570
19383
|
* Threads → the comments of one pending GitHub review, one per thread. Stale threads are skipped:
|
|
18571
19384
|
* their lines no longer sit in the diff, and GitHub would refuse them anyway. With
|
|
@@ -18701,10 +19514,7 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, githu
|
|
|
18701
19514
|
});
|
|
18702
19515
|
continue;
|
|
18703
19516
|
}
|
|
18704
|
-
await github.
|
|
18705
|
-
pullRequestReviewCommentId: hit.nodeId,
|
|
18706
|
-
body: c.body
|
|
18707
|
-
} });
|
|
19517
|
+
await updateComment(github, hit.nodeId, c.body);
|
|
18708
19518
|
updated++;
|
|
18709
19519
|
}
|
|
18710
19520
|
for (const c of add) {
|
|
@@ -18727,129 +19537,6 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, githu
|
|
|
18727
19537
|
function threadMarker(id) {
|
|
18728
19538
|
return `<!-- diffle-thread:${encodeURIComponent(id)} -->`;
|
|
18729
19539
|
}
|
|
18730
|
-
/** The anchor must still match before we rewrite an exported thread. */
|
|
18731
|
-
function anchorKey(c) {
|
|
18732
|
-
if (isFileComment(c)) return [c.path, "FILE"].join("\0");
|
|
18733
|
-
return [
|
|
18734
|
-
c.path,
|
|
18735
|
-
c.side,
|
|
18736
|
-
c.startLine ?? c.line,
|
|
18737
|
-
c.line
|
|
18738
|
-
].join("\0");
|
|
18739
|
-
}
|
|
18740
|
-
const PENDING_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
|
|
18741
|
-
viewer{login}
|
|
18742
|
-
repository(owner:$owner,name:$repo){pullRequest(number:$number){id reviews(last:20,states:[PENDING]){nodes{id author{login}}}}}
|
|
18743
|
-
}`;
|
|
18744
|
-
/**
|
|
18745
|
-
* The PR's node id and the viewer's own pending review on it, or null. GitHub hides other
|
|
18746
|
-
* people's pending reviews, and the login check makes sure of it: appending to someone
|
|
18747
|
-
* else's draft would put our comments in their review.
|
|
18748
|
-
*/
|
|
18749
|
-
async function findPendingReview(github, pr) {
|
|
18750
|
-
const { owner, repo } = repoOfPrUrl(pr.url);
|
|
18751
|
-
const data = await github.graphql(PENDING_QUERY, {
|
|
18752
|
-
owner,
|
|
18753
|
-
repo,
|
|
18754
|
-
number: pr.number
|
|
18755
|
-
});
|
|
18756
|
-
const pullRequestId = data.repository?.pullRequest?.id;
|
|
18757
|
-
if (typeof pullRequestId !== "string") throw new GithubError(`cannot read pull request ${pr.url} from GitHub`, 502);
|
|
18758
|
-
const login = data.viewer?.login;
|
|
18759
|
-
for (const node of data.repository?.pullRequest?.reviews?.nodes ?? []) if (node?.id && node.author?.login === login) return {
|
|
18760
|
-
pullRequestId,
|
|
18761
|
-
pending: { id: node.id }
|
|
18762
|
-
};
|
|
18763
|
-
return {
|
|
18764
|
-
pullRequestId,
|
|
18765
|
-
pending: null
|
|
18766
|
-
};
|
|
18767
|
-
}
|
|
18768
|
-
const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
|
|
18769
|
-
repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
|
|
18770
|
-
pageInfo{hasNextPage endCursor}
|
|
18771
|
-
nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
|
|
18772
|
-
}}}
|
|
18773
|
-
}`;
|
|
18774
|
-
/**
|
|
18775
|
-
* The pending review's own comments, by anchor. It has to be `reviewThreads`: it is the only view
|
|
18776
|
-
* that reports a *pending* comment's real position — REST's review-comment list leaves `line`,
|
|
18777
|
-
* `side` and `start_line` null for one, and the GraphQL comment type has no side field at all.
|
|
18778
|
-
* A failed or incomplete lookup aborts the export: absence is safe to infer only from a complete read.
|
|
18779
|
-
*/
|
|
18780
|
-
async function pendingComments(github, pr, reviewId) {
|
|
18781
|
-
const by = /* @__PURE__ */ new Map();
|
|
18782
|
-
const { owner, repo } = repoOfPrUrl(pr.url);
|
|
18783
|
-
let after = null;
|
|
18784
|
-
for (let page = 0; page < 10; page++) {
|
|
18785
|
-
const threads = (await github.graphql(THREADS_QUERY, {
|
|
18786
|
-
owner,
|
|
18787
|
-
repo,
|
|
18788
|
-
number: pr.number,
|
|
18789
|
-
after
|
|
18790
|
-
})).repository?.pullRequest?.reviewThreads;
|
|
18791
|
-
if (!threads?.nodes || typeof threads.pageInfo?.hasNextPage !== "boolean") throw new GithubError("cannot read the complete pending review", 502);
|
|
18792
|
-
for (const t of threads.nodes) {
|
|
18793
|
-
const c = t?.comments?.nodes?.[0];
|
|
18794
|
-
if (!t?.path || !c?.id || c.pullRequestReview?.id !== reviewId) continue;
|
|
18795
|
-
const key = pendingKey(t);
|
|
18796
|
-
if (key == null) continue;
|
|
18797
|
-
const at = by.get(key);
|
|
18798
|
-
if (at) at.push({
|
|
18799
|
-
nodeId: c.id,
|
|
18800
|
-
body: c.body ?? ""
|
|
18801
|
-
});
|
|
18802
|
-
else by.set(key, [{
|
|
18803
|
-
nodeId: c.id,
|
|
18804
|
-
body: c.body ?? ""
|
|
18805
|
-
}]);
|
|
18806
|
-
}
|
|
18807
|
-
if (!threads.pageInfo.hasNextPage) return by;
|
|
18808
|
-
if (!threads.pageInfo.endCursor || threads.pageInfo.endCursor === after) throw new GithubError("cannot read the next page of the pending review", 502);
|
|
18809
|
-
after = threads.pageInfo.endCursor;
|
|
18810
|
-
}
|
|
18811
|
-
throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
|
|
18812
|
-
}
|
|
18813
|
-
/** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
|
|
18814
|
-
function pendingKey(t) {
|
|
18815
|
-
const path = t.path;
|
|
18816
|
-
if (t.subjectType === "FILE") return anchorKey({
|
|
18817
|
-
path,
|
|
18818
|
-
subjectType: "FILE",
|
|
18819
|
-
body: ""
|
|
18820
|
-
});
|
|
18821
|
-
if (t.line == null) return null;
|
|
18822
|
-
return anchorKey({
|
|
18823
|
-
path,
|
|
18824
|
-
side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
|
|
18825
|
-
line: t.line,
|
|
18826
|
-
startLine: t.startLine ?? void 0,
|
|
18827
|
-
body: ""
|
|
18828
|
-
});
|
|
18829
|
-
}
|
|
18830
|
-
const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
|
|
18831
|
-
const CREATE_REVIEW = `mutation($input:AddPullRequestReviewInput!){addPullRequestReview(input:$input){pullRequestReview{id}}}`;
|
|
18832
|
-
/**
|
|
18833
|
-
* No `event` in the input, so GitHub keeps the new review pending with all of its threads.
|
|
18834
|
-
* Resolves the review's node id, for the comments a new review cannot carry.
|
|
18835
|
-
*/
|
|
18836
|
-
async function createPendingReview(github, pullRequestId, commitOID, threads) {
|
|
18837
|
-
const id = (await github.graphql(CREATE_REVIEW, { input: {
|
|
18838
|
-
pullRequestId,
|
|
18839
|
-
commitOID,
|
|
18840
|
-
threads
|
|
18841
|
-
} })).addPullRequestReview?.pullRequestReview?.id;
|
|
18842
|
-
if (typeof id !== "string") throw new GithubError("GitHub did not return the created review", 502);
|
|
18843
|
-
return { id };
|
|
18844
|
-
}
|
|
18845
|
-
const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
|
|
18846
|
-
/** Appends one thread to the pending review; the only way to extend one, or to place a file comment. */
|
|
18847
|
-
async function addToPendingReview(github, reviewId, c) {
|
|
18848
|
-
await github.graphql(ADD_THREAD, { input: {
|
|
18849
|
-
pullRequestReviewId: reviewId,
|
|
18850
|
-
...c
|
|
18851
|
-
} });
|
|
18852
|
-
}
|
|
18853
19540
|
function describe(skipped) {
|
|
18854
19541
|
const counts = /* @__PURE__ */ new Map();
|
|
18855
19542
|
for (const s of skipped) counts.set(s.reason, (counts.get(s.reason) ?? 0) + 1);
|
|
@@ -18952,7 +19639,7 @@ async function read(file) {
|
|
|
18952
19639
|
}
|
|
18953
19640
|
}
|
|
18954
19641
|
//#endregion
|
|
18955
|
-
//#region src/server/rangediff.ts
|
|
19642
|
+
//#region src/server/mode/rangediff.ts
|
|
18956
19643
|
/**
|
|
18957
19644
|
* The mode that compares `older` with `newer`: `older`'s head replayed onto `newer`'s base against
|
|
18958
19645
|
* `newer`'s head, so only what the series itself changed in between shows, with the two ranges'
|
|
@@ -19013,7 +19700,7 @@ async function pairRanges(repo, older, newer) {
|
|
|
19013
19700
|
}));
|
|
19014
19701
|
}
|
|
19015
19702
|
//#endregion
|
|
19016
|
-
//#region src/server/mode.ts
|
|
19703
|
+
//#region src/server/mode/resolve.ts
|
|
19017
19704
|
/**
|
|
19018
19705
|
* Resolves a comparison to Git endpoints for initial mode setup, PR setup after
|
|
19019
19706
|
* fetching, and snapshot refreshes. Revisions become full hashes; "worktree"
|
|
@@ -24064,7 +24751,8 @@ program.command("lsp").description("show the language server each language would
|
|
|
24064
24751
|
const { servers, missing } = resolveServers(LANGUAGE_IDS, lspCommands);
|
|
24065
24752
|
const answer = /* @__PURE__ */ new Map();
|
|
24066
24753
|
for (const s of servers) for (const language of s.languages) answer.set(language, `${s.command}${lspCommands[language] == null ? "" : c.dim(" (config)")}`);
|
|
24067
|
-
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}`);
|
|
24068
24756
|
const width = Math.max(...LANGUAGE_IDS.map((l) => l.length));
|
|
24069
24757
|
for (const language of LANGUAGE_IDS) console.log(`${language.padEnd(width)} ${answer.get(language) ?? ""}`);
|
|
24070
24758
|
});
|
|
@@ -24181,7 +24869,7 @@ async function serve(req, opts, repo, closeRepo, config, github, timing) {
|
|
|
24181
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)")}`);
|
|
24182
24870
|
if (lsp) {
|
|
24183
24871
|
const { servers, missing } = lsp.status();
|
|
24184
|
-
const names = servers.map((s) => `${s.name} ${c.dim(`(${s
|
|
24872
|
+
const names = servers.map((s) => `${s.name} ${c.dim(`(${languagesLabel(s)})`)}`).join(", ");
|
|
24185
24873
|
if (!followsCheckout(snap)) console.error(`🧭 ${c.dim("no language server: symbol navigation needs the new side to be the checkout")}`);
|
|
24186
24874
|
else console.error(`🧭 ${c.dim("lsp")} ${names || c.dim("no language server for this diff")}`);
|
|
24187
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(", ")})`)}`);
|
|
@@ -24207,17 +24895,11 @@ async function connectGithub() {
|
|
|
24207
24895
|
}
|
|
24208
24896
|
/**
|
|
24209
24897
|
* The pool reads new-side text through the session, so the snapshot allowlist applies to
|
|
24210
|
-
* every LSP path. Each snapshot decides which languages run
|
|
24211
|
-
*
|
|
24212
|
-
* (see LspPool.track). Nothing starts when the new side is not the checkout: the client
|
|
24213
|
-
* refuses symbol navigation there, and a server would otherwise see text that contradicts
|
|
24214
|
-
* 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).
|
|
24215
24900
|
*/
|
|
24216
24901
|
function startLsp(repo, session, hub, overrides, preload) {
|
|
24217
|
-
session.onSnapshot((snap) =>
|
|
24218
|
-
const paths = followsCheckout(snap) ? snap.changed.filter((f) => f.status !== "D" && !f.binary).map((f) => f.path) : [];
|
|
24219
|
-
lsp.track(paths);
|
|
24220
|
-
});
|
|
24902
|
+
session.onSnapshot((snap) => void lsp.track(snap));
|
|
24221
24903
|
const reported = /* @__PURE__ */ new Map();
|
|
24222
24904
|
const lsp = new LspPool({
|
|
24223
24905
|
root: repo.root,
|