@trim21/personal-pi-extensions 0.1.541 → 0.1.542
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/package.json +1 -1
- package/src/claude-code/files.ts +210 -221
- package/src/lib/lsp/diagnostic.ts +41 -14
- package/src/lib/lsp/lsp.ts +6 -9
- package/src/opencode/files.ts +46 -45
package/package.json
CHANGED
package/src/claude-code/files.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
} from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { Type } from "typebox";
|
|
16
16
|
|
|
17
|
-
import { appendLspDiagnosticText } from "../lib/lsp/diagnostic.js";
|
|
17
|
+
import { appendLspDiagnosticText, type DiagnosticReport } from "../lib/lsp/diagnostic.js";
|
|
18
18
|
import { registerLspInspectTools } from "../lib/lsp/inspect-tool.js";
|
|
19
19
|
import { createLspManager, type LspService, type LspServiceOptions } from "../lib/lsp/lsp.js";
|
|
20
20
|
import { registerLspRenameTool } from "../lib/lsp/rename-tool.js";
|
|
@@ -333,24 +333,25 @@ export function registerFileTools(
|
|
|
333
333
|
const snapshot = snapshotOf(buffer);
|
|
334
334
|
state.reads.set(key, snapshot);
|
|
335
335
|
// 与 Edit / Write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
|
|
336
|
-
const {
|
|
337
|
-
text: diagnosticText,
|
|
338
|
-
errorCount,
|
|
339
|
-
warningCount,
|
|
340
|
-
} = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
336
|
+
const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
341
337
|
notify: (message, level) => ctx.ui.notify(message, level),
|
|
342
338
|
});
|
|
343
339
|
return {
|
|
344
340
|
content: [
|
|
345
341
|
{
|
|
346
342
|
type: "text",
|
|
347
|
-
text: appendLspDiagnosticText(formatted.text,
|
|
343
|
+
text: appendLspDiagnosticText(formatted.text, diagnostics.text),
|
|
348
344
|
},
|
|
349
345
|
],
|
|
350
346
|
details: {
|
|
351
347
|
reads: { [key]: snapshot },
|
|
352
348
|
pendant: {
|
|
353
|
-
subtitle: formatSubtitlePath(
|
|
349
|
+
subtitle: formatSubtitlePath(
|
|
350
|
+
ctx.cwd,
|
|
351
|
+
filePath,
|
|
352
|
+
diagnostics.errorCount,
|
|
353
|
+
diagnostics.warningCount,
|
|
354
|
+
),
|
|
354
355
|
title: "Read",
|
|
355
356
|
} satisfies ToolPendant,
|
|
356
357
|
},
|
|
@@ -394,178 +395,167 @@ export function registerFileTools(
|
|
|
394
395
|
replaceAll: params.replace_all,
|
|
395
396
|
},
|
|
396
397
|
});
|
|
397
|
-
const [message, details,
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const content = await readFile(filePath, "utf8");
|
|
415
|
-
if (content.trim() !== "") {
|
|
416
|
-
throw new Error("Cannot create new file - file already exists.");
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
} catch (error) {
|
|
420
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
421
|
-
exists = false;
|
|
422
|
-
} else {
|
|
423
|
-
throw error;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
if (!exists) await mkdir(dirname(filePath), { recursive: true });
|
|
427
|
-
await writeFile(filePath, newString, "utf8");
|
|
428
|
-
const snapshot = snapshotOf(newString);
|
|
429
|
-
const key = await readStateKey(filePath);
|
|
430
|
-
state.reads.set(key, snapshot);
|
|
431
|
-
const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
|
|
432
|
-
signal?.throwIfAborted();
|
|
433
|
-
const {
|
|
434
|
-
text: diagnosticText,
|
|
435
|
-
errorCount,
|
|
436
|
-
warningCount,
|
|
437
|
-
} = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
438
|
-
notify: (message, level) => ctx.ui.notify(message, level),
|
|
439
|
-
signal,
|
|
440
|
-
});
|
|
441
|
-
return [
|
|
442
|
-
`The file ${filePath} has been updated successfully.`,
|
|
443
|
-
{
|
|
444
|
-
diff: diff.diff,
|
|
445
|
-
patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
|
|
446
|
-
firstChangedLine: diff.firstChangedLine,
|
|
447
|
-
reads: { [key]: snapshot },
|
|
448
|
-
},
|
|
449
|
-
diagnosticText,
|
|
450
|
-
errorCount,
|
|
451
|
-
warningCount,
|
|
452
|
-
];
|
|
453
|
-
}
|
|
454
|
-
const replaceAll = params.replace_all ?? false;
|
|
455
|
-
// 防止 OOM 的大文件检查(对齐 Claude Code)
|
|
456
|
-
try {
|
|
457
|
-
const { size } = await stat(filePath);
|
|
458
|
-
if (size > MAX_EDIT_FILE_SIZE) {
|
|
459
|
-
throw new Error(
|
|
460
|
-
`File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
|
|
461
|
-
);
|
|
462
|
-
}
|
|
463
|
-
} catch (error) {
|
|
464
|
-
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
465
|
-
throw error;
|
|
398
|
+
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
399
|
+
[string, FileToolDetails, DiagnosticReport]
|
|
400
|
+
>(filePath, async () => {
|
|
401
|
+
const oldString = params.old_string;
|
|
402
|
+
const newString = params.new_string;
|
|
403
|
+
if (oldString === newString) {
|
|
404
|
+
throw new Error("No changes to make: old_string and new_string are exactly the same.");
|
|
405
|
+
}
|
|
406
|
+
// 空 old_string:创建新文件或填充空文件(不需要先 Read,对齐 Claude Code)
|
|
407
|
+
if (oldString === "") {
|
|
408
|
+
let exists = true;
|
|
409
|
+
try {
|
|
410
|
+
const value = await stat(filePath);
|
|
411
|
+
if (value.isFile()) {
|
|
412
|
+
const content = await readFile(filePath, "utf8");
|
|
413
|
+
if (content.trim() !== "") {
|
|
414
|
+
throw new Error("Cannot create new file - file already exists.");
|
|
466
415
|
}
|
|
467
416
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
473
|
-
const suggestion = await didYouMean(filePath, ctx.cwd);
|
|
474
|
-
throw new Error(
|
|
475
|
-
`File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
|
|
476
|
-
{ cause: error },
|
|
477
|
-
);
|
|
478
|
-
}
|
|
417
|
+
} catch (error) {
|
|
418
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
419
|
+
exists = false;
|
|
420
|
+
} else {
|
|
479
421
|
throw error;
|
|
480
422
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
423
|
+
}
|
|
424
|
+
if (!exists) await mkdir(dirname(filePath), { recursive: true });
|
|
425
|
+
await writeFile(filePath, newString, "utf8");
|
|
426
|
+
const snapshot = snapshotOf(newString);
|
|
427
|
+
const key = await readStateKey(filePath);
|
|
428
|
+
state.reads.set(key, snapshot);
|
|
429
|
+
const diff = generateDiffString("", convertLeadingTabsToSpaces(newString));
|
|
430
|
+
signal?.throwIfAborted();
|
|
431
|
+
const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
432
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
433
|
+
signal,
|
|
434
|
+
});
|
|
435
|
+
return [
|
|
436
|
+
`The file ${filePath} has been updated successfully.`,
|
|
437
|
+
{
|
|
438
|
+
diff: diff.diff,
|
|
439
|
+
patch: generateUnifiedPatch(filePath, "", convertLeadingTabsToSpaces(newString)),
|
|
440
|
+
firstChangedLine: diff.firstChangedLine,
|
|
441
|
+
reads: { [key]: snapshot },
|
|
442
|
+
},
|
|
443
|
+
diagnostics,
|
|
444
|
+
];
|
|
445
|
+
}
|
|
446
|
+
const replaceAll = params.replace_all ?? false;
|
|
447
|
+
// 防止 OOM 的大文件检查(对齐 Claude Code)
|
|
448
|
+
try {
|
|
449
|
+
const { size } = await stat(filePath);
|
|
450
|
+
if (size > MAX_EDIT_FILE_SIZE) {
|
|
451
|
+
throw new Error(
|
|
452
|
+
`File is too large to edit (${formatFileSize(size)}). Maximum editable file size is ${formatFileSize(MAX_EDIT_FILE_SIZE)}.`,
|
|
453
|
+
);
|
|
454
|
+
}
|
|
455
|
+
} catch (error) {
|
|
456
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
let content: Buffer;
|
|
461
|
+
try {
|
|
462
|
+
content = await readFile(filePath);
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
465
|
+
const suggestion = await didYouMean(filePath, ctx.cwd);
|
|
466
|
+
throw new Error(
|
|
467
|
+
`File does not exist. Note: your current working directory is ${ctx.cwd}.${suggestion ? ` Did you mean ${suggestion}?` : ""}`,
|
|
468
|
+
{ cause: error },
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
throw error;
|
|
472
|
+
}
|
|
473
|
+
if (extname(filePath).toLowerCase() === ".ipynb") {
|
|
474
|
+
throw new Error(
|
|
475
|
+
"File is a Jupyter Notebook. Use the NotebookEditTool to edit this file.",
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const key = await readStateKey(filePath);
|
|
479
|
+
requireCurrentRead(state, key, filePath, content);
|
|
480
|
+
await access(filePath, constants.R_OK | constants.W_OK);
|
|
481
|
+
signal?.throwIfAborted();
|
|
482
|
+
const original = content.toString("utf8");
|
|
483
|
+
|
|
484
|
+
// CRLF 规范化后匹配(old_string 不需要带 \r),写回时恢复原行尾
|
|
485
|
+
const crlfCount = (original.match(/\r\n/g) ?? []).length;
|
|
486
|
+
const lfCount = (original.match(/(?<!\r)\n/g) ?? []).length;
|
|
487
|
+
const lineEnding = crlfCount > lfCount ? "\r\n" : "\n";
|
|
488
|
+
const normalized = original.replaceAll("\r\n", "\n");
|
|
489
|
+
const actualOldString = findActualString(normalized, oldString) ?? oldString;
|
|
490
|
+
const matches = normalized.split(actualOldString).length - 1;
|
|
491
|
+
if (matches === 0) {
|
|
492
|
+
throw new Error(`String to replace not found in file.\nString: ${oldString}`);
|
|
493
|
+
}
|
|
494
|
+
if (!replaceAll && matches > 1) {
|
|
495
|
+
throw new Error(
|
|
496
|
+
`Found ${matches} matches of the string to replace, but replace_all is false. To replace all occurrences, set replace_all to true. To replace only one occurrence, please provide more context to uniquely identify the instance.\nString: ${oldString}`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
const actualNewString = preserveQuoteStyle(oldString, actualOldString, newString);
|
|
500
|
+
// 删除场景(new_string 为空):old_string 不以换行结尾且文件里是
|
|
501
|
+
// "old_string\n" 时连换行一起删,避免留下空行(对齐 Claude Code
|
|
502
|
+
// applyEditToFile 的 stripTrailingNewline 语义)
|
|
503
|
+
let searchString = actualOldString;
|
|
504
|
+
if (
|
|
505
|
+
actualNewString === "" &&
|
|
506
|
+
!actualOldString.endsWith("\n") &&
|
|
507
|
+
normalized.includes(actualOldString + "\n")
|
|
508
|
+
) {
|
|
509
|
+
searchString = actualOldString + "\n";
|
|
510
|
+
}
|
|
511
|
+
// split/join 与函数替换:replacement 含 $ 时不会触发 $& 等特殊语义
|
|
512
|
+
const updated = replaceAll
|
|
513
|
+
? normalized.split(searchString).join(actualNewString)
|
|
514
|
+
: normalized.replace(searchString, () => actualNewString);
|
|
515
|
+
const restored = lineEnding === "\r\n" ? updated.replaceAll("\n", "\r\n") : updated;
|
|
516
|
+
await writeFile(filePath, restored, "utf8");
|
|
517
|
+
const snapshot = snapshotOf(restored);
|
|
518
|
+
state.reads.set(key, snapshot);
|
|
519
|
+
// patch/diff 仅供显示:前导 tab 转空格,避免 UI 渲染错位(对齐 Claude Code)
|
|
520
|
+
const diff = generateDiffString(
|
|
521
|
+
convertLeadingTabsToSpaces(original),
|
|
522
|
+
convertLeadingTabsToSpaces(restored),
|
|
523
|
+
);
|
|
524
|
+
const text = replaceAll
|
|
525
|
+
? `The file ${filePath} has been updated. All occurrences were successfully replaced.`
|
|
526
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
527
|
+
signal?.throwIfAborted();
|
|
528
|
+
const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
529
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
530
|
+
});
|
|
531
|
+
return [
|
|
532
|
+
text,
|
|
533
|
+
{
|
|
534
|
+
diff: diff.diff,
|
|
535
|
+
patch: generateUnifiedPatch(
|
|
536
|
+
filePath,
|
|
529
537
|
convertLeadingTabsToSpaces(original),
|
|
530
538
|
convertLeadingTabsToSpaces(restored),
|
|
531
|
-
)
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
: `The file ${filePath} has been updated successfully.`;
|
|
535
|
-
signal?.throwIfAborted();
|
|
536
|
-
const {
|
|
537
|
-
text: diagnosticText,
|
|
538
|
-
errorCount,
|
|
539
|
-
warningCount,
|
|
540
|
-
} = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
541
|
-
notify: (message, level) => ctx.ui.notify(message, level),
|
|
542
|
-
});
|
|
543
|
-
return [
|
|
544
|
-
text,
|
|
545
|
-
{
|
|
546
|
-
diff: diff.diff,
|
|
547
|
-
patch: generateUnifiedPatch(
|
|
548
|
-
filePath,
|
|
549
|
-
convertLeadingTabsToSpaces(original),
|
|
550
|
-
convertLeadingTabsToSpaces(restored),
|
|
551
|
-
),
|
|
552
|
-
firstChangedLine: diff.firstChangedLine,
|
|
553
|
-
reads: { [key]: snapshot },
|
|
554
|
-
},
|
|
555
|
-
diagnosticText,
|
|
556
|
-
errorCount,
|
|
557
|
-
warningCount,
|
|
558
|
-
];
|
|
539
|
+
),
|
|
540
|
+
firstChangedLine: diff.firstChangedLine,
|
|
541
|
+
reads: { [key]: snapshot },
|
|
559
542
|
},
|
|
560
|
-
|
|
543
|
+
diagnostics,
|
|
544
|
+
];
|
|
545
|
+
});
|
|
561
546
|
|
|
562
|
-
const text = appendLspDiagnosticText(message,
|
|
547
|
+
const text = appendLspDiagnosticText(message, diagnostics.text);
|
|
563
548
|
return {
|
|
564
549
|
content: [{ type: "text" as const, text }],
|
|
565
550
|
details: {
|
|
566
551
|
...details,
|
|
567
552
|
pendant: {
|
|
568
|
-
subtitle: formatSubtitlePath(
|
|
553
|
+
subtitle: formatSubtitlePath(
|
|
554
|
+
ctx.cwd,
|
|
555
|
+
filePath,
|
|
556
|
+
diagnostics.errorCount,
|
|
557
|
+
diagnostics.warningCount,
|
|
558
|
+
),
|
|
569
559
|
title: "Edit",
|
|
570
560
|
} satisfies ToolPendant,
|
|
571
561
|
},
|
|
@@ -600,66 +590,65 @@ export function registerFileTools(
|
|
|
600
590
|
absolutePath: filePath,
|
|
601
591
|
change: { oldText: "", newText: params.content },
|
|
602
592
|
});
|
|
603
|
-
const [message, details,
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
{
|
|
645
|
-
diff: diff.diff,
|
|
646
|
-
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
|
647
|
-
firstChangedLine: diff.firstChangedLine,
|
|
648
|
-
reads: { [resolvedKey]: snapshot },
|
|
649
|
-
},
|
|
650
|
-
diagnosticText,
|
|
651
|
-
errorCount,
|
|
652
|
-
warningCount,
|
|
653
|
-
];
|
|
593
|
+
const [message, details, diagnostics] = await withFileMutationQueue<
|
|
594
|
+
[string, FileToolDetails, DiagnosticReport]
|
|
595
|
+
>(filePath, async () => {
|
|
596
|
+
let original: string | undefined;
|
|
597
|
+
let key: string | undefined;
|
|
598
|
+
try {
|
|
599
|
+
const value = await stat(filePath);
|
|
600
|
+
if (value.isFile()) {
|
|
601
|
+
const content = await readFile(filePath);
|
|
602
|
+
key = await readStateKey(filePath);
|
|
603
|
+
requireCurrentRead(state, key, filePath, content);
|
|
604
|
+
original = content.toString("utf8");
|
|
605
|
+
}
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ENOENT") {
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
signal?.throwIfAborted();
|
|
612
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
613
|
+
await writeFile(filePath, params.content, "utf8");
|
|
614
|
+
const snapshot = snapshotOf(params.content);
|
|
615
|
+
// 新建文件:writeFile 之后 realpath 才能解析;覆盖写则复用上面的 key
|
|
616
|
+
const resolvedKey = key ?? (await readStateKey(filePath));
|
|
617
|
+
state.reads.set(resolvedKey, snapshot);
|
|
618
|
+
const diff = generateDiffString(original ?? "", params.content);
|
|
619
|
+
const text =
|
|
620
|
+
original === undefined
|
|
621
|
+
? `File created successfully at: ${filePath}`
|
|
622
|
+
: `The file ${filePath} has been updated successfully.`;
|
|
623
|
+
signal?.throwIfAborted();
|
|
624
|
+
const diagnostics = await getService().lspDiagnosticsForFile(filePath, ctx.cwd, {
|
|
625
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
626
|
+
});
|
|
627
|
+
return [
|
|
628
|
+
text,
|
|
629
|
+
{
|
|
630
|
+
diff: diff.diff,
|
|
631
|
+
patch: generateUnifiedPatch(filePath, original ?? "", params.content),
|
|
632
|
+
firstChangedLine: diff.firstChangedLine,
|
|
633
|
+
reads: { [resolvedKey]: snapshot },
|
|
654
634
|
},
|
|
655
|
-
|
|
635
|
+
diagnostics,
|
|
636
|
+
];
|
|
637
|
+
});
|
|
656
638
|
|
|
657
|
-
const text = appendLspDiagnosticText(message,
|
|
639
|
+
const text = appendLspDiagnosticText(message, diagnostics.text);
|
|
658
640
|
return {
|
|
659
641
|
content: [{ type: "text" as const, text }],
|
|
660
642
|
details: {
|
|
661
643
|
...details,
|
|
662
|
-
pendant: {
|
|
644
|
+
pendant: {
|
|
645
|
+
subtitle: formatSubtitlePath(
|
|
646
|
+
ctx.cwd,
|
|
647
|
+
filePath,
|
|
648
|
+
diagnostics.errorCount,
|
|
649
|
+
diagnostics.warningCount,
|
|
650
|
+
),
|
|
651
|
+
},
|
|
663
652
|
},
|
|
664
653
|
};
|
|
665
654
|
},
|
|
@@ -17,6 +17,20 @@ function severityOf(diagnostic: Diagnostic): number {
|
|
|
17
17
|
return diagnostic.severity ?? 1;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** 单个文件的诊断报告;计数覆盖该文件全部 ERROR / WARN,不受每文件上限影响。 */
|
|
21
|
+
export interface DiagnosticReport {
|
|
22
|
+
/** 空字符串表示没有 ERROR / WARN。 */
|
|
23
|
+
text: string;
|
|
24
|
+
errorCount: number;
|
|
25
|
+
warningCount: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const EMPTY_DIAGNOSTIC_REPORT: DiagnosticReport = {
|
|
29
|
+
text: "",
|
|
30
|
+
errorCount: 0,
|
|
31
|
+
warningCount: 0,
|
|
32
|
+
};
|
|
33
|
+
|
|
20
34
|
export function prettyDiagnostic(diagnostic: Diagnostic): string {
|
|
21
35
|
const severity = SEVERITY_LABELS[severityOf(diagnostic)] ?? "ERROR";
|
|
22
36
|
const line = diagnostic.range.start.line + 1;
|
|
@@ -27,25 +41,38 @@ export function prettyDiagnostic(diagnostic: Diagnostic): string {
|
|
|
27
41
|
return `${severity} [${line}:${col}] ${message}`;
|
|
28
42
|
}
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
|
|
44
|
+
function pluralize(count: number, noun: string): string {
|
|
45
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 未列出部分的构成:`3 errors, 4 warnings`(为 0 的一类省略;单复数按数量)。 */
|
|
49
|
+
function hiddenSummary(hidden: Diagnostic[]): string {
|
|
50
|
+
const errors = hidden.filter((item) => severityOf(item) === 1).length;
|
|
51
|
+
const warnings = hidden.length - errors;
|
|
52
|
+
return [
|
|
53
|
+
...(errors > 0 ? [pluralize(errors, "error")] : []),
|
|
54
|
+
...(warnings > 0 ? [pluralize(warnings, "warning")] : []),
|
|
55
|
+
].join(", ");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 返回空 text 表示没有 ERROR / WARN。ERROR 在前,WARN 在后,超出上限的部分只报数量。 */
|
|
59
|
+
export function report(file: string, issues: Diagnostic[]): DiagnosticReport {
|
|
32
60
|
const errors = issues.filter((item) => severityOf(item) === 1);
|
|
33
61
|
const warnings = issues.filter((item) => item.severity === 2);
|
|
34
62
|
const relevant = [...errors, ...warnings];
|
|
35
|
-
if (relevant.length === 0) return
|
|
63
|
+
if (relevant.length === 0) return EMPTY_DIAGNOSTIC_REPORT;
|
|
36
64
|
const limited = relevant.slice(0, MAX_PER_FILE);
|
|
37
|
-
const
|
|
38
|
-
const suffix =
|
|
39
|
-
return
|
|
65
|
+
const hidden = relevant.slice(MAX_PER_FILE);
|
|
66
|
+
const suffix = hidden.length > 0 ? `\n... and ${hiddenSummary(hidden)}` : "";
|
|
67
|
+
return {
|
|
68
|
+
text: `<diagnostics file="${file}">\n${limited.map((d) => prettyDiagnostic(d)).join("\n")}${suffix}\n</diagnostics>`,
|
|
69
|
+
errorCount: errors.length,
|
|
70
|
+
warningCount: warnings.length,
|
|
71
|
+
};
|
|
40
72
|
}
|
|
41
73
|
|
|
42
74
|
/** 把 LSP 诊断块接到工具成功文案后面;无诊断时原样返回。 */
|
|
43
|
-
export function appendLspDiagnosticText(
|
|
44
|
-
message
|
|
45
|
-
diagnosticText
|
|
46
|
-
errorCount: number,
|
|
47
|
-
): string {
|
|
48
|
-
if (!diagnosticText) return message;
|
|
49
|
-
const kind = errorCount > 0 ? "errors" : "warnings";
|
|
50
|
-
return `${message}\n\nLSP ${kind} detected in this file:\n${diagnosticText}`;
|
|
75
|
+
export function appendLspDiagnosticText(message: string, diagnosticText: string): string {
|
|
76
|
+
if (diagnosticText === "") return message;
|
|
77
|
+
return `${message}\n\nLSP diagnostics detected in this file\n${diagnosticText}`;
|
|
51
78
|
}
|
package/src/lib/lsp/lsp.ts
CHANGED
|
@@ -43,7 +43,7 @@ import {
|
|
|
43
43
|
WATCH_KIND_CREATE,
|
|
44
44
|
WATCH_KIND_DELETE,
|
|
45
45
|
} from "./client.js";
|
|
46
|
-
import { report } from "./diagnostic.js";
|
|
46
|
+
import { type DiagnosticReport, EMPTY_DIAGNOSTIC_REPORT, report } from "./diagnostic.js";
|
|
47
47
|
import {
|
|
48
48
|
createAdapters,
|
|
49
49
|
matchesInclude,
|
|
@@ -398,12 +398,12 @@ export interface LspService {
|
|
|
398
398
|
options?: LspRequestOptions,
|
|
399
399
|
): Promise<void>;
|
|
400
400
|
diagnostics(): Promise<Record<string, Diagnostic[]>>;
|
|
401
|
-
/** read / edit / write 用:等待文档诊断并返回该文件的 ERROR / WARN
|
|
401
|
+
/** read / edit / write 用:等待文档诊断并返回该文件的 ERROR / WARN 报告与计数。 */
|
|
402
402
|
lspDiagnosticsForFile(
|
|
403
403
|
file: string,
|
|
404
404
|
cwd: string,
|
|
405
405
|
options?: LspRequestOptions,
|
|
406
|
-
): Promise<
|
|
406
|
+
): Promise<DiagnosticReport>;
|
|
407
407
|
/**
|
|
408
408
|
* 符号重命名:只面向 kind 为 "language" 的服务器(linter 不参与符号级
|
|
409
409
|
* 功能);多 client 按配置顺序取第一个成功结果,全部失败时抛聚合错误。
|
|
@@ -929,15 +929,12 @@ export function createLspService(
|
|
|
929
929
|
file: string,
|
|
930
930
|
cwd: string,
|
|
931
931
|
options?: LspRequestOptions,
|
|
932
|
-
): Promise<
|
|
933
|
-
if (options?.signal?.aborted) return
|
|
932
|
+
): Promise<DiagnosticReport> {
|
|
933
|
+
if (options?.signal?.aborted) return EMPTY_DIAGNOSTIC_REPORT;
|
|
934
934
|
await touchFile(file, cwd, "document", options);
|
|
935
935
|
const all = await diagnostics();
|
|
936
936
|
const normalized = normalize(file);
|
|
937
|
-
|
|
938
|
-
const errorCount = issues.filter((item) => (item.severity ?? 1) === 1).length;
|
|
939
|
-
const warningCount = issues.filter((item) => item.severity === 2).length;
|
|
940
|
-
return { text: report(normalized, issues), errorCount, warningCount };
|
|
937
|
+
return report(normalized, all[normalized] ?? []);
|
|
941
938
|
}
|
|
942
939
|
|
|
943
940
|
/** 符号重命名:只面向 language 类服务器;按配置顺序取第一个成功结果。 */
|
package/src/opencode/files.ts
CHANGED
|
@@ -432,15 +432,13 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
432
432
|
}
|
|
433
433
|
|
|
434
434
|
// opencode: 与 edit / write 同一条驻留路径:didOpen 后等待该文件的诊断并报告
|
|
435
|
-
const {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
warningCount,
|
|
439
|
-
} = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
|
|
435
|
+
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
436
|
+
signal,
|
|
437
|
+
});
|
|
440
438
|
content = [
|
|
441
439
|
{
|
|
442
440
|
type: "text",
|
|
443
|
-
text: appendLspDiagnosticText(outputText,
|
|
441
|
+
text: appendLspDiagnosticText(outputText, diagnostics.text),
|
|
444
442
|
},
|
|
445
443
|
];
|
|
446
444
|
|
|
@@ -449,7 +447,12 @@ function registerReadTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
449
447
|
details: {
|
|
450
448
|
...details,
|
|
451
449
|
pendant: {
|
|
452
|
-
subtitle: formatSubtitlePath(
|
|
450
|
+
subtitle: formatSubtitlePath(
|
|
451
|
+
ctx.cwd,
|
|
452
|
+
absolutePath,
|
|
453
|
+
diagnostics.errorCount,
|
|
454
|
+
diagnostics.warningCount,
|
|
455
|
+
),
|
|
453
456
|
},
|
|
454
457
|
},
|
|
455
458
|
};
|
|
@@ -503,8 +506,9 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
503
506
|
change: { oldText: oldString, newText: newString, replaceAll },
|
|
504
507
|
});
|
|
505
508
|
|
|
506
|
-
const [message, details,
|
|
507
|
-
|
|
509
|
+
const [message, details, diagnostics] = await withFileMutationQueue(
|
|
510
|
+
absolutePath,
|
|
511
|
+
async () => {
|
|
508
512
|
signal?.throwIfAborted();
|
|
509
513
|
|
|
510
514
|
// opencode: 前置校验,先于空 oldString 分支
|
|
@@ -529,17 +533,13 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
529
533
|
await mkdir(dirname(absolutePath), { recursive: true });
|
|
530
534
|
signal?.throwIfAborted();
|
|
531
535
|
await writeFile(absolutePath, newString, "utf8");
|
|
532
|
-
const {
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
warningCount,
|
|
536
|
-
} = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
|
|
536
|
+
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
537
|
+
signal,
|
|
538
|
+
});
|
|
537
539
|
return [
|
|
538
540
|
"Edit applied successfully.",
|
|
539
541
|
{ diff: "", patch: "", firstChangedLine: 0 },
|
|
540
|
-
|
|
541
|
-
errorCount,
|
|
542
|
-
warningCount,
|
|
542
|
+
diagnostics,
|
|
543
543
|
] as const;
|
|
544
544
|
}
|
|
545
545
|
|
|
@@ -571,27 +571,29 @@ function registerEditTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
571
571
|
const diffNew = normalizeToLF(applied.contentNew);
|
|
572
572
|
const diffResult = generateDiffString(diffOld, diffNew);
|
|
573
573
|
const patch = generateUnifiedPatch(filePath, diffOld, diffNew);
|
|
574
|
-
const {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
warningCount,
|
|
578
|
-
} = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
|
|
574
|
+
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
575
|
+
signal,
|
|
576
|
+
});
|
|
579
577
|
return [
|
|
580
578
|
"Edit applied successfully.",
|
|
581
579
|
{ diff: diffResult.diff, patch, firstChangedLine: diffResult.firstChangedLine },
|
|
582
|
-
|
|
583
|
-
errorCount,
|
|
584
|
-
warningCount,
|
|
580
|
+
diagnostics,
|
|
585
581
|
] as const;
|
|
586
|
-
}
|
|
582
|
+
},
|
|
583
|
+
);
|
|
587
584
|
|
|
588
|
-
const text = appendLspDiagnosticText(message,
|
|
585
|
+
const text = appendLspDiagnosticText(message, diagnostics.text);
|
|
589
586
|
return {
|
|
590
587
|
content: [{ type: "text" as const, text }],
|
|
591
588
|
details: {
|
|
592
589
|
...details,
|
|
593
590
|
pendant: {
|
|
594
|
-
subtitle: formatSubtitlePath(
|
|
591
|
+
subtitle: formatSubtitlePath(
|
|
592
|
+
ctx.cwd,
|
|
593
|
+
absolutePath,
|
|
594
|
+
diagnostics.errorCount,
|
|
595
|
+
diagnostics.warningCount,
|
|
596
|
+
),
|
|
595
597
|
},
|
|
596
598
|
},
|
|
597
599
|
};
|
|
@@ -647,8 +649,9 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
647
649
|
});
|
|
648
650
|
const dir = dirname(absolutePath);
|
|
649
651
|
|
|
650
|
-
const [message, details,
|
|
651
|
-
|
|
652
|
+
const [message, details, diagnostics] = await withFileMutationQueue(
|
|
653
|
+
absolutePath,
|
|
654
|
+
async () => {
|
|
652
655
|
signal?.throwIfAborted();
|
|
653
656
|
|
|
654
657
|
// opencode: desiredBom = source.bom || next.bom —— 保留原文件 BOM,
|
|
@@ -671,28 +674,26 @@ function registerWriteTool(pi: ExtensionAPI, getService: () => LspService): void
|
|
|
671
674
|
await mkdir(dir, { recursive: true });
|
|
672
675
|
signal?.throwIfAborted();
|
|
673
676
|
await writeFile(absolutePath, desiredBom + nextText, "utf8");
|
|
674
|
-
const {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
warningCount,
|
|
678
|
-
} = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, { signal });
|
|
677
|
+
const diagnostics = await getService().lspDiagnosticsForFile(absolutePath, ctx.cwd, {
|
|
678
|
+
signal,
|
|
679
|
+
});
|
|
679
680
|
|
|
680
|
-
return [
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
diagnosticText,
|
|
684
|
-
errorCount,
|
|
685
|
-
warningCount,
|
|
686
|
-
] as const;
|
|
687
|
-
});
|
|
681
|
+
return ["Wrote file successfully.", {}, diagnostics] as const;
|
|
682
|
+
},
|
|
683
|
+
);
|
|
688
684
|
|
|
689
|
-
const text = appendLspDiagnosticText(message,
|
|
685
|
+
const text = appendLspDiagnosticText(message, diagnostics.text);
|
|
690
686
|
return {
|
|
691
687
|
content: [{ type: "text" as const, text }],
|
|
692
688
|
details: {
|
|
693
689
|
...details,
|
|
694
690
|
pendant: {
|
|
695
|
-
subtitle: formatSubtitlePath(
|
|
691
|
+
subtitle: formatSubtitlePath(
|
|
692
|
+
ctx.cwd,
|
|
693
|
+
absolutePath,
|
|
694
|
+
diagnostics.errorCount,
|
|
695
|
+
diagnostics.warningCount,
|
|
696
|
+
),
|
|
696
697
|
},
|
|
697
698
|
},
|
|
698
699
|
};
|