pi-x-ide 1.4.0 → 1.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +183 -114
  2. package/README.zh.md +183 -114
  3. package/dist/package.json +10 -4
  4. package/dist/src/nvim/sidecar-schema.d.ts +25 -0
  5. package/dist/src/nvim/sidecar-schema.js +64 -0
  6. package/dist/src/nvim/sidecar-schema.js.map +1 -0
  7. package/dist/src/nvim/sidecar.d.ts +16 -0
  8. package/dist/src/nvim/sidecar.js +173 -0
  9. package/dist/src/nvim/sidecar.js.map +1 -0
  10. package/dist/src/pi/index.js +10 -2
  11. package/dist/src/pi/index.js.map +1 -1
  12. package/dist/src/pi/install.js +22 -1
  13. package/dist/src/pi/install.js.map +1 -1
  14. package/dist/src/shared/ide-server.d.ts +20 -0
  15. package/dist/src/shared/ide-server.js +144 -0
  16. package/dist/src/shared/ide-server.js.map +1 -0
  17. package/dist/src/shared/lock-file.d.ts +16 -0
  18. package/dist/src/shared/lock-file.js +58 -0
  19. package/dist/src/shared/lock-file.js.map +1 -0
  20. package/dist/src/shared/paths.js +8 -1
  21. package/dist/src/shared/paths.js.map +1 -1
  22. package/dist/src/shared/protocol.d.ts +1 -1
  23. package/dist/src/shared/schema.js +1 -1
  24. package/dist/src/shared/schema.js.map +1 -1
  25. package/dist/test/nvim-sidecar.test.d.ts +1 -0
  26. package/dist/test/nvim-sidecar.test.js +148 -0
  27. package/dist/test/nvim-sidecar.test.js.map +1 -0
  28. package/dist/test/shared.test.js +10 -0
  29. package/dist/test/shared.test.js.map +1 -1
  30. package/nvim/bin/pi-x-ide-nvim-sidecar.cjs +21 -0
  31. package/nvim/doc/pi-x-ide.txt +112 -0
  32. package/nvim/lua/pi_x_ide/init.lua +442 -0
  33. package/nvim/plugin/pi-x-ide.lua +15 -0
  34. package/package.json +10 -4
  35. package/src/nvim/sidecar-schema.ts +71 -0
  36. package/src/nvim/sidecar.ts +219 -0
  37. package/src/pi/index.ts +12 -2
  38. package/src/pi/install.ts +24 -1
  39. package/src/shared/ide-server.ts +120 -0
  40. package/src/shared/lock-file.ts +65 -0
  41. package/src/shared/paths.ts +8 -1
  42. package/src/shared/protocol.ts +1 -1
  43. package/src/shared/schema.ts +1 -1
@@ -0,0 +1,442 @@
1
+ local M = {}
2
+
3
+ local state = {
4
+ config = nil,
5
+ job_id = nil,
6
+ timer = nil,
7
+ augroup = nil,
8
+ latest_snapshot = nil,
9
+ visual_mark_valid = false,
10
+ }
11
+
12
+ local defaults = {
13
+ enabled = true,
14
+ sidecar_cmd = nil,
15
+ debounce_ms = 150,
16
+ range_format = "comma",
17
+ keymap = nil,
18
+ workspace_folders = nil,
19
+ }
20
+
21
+ local function notify(message, level)
22
+ vim.notify("Pi x IDE: " .. message, level or vim.log.levels.INFO)
23
+ end
24
+
25
+ local function plugin_root()
26
+ local source = debug.getinfo(1, "S").source
27
+ if source:sub(1, 1) == "@" then
28
+ source = source:sub(2)
29
+ end
30
+ return vim.fn.fnamemodify(source, ":p:h:h:h")
31
+ end
32
+
33
+ local function default_sidecar_cmd()
34
+ return { "node", plugin_root() .. "/bin/pi-x-ide-nvim-sidecar.cjs" }
35
+ end
36
+
37
+ local function encode(value)
38
+ return vim.json and vim.json.encode(value) or vim.fn.json_encode(value)
39
+ end
40
+
41
+ local function send(value)
42
+ if not state.job_id then
43
+ return false
44
+ end
45
+ local ok = vim.fn.chansend(state.job_id, encode(value) .. "\n")
46
+ return ok == 1
47
+ end
48
+
49
+ local function normalize_config(opts)
50
+ local config = vim.tbl_deep_extend("force", defaults, opts or {})
51
+ if config.range_format ~= "dash" then
52
+ config.range_format = "comma"
53
+ end
54
+ return config
55
+ end
56
+
57
+ local function workspace_folders()
58
+ local configured = state.config and state.config.workspace_folders
59
+ if type(configured) == "table" and #configured > 0 then
60
+ return configured
61
+ end
62
+
63
+ local folders = { vim.fn.getcwd() }
64
+ for _, client in pairs(vim.lsp.get_clients and vim.lsp.get_clients() or {}) do
65
+ for _, folder in ipairs(client.workspace_folders or {}) do
66
+ if folder.name and folder.name ~= "" then
67
+ table.insert(folders, folder.name)
68
+ elseif folder.uri then
69
+ local path = vim.uri_to_fname(folder.uri)
70
+ if path and path ~= "" then
71
+ table.insert(folders, path)
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ local seen = {}
78
+ local result = {}
79
+ for _, folder in ipairs(folders) do
80
+ local normalized = vim.fn.fnamemodify(folder, ":p"):gsub("/$", "")
81
+ if normalized ~= "" and not seen[normalized] then
82
+ seen[normalized] = true
83
+ table.insert(result, normalized)
84
+ end
85
+ end
86
+ return result
87
+ end
88
+
89
+ local function is_path_inside(parent, child)
90
+ parent = vim.fn.fnamemodify(parent, ":p"):gsub("/$", "")
91
+ child = vim.fn.fnamemodify(child, ":p")
92
+ if child:gsub("/$", "") == parent then
93
+ return true
94
+ end
95
+ return child:sub(1, #parent + 1) == parent .. "/"
96
+ end
97
+
98
+ local function best_workspace(file_path)
99
+ local best = nil
100
+ for _, folder in ipairs(workspace_folders()) do
101
+ if is_path_inside(folder, file_path) and (not best or #folder > #best) then
102
+ best = folder
103
+ end
104
+ end
105
+ return best or workspace_folders()[1]
106
+ end
107
+
108
+ local function byte_to_utf16(line, byte_col)
109
+ return vim.str_utfindex(line or "", "utf-16", math.max(0, byte_col), false)
110
+ end
111
+
112
+ local function byte_after_char(line, byte_col)
113
+ line = line or ""
114
+ if byte_col >= #line then
115
+ return #line
116
+ end
117
+ local utf16 = byte_to_utf16(line, byte_col)
118
+ return math.min(#line, vim.str_byteindex(line, "utf-16", utf16 + 1, false))
119
+ end
120
+
121
+ local function compare_positions(a, b)
122
+ if a.line ~= b.line then
123
+ return a.line < b.line
124
+ end
125
+ return a.col <= b.col
126
+ end
127
+
128
+ local function sorted_positions(a, b)
129
+ if compare_positions(a, b) then
130
+ return a, b
131
+ end
132
+ return b, a
133
+ end
134
+
135
+ local function get_lines(buf, start_row, end_row)
136
+ return vim.api.nvim_buf_get_lines(buf, start_row, end_row + 1, false)
137
+ end
138
+
139
+ local function text_from_range(buf, start_row, start_col, end_row, end_col)
140
+ return table.concat(vim.api.nvim_buf_get_text(buf, start_row, start_col, end_row, end_col, {}), "\n")
141
+ end
142
+
143
+ local function range_from_bytes(buf, start_row, start_col, end_row, end_col)
144
+ local lines = get_lines(buf, start_row, end_row)
145
+ local start_line = lines[1] or ""
146
+ local end_line = lines[#lines] or ""
147
+ return {
148
+ text = text_from_range(buf, start_row, start_col, end_row, end_col),
149
+ selection = {
150
+ start = { line = start_row, character = byte_to_utf16(start_line, start_col) },
151
+ ["end"] = { line = end_row, character = byte_to_utf16(end_line, end_col) },
152
+ },
153
+ }
154
+ end
155
+
156
+ local function pos_from_getpos(pos)
157
+ return { line = math.max(0, pos[2] - 1), col = math.max(0, pos[3] - 1), bufnr = pos[1] }
158
+ end
159
+
160
+ local function active_visual_range(buf, mode)
161
+ local start_pos = pos_from_getpos(vim.fn.getpos("v"))
162
+ local end_pos = pos_from_getpos(vim.fn.getpos("."))
163
+ if start_pos.bufnr ~= 0 and start_pos.bufnr ~= buf then
164
+ return {}
165
+ end
166
+ if end_pos.bufnr ~= 0 and end_pos.bufnr ~= buf then
167
+ return {}
168
+ end
169
+
170
+ start_pos, end_pos = sorted_positions(start_pos, end_pos)
171
+
172
+ if mode == "V" then
173
+ local lines = get_lines(buf, start_pos.line, end_pos.line)
174
+ return { range_from_bytes(buf, start_pos.line, 0, end_pos.line, #(lines[#lines] or "")) }
175
+ end
176
+
177
+ if mode == "\022" then
178
+ local start_col = math.min(start_pos.col, end_pos.col)
179
+ local end_col = math.max(start_pos.col, end_pos.col)
180
+ local ranges = {}
181
+ local lines = get_lines(buf, start_pos.line, end_pos.line)
182
+ for offset, line in ipairs(lines) do
183
+ local row = start_pos.line + offset - 1
184
+ local col_start = math.min(start_col, #line)
185
+ local col_end = byte_after_char(line, math.min(end_col, #line))
186
+ if col_start < col_end then
187
+ table.insert(ranges, range_from_bytes(buf, row, col_start, row, col_end))
188
+ end
189
+ end
190
+ return ranges
191
+ end
192
+
193
+ local end_line = get_lines(buf, end_pos.line, end_pos.line)[1] or ""
194
+ return { range_from_bytes(buf, start_pos.line, start_pos.col, end_pos.line, byte_after_char(end_line, end_pos.col)) }
195
+ end
196
+
197
+ local function marked_visual_range(buf)
198
+ if not state.visual_mark_valid then
199
+ return {}
200
+ end
201
+ local start_mark = vim.api.nvim_buf_get_mark(buf, "<")
202
+ local end_mark = vim.api.nvim_buf_get_mark(buf, ">")
203
+ if start_mark[1] == 0 or end_mark[1] == 0 then
204
+ return {}
205
+ end
206
+ local start_pos = { line = start_mark[1] - 1, col = start_mark[2], bufnr = buf }
207
+ local end_pos = { line = end_mark[1] - 1, col = end_mark[2], bufnr = buf }
208
+ start_pos, end_pos = sorted_positions(start_pos, end_pos)
209
+ local end_line = get_lines(buf, end_pos.line, end_pos.line)[1] or ""
210
+ return { range_from_bytes(buf, start_pos.line, start_pos.col, end_pos.line, byte_after_char(end_line, end_pos.col)) }
211
+ end
212
+
213
+ function M.snapshot(opts)
214
+ opts = opts or {}
215
+ local buf = vim.api.nvim_get_current_buf()
216
+ if vim.bo[buf].buftype ~= "" then
217
+ return state.latest_snapshot
218
+ end
219
+
220
+ local file_path = vim.api.nvim_buf_get_name(buf)
221
+ if file_path == "" then
222
+ return nil
223
+ end
224
+ file_path = vim.fn.fnamemodify(file_path, ":p")
225
+
226
+ local mode = vim.fn.mode()
227
+ local ranges = {}
228
+ if mode == "v" or mode == "V" or mode == "\022" then
229
+ ranges = active_visual_range(buf, mode)
230
+ elseif opts.prefer_marks then
231
+ ranges = marked_visual_range(buf)
232
+ end
233
+
234
+ local snapshot = {
235
+ source = "nvim",
236
+ filePath = file_path,
237
+ workspaceFolder = best_workspace(file_path),
238
+ ranges = ranges,
239
+ }
240
+ state.latest_snapshot = snapshot
241
+ return snapshot
242
+ end
243
+
244
+ local function relative_path(snapshot)
245
+ local base = snapshot.workspaceFolder or vim.fn.getcwd()
246
+ local rel = vim.fn.fnamemodify(snapshot.filePath, ":p")
247
+ if vim.fs and vim.fs.relpath then
248
+ local ok, value = pcall(vim.fs.relpath, base, snapshot.filePath)
249
+ if ok and value and value ~= "" then
250
+ return value
251
+ end
252
+ end
253
+ local prefix = vim.fn.fnamemodify(base, ":p"):gsub("/$", "") .. "/"
254
+ if rel:sub(1, #prefix) == prefix then
255
+ return rel:sub(#prefix + 1)
256
+ end
257
+ return snapshot.filePath
258
+ end
259
+
260
+ local function line_span(range)
261
+ local start_line = range.selection.start.line + 1
262
+ local end_line = range.selection["end"].line + 1
263
+ if start_line == end_line then
264
+ return "#L" .. start_line
265
+ end
266
+ if state.config and state.config.range_format == "dash" then
267
+ return "#L" .. start_line .. "-L" .. end_line
268
+ end
269
+ return "#L" .. start_line .. "," .. end_line
270
+ end
271
+
272
+ function M.format_range_mention(snapshot)
273
+ local rel = relative_path(snapshot)
274
+ if not snapshot.ranges or #snapshot.ranges == 0 then
275
+ return "@" .. rel
276
+ end
277
+ return "@" .. rel .. line_span(snapshot.ranges[1])
278
+ end
279
+
280
+ local function publish_snapshot()
281
+ if not state.job_id then
282
+ return
283
+ end
284
+ local snapshot = M.snapshot()
285
+ if not snapshot then
286
+ send({ type = "selection_cleared", reason = "no-active-editor" })
287
+ return
288
+ end
289
+ send({ type = "selection_changed", snapshot = snapshot })
290
+ end
291
+
292
+ local function schedule_publish(delay)
293
+ delay = delay or (state.config and state.config.debounce_ms or defaults.debounce_ms)
294
+ if state.timer then
295
+ pcall(state.timer.stop, state.timer)
296
+ pcall(state.timer.close, state.timer)
297
+ end
298
+ state.timer = vim.defer_fn(publish_snapshot, delay)
299
+ end
300
+
301
+ function M.start()
302
+ if state.job_id then
303
+ return true
304
+ end
305
+ state.config = state.config or normalize_config()
306
+ local cmd = state.config.sidecar_cmd or default_sidecar_cmd()
307
+ state.job_id = vim.fn.jobstart(cmd, {
308
+ stdin = "pipe",
309
+ detach = false,
310
+ on_exit = function(_, code)
311
+ state.job_id = nil
312
+ if code ~= 0 then
313
+ notify("sidecar exited with code " .. tostring(code), vim.log.levels.WARN)
314
+ end
315
+ end,
316
+ on_stderr = function(_, data)
317
+ for _, line in ipairs(data or {}) do
318
+ if line ~= "" then
319
+ vim.schedule(function()
320
+ notify(line, vim.log.levels.WARN)
321
+ end)
322
+ end
323
+ end
324
+ end,
325
+ })
326
+
327
+ if state.job_id <= 0 then
328
+ state.job_id = nil
329
+ notify("failed to start sidecar", vim.log.levels.ERROR)
330
+ return false
331
+ end
332
+
333
+ send({ workspaceFolders = workspace_folders(), name = "Neovim" })
334
+ schedule_publish(0)
335
+ return true
336
+ end
337
+
338
+ function M.stop()
339
+ if state.timer then
340
+ pcall(state.timer.stop, state.timer)
341
+ pcall(state.timer.close, state.timer)
342
+ state.timer = nil
343
+ end
344
+ if state.job_id then
345
+ send({ type = "shutdown" })
346
+ vim.fn.chanclose(state.job_id, "stdin")
347
+ state.job_id = nil
348
+ end
349
+ end
350
+
351
+ function M.attach()
352
+ if not state.job_id and not M.start() then
353
+ return
354
+ end
355
+ local snapshot = M.snapshot({ prefer_marks = true })
356
+ if not snapshot then
357
+ notify("no active file to attach", vim.log.levels.WARN)
358
+ return
359
+ end
360
+ local range_text = M.format_range_mention(snapshot)
361
+ send({ type = "at_mentioned", snapshot = vim.tbl_extend("force", snapshot, { rangeText = range_text }), rangeText = range_text })
362
+ notify("attached " .. range_text)
363
+ end
364
+
365
+ function M.status()
366
+ notify(state.job_id and "sidecar running" or "sidecar stopped")
367
+ end
368
+
369
+ local function setup_autocmds()
370
+ if state.augroup then
371
+ pcall(vim.api.nvim_del_augroup_by_id, state.augroup)
372
+ end
373
+ state.augroup = vim.api.nvim_create_augroup("PiXIde", { clear = true })
374
+ vim.api.nvim_create_autocmd({ "BufEnter", "WinEnter", "CursorMoved", "CursorMovedI", "TextChanged", "TextChangedI", "DirChanged" }, {
375
+ group = state.augroup,
376
+ callback = function(event)
377
+ if event.event == "DirChanged" then
378
+ send({ type = "workspace_changed", workspaceFolders = workspace_folders() })
379
+ end
380
+ schedule_publish()
381
+ end,
382
+ })
383
+ vim.api.nvim_create_autocmd("ModeChanged", {
384
+ group = state.augroup,
385
+ callback = function(event)
386
+ if event.match and event.match:match("^[vV\022].*:n$") then
387
+ state.visual_mark_valid = true
388
+ end
389
+ schedule_publish(0)
390
+ end,
391
+ })
392
+ vim.api.nvim_create_autocmd("VimLeavePre", {
393
+ group = state.augroup,
394
+ callback = function()
395
+ M.stop()
396
+ end,
397
+ })
398
+ end
399
+
400
+ local function setup_commands()
401
+ local commands = {
402
+ PiXIdeStart = M.start,
403
+ PiXIdeStop = M.stop,
404
+ PiXIdeStatus = M.status,
405
+ PiXIdeAttach = M.attach,
406
+ }
407
+ for name, fn in pairs(commands) do
408
+ pcall(vim.api.nvim_create_user_command, name, fn, {})
409
+ end
410
+ end
411
+
412
+ local function setup_keymap()
413
+ if not state.config.keymap then
414
+ return
415
+ end
416
+ vim.keymap.set({ "n", "v" }, state.config.keymap, M.attach, { desc = "Pi x IDE: attach selection" })
417
+ end
418
+
419
+ function M.setup(opts)
420
+ state.config = normalize_config(opts)
421
+ setup_commands()
422
+ setup_autocmds()
423
+ setup_keymap()
424
+ if state.config.enabled then
425
+ M.start()
426
+ end
427
+ end
428
+
429
+ function M._test_set_visual_mark_valid(value)
430
+ state.visual_mark_valid = value
431
+ end
432
+
433
+ function M._test_reset()
434
+ M.stop()
435
+ state.config = normalize_config({ enabled = false })
436
+ state.latest_snapshot = nil
437
+ state.visual_mark_valid = false
438
+ end
439
+
440
+ setup_commands()
441
+
442
+ return M
@@ -0,0 +1,15 @@
1
+ if vim.g.loaded_pi_x_ide == 1 then
2
+ return
3
+ end
4
+ vim.g.loaded_pi_x_ide = 1
5
+
6
+ local function command(name, fn)
7
+ pcall(vim.api.nvim_create_user_command, name, function()
8
+ require("pi_x_ide")[fn]()
9
+ end, {})
10
+ end
11
+
12
+ command("PiXIdeStart", "start")
13
+ command("PiXIdeStop", "stop")
14
+ command("PiXIdeStatus", "status")
15
+ command("PiXIdeAttach", "attach")
package/package.json CHANGED
@@ -1,18 +1,24 @@
1
1
  {
2
2
  "name": "pi-x-ide",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Pi extension package for IDE selection context integration.",
5
5
  "files": [
6
6
  "dist",
7
7
  "schemas",
8
- "src"
8
+ "src",
9
+ "nvim/lua",
10
+ "nvim/plugin",
11
+ "nvim/doc",
12
+ "nvim/bin"
9
13
  ],
10
14
  "keywords": [
11
15
  "pi-package",
12
16
  "pi",
13
17
  "ide",
14
18
  "vscode",
15
- "zed"
19
+ "zed",
20
+ "neovim",
21
+ "nvim"
16
22
  ],
17
23
  "license": "Apache-2.0",
18
24
  "repository": {
@@ -54,7 +60,7 @@
54
60
  ]
55
61
  },
56
62
  "scripts": {
57
- "build": "tsc -p tsconfig.json && pnpm --filter './vscode' compile",
63
+ "build": "tsc -p tsconfig.json && node scripts/build-nvim-sidecar.mjs --production && pnpm --filter './vscode' compile",
58
64
  "typecheck": "tsc -p tsconfig.json --noEmit && pnpm --filter './vscode' typecheck",
59
65
  "generate:config-schema": "node scripts/generate-config-schema.cjs",
60
66
  "check:config-schema": "node scripts/generate-config-schema.cjs --check",
@@ -0,0 +1,71 @@
1
+ import type { AtMentionedParams, EditorSelectionSnapshot, SelectionClearedParams } from "../shared/protocol";
2
+ import { isAtMentionedParams, isEditorSelectionSnapshot, isSelectionClearedParams } from "../shared/schema";
3
+
4
+ export interface SidecarConfig {
5
+ workspaceFolders?: string[];
6
+ name?: string;
7
+ lockDir?: string;
8
+ }
9
+
10
+ export type NvimSidecarMessage =
11
+ | { type: "selection_changed"; snapshot: EditorSelectionSnapshot }
12
+ | { type: "selection_cleared"; reason?: SelectionClearedParams["reason"] }
13
+ | { type: "at_mentioned"; snapshot: AtMentionedParams; rangeText?: string }
14
+ | { type: "workspace_changed"; workspaceFolders: string[] }
15
+ | { type: "shutdown" };
16
+
17
+ function isRecord(value: unknown): value is Record<string, unknown> {
18
+ return typeof value === "object" && value !== null && !Array.isArray(value);
19
+ }
20
+
21
+ function isStringArray(value: unknown): value is string[] {
22
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
23
+ }
24
+
25
+ export function parseSidecarConfig(value: unknown): SidecarConfig | undefined {
26
+ if (!isRecord(value)) return undefined;
27
+ const workspaceFolders = value.workspaceFolders;
28
+ const name = value.name;
29
+ const lockDir = value.lockDir;
30
+ if (workspaceFolders !== undefined && !isStringArray(workspaceFolders)) return undefined;
31
+ if (name !== undefined && typeof name !== "string") return undefined;
32
+ if (lockDir !== undefined && typeof lockDir !== "string") return undefined;
33
+ return { workspaceFolders, name, lockDir };
34
+ }
35
+
36
+ export function parseNvimSidecarMessage(value: unknown): NvimSidecarMessage | undefined {
37
+ if (!isRecord(value) || typeof value.type !== "string") return undefined;
38
+
39
+ switch (value.type) {
40
+ case "selection_changed":
41
+ return isEditorSelectionSnapshot(value.snapshot)
42
+ ? { type: "selection_changed", snapshot: value.snapshot }
43
+ : undefined;
44
+ case "selection_cleared": {
45
+ const params = { source: "nvim", reason: value.reason ?? "no-active-editor" };
46
+ return isSelectionClearedParams(params) ? { type: "selection_cleared", reason: params.reason } : undefined;
47
+ }
48
+ case "at_mentioned": {
49
+ const snapshot = value.snapshot;
50
+ if (!isAtMentionedParams(snapshot)) return undefined;
51
+ const rangeText = typeof value.rangeText === "string" ? value.rangeText : snapshot.rangeText;
52
+ return { type: "at_mentioned", snapshot, rangeText };
53
+ }
54
+ case "workspace_changed":
55
+ return isStringArray(value.workspaceFolders)
56
+ ? { type: "workspace_changed", workspaceFolders: value.workspaceFolders }
57
+ : undefined;
58
+ case "shutdown":
59
+ return { type: "shutdown" };
60
+ default:
61
+ return undefined;
62
+ }
63
+ }
64
+
65
+ export function parseJsonLine(line: string): unknown {
66
+ try {
67
+ return JSON.parse(line) as unknown;
68
+ } catch {
69
+ return undefined;
70
+ }
71
+ }