@flotiarenor/dsh-tool-text-editor 1.0.0
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/LICENSE +202 -0
- package/README.md +177 -0
- package/README.zh.md +167 -0
- package/cordis.patch.yml +22 -0
- package/lib/core.mjs +973 -0
- package/lib/editor.mjs +301 -0
- package/package.json +69 -0
- package/preset/preset.yml +4 -0
- package/scripts/install-preset.mjs +227 -0
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Flotiarenor
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
/**
|
|
4
|
+
* core.mjs —— 文本编辑核心。纯 Node,只用 `node:` 内置模块,不启动任何子进程。
|
|
5
|
+
*
|
|
6
|
+
* 它承担 dsh 原生 `write`/`edit` 在 Windows 上做不到的事:
|
|
7
|
+
* * **保留 UTF-8 BOM**(原生工具会丢);
|
|
8
|
+
* * **行尾跟随文件**(原生 `write` 会把 CRLF 文件拍成 LF);
|
|
9
|
+
* * 默认先出 unified diff,落盘前自动备份,记编辑台账;
|
|
10
|
+
* * 锚点可以从目标取(`grep` 正则 / `lines` 行号),不必手抄旧文本;
|
|
11
|
+
* * 匹配失败时给"最接近的候选",歧义时拒绝写盘而不是猜。
|
|
12
|
+
*
|
|
13
|
+
* 两条落盘保证:
|
|
14
|
+
* * **原子写**:同目录临时文件 + fsync + rename —— 中途被杀不会留下半个文件,也不会出现
|
|
15
|
+
* 截断写造成的空文件;
|
|
16
|
+
* * **同目标串行**:进程内按目标路径排队,并行工具调用不会互相覆盖(跨进程不串行,
|
|
17
|
+
* 那是另一件事,README 里写明了)。
|
|
18
|
+
*
|
|
19
|
+
* 备份与台账的格式:`<工作区>/.dsh/backups/<扁平化绝对路径>@<时间戳>` 与
|
|
20
|
+
* `<工作区>/.dsh/edits.log` 里的 JSONL 记录(字段见 `appendLedger`)。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
closeSync,
|
|
25
|
+
existsSync,
|
|
26
|
+
fsyncSync,
|
|
27
|
+
mkdirSync,
|
|
28
|
+
openSync,
|
|
29
|
+
readFileSync,
|
|
30
|
+
readdirSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
statSync,
|
|
33
|
+
unlinkSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
writeSync,
|
|
36
|
+
} from 'node:fs'
|
|
37
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
|
|
38
|
+
|
|
39
|
+
export const UTF8_BOM_BYTES = Buffer.from([0xef, 0xbb, 0xbf])
|
|
40
|
+
export const GUARD_DIRS = ['.git', '.dsh']
|
|
41
|
+
export const DEFAULT_CONTEXT = 3
|
|
42
|
+
/** LCS 动态规划的格子上限:超过就退化成"整块替换",避免大文件吃光内存。 */
|
|
43
|
+
const MAX_DIFF_CELLS = 4_000_000
|
|
44
|
+
|
|
45
|
+
/** 一次编辑用法错误(调用方原样返回给模型,不写盘)。 */
|
|
46
|
+
export class UsageError extends Error {}
|
|
47
|
+
|
|
48
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
49
|
+
// 一、文本与字节
|
|
50
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
/** 任意来源的文本归一为「无 BOM、\n 行尾」的逻辑文本。 */
|
|
53
|
+
export function toLf(text) {
|
|
54
|
+
if (typeof text !== 'string') return text
|
|
55
|
+
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 只看字节,判断 BOM / 行尾风格 / 是否可编辑。
|
|
60
|
+
* @param bytes - 文件原始字节。
|
|
61
|
+
* @returns { bom, eol, crlf, lf, mixed, binary, invalidUtf8 }
|
|
62
|
+
*/
|
|
63
|
+
export function analyzeBytes(bytes) {
|
|
64
|
+
const bom = bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
|
65
|
+
const body = bom ? bytes.subarray(3) : bytes
|
|
66
|
+
let crlf = 0
|
|
67
|
+
let lf = 0
|
|
68
|
+
let binary = false
|
|
69
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
70
|
+
const byte = body[i]
|
|
71
|
+
if (byte === 0) { binary = true; break }
|
|
72
|
+
if (byte === 0x0a) {
|
|
73
|
+
if (i > 0 && body[i - 1] === 0x0d) crlf += 1
|
|
74
|
+
else lf += 1
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// 判定规则:有 CRLF 且 CRLF >= 独立 LF ⇒ CRLF(跟随多数派,平局算 CRLF)
|
|
78
|
+
const eol = crlf > 0 && crlf >= lf ? '\r\n' : '\n'
|
|
79
|
+
return { bom, eol, crlf, lf, mixed: crlf > 0 && lf > 0, binary, invalidUtf8: false }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 解码为逻辑文本(无 BOM、\n 行尾)。非法 UTF-8 或二进制内容会被拒绝。
|
|
84
|
+
* @param bytes - 文件原始字节。
|
|
85
|
+
* @param label - 出错信息里用的显示路径。
|
|
86
|
+
* @returns { text, info }
|
|
87
|
+
* @throws {UsageError} 内容不可安全编辑时。
|
|
88
|
+
*/
|
|
89
|
+
export function decodeText(bytes, label) {
|
|
90
|
+
const info = analyzeBytes(bytes)
|
|
91
|
+
if (info.binary) {
|
|
92
|
+
throw new UsageError(`${label}: 含 NUL 字节,看起来是二进制文件,拒绝编辑(本工具只处理 UTF-8 文本)`)
|
|
93
|
+
}
|
|
94
|
+
const body = info.bom ? bytes.subarray(3) : bytes
|
|
95
|
+
let text
|
|
96
|
+
try {
|
|
97
|
+
text = new TextDecoder('utf-8', { fatal: true }).decode(body)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
throw new UsageError(`${label}: 不是合法 UTF-8(${error && error.message ? error.message : 'decode error'}),拒绝写盘以免损坏文件`)
|
|
100
|
+
}
|
|
101
|
+
return { text: toLf(text), info }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 逻辑文本 → 目标字节(恢复行尾 + 恢复 BOM)。绝不经过任何换行翻译层。 */
|
|
105
|
+
export function encodeText(text, info) {
|
|
106
|
+
const body = info.eol === '\n' ? text : text.replace(/\n/g, info.eol)
|
|
107
|
+
const raw = Buffer.from(body, 'utf8')
|
|
108
|
+
return info.bom ? Buffer.concat([UTF8_BOM_BYTES, raw]) : raw
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 拆成"不带换行的行数组" + 末尾是否有换行。行号 = 下标 + 1。 */
|
|
112
|
+
export function splitLines(text) {
|
|
113
|
+
const endsWithNl = text.endsWith('\n')
|
|
114
|
+
if (text === '') return { lines: [], endsWithNl: false }
|
|
115
|
+
const lines = text.split('\n')
|
|
116
|
+
if (endsWithNl) lines.pop()
|
|
117
|
+
return { lines, endsWithNl }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** splitLines 的逆运算。 */
|
|
121
|
+
export function joinLines(lines, endsWithNl) {
|
|
122
|
+
if (lines.length === 0) return ''
|
|
123
|
+
return lines.join('\n') + (endsWithNl ? '\n' : '')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 逻辑文本 → 字符下标 → 行号(1-based)的查找表。 */
|
|
127
|
+
function lineStarts(text) {
|
|
128
|
+
const starts = [0]
|
|
129
|
+
for (let i = 0; i < text.length; i += 1) if (text[i] === '\n') starts.push(i + 1)
|
|
130
|
+
return starts
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function offsetToLine(starts, offset) {
|
|
134
|
+
let lo = 0
|
|
135
|
+
let hi = starts.length - 1
|
|
136
|
+
while (lo < hi) {
|
|
137
|
+
const mid = (lo + hi + 1) >> 1
|
|
138
|
+
if (starts[mid] <= offset) lo = mid
|
|
139
|
+
else hi = mid - 1
|
|
140
|
+
}
|
|
141
|
+
return lo + 1
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
145
|
+
// 二、路径护栏与显示
|
|
146
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
function normalizeKey(path) {
|
|
149
|
+
return resolve(path).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 危险路径护栏:拒绝 `.git/`、`.dsh/` 内部,以及工作区之外的路径。
|
|
154
|
+
* @param absPath - 绝对目标路径。
|
|
155
|
+
* @param root - 工作区根。
|
|
156
|
+
* @returns 错误说明,`null` 表示放行。
|
|
157
|
+
*/
|
|
158
|
+
export function guardTarget(absPath, root) {
|
|
159
|
+
const norm = normalizeKey(absPath)
|
|
160
|
+
const parts = norm.split('/')
|
|
161
|
+
for (const dir of GUARD_DIRS) {
|
|
162
|
+
if (parts.includes(dir)) {
|
|
163
|
+
return `拒绝写入 ${dir}/ 内部(${absPath})——那是 git / dsh 自己的地盘`
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const rootKey = normalizeKey(root)
|
|
167
|
+
if (rootKey !== '' && norm !== rootKey && !norm.startsWith(rootKey + '/')) {
|
|
168
|
+
return `目标在工作区之外:${absPath}(工作区根=${root})`
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** 工作区内的相对路径(用 / 分隔),用于 diff 头与台账。 */
|
|
174
|
+
export function relativeLabel(root, absPath) {
|
|
175
|
+
const rel = relative(root, absPath)
|
|
176
|
+
if (rel === '') return basename(absPath)
|
|
177
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return absPath.replace(/\\/g, '/')
|
|
178
|
+
return rel.split(sep).join('/')
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
182
|
+
// 三、锚点定位:--lines / --grep
|
|
183
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `263` | `263:270` | `263-270` | 负数从末尾算 → 1-based 闭区间。
|
|
187
|
+
* @throws {UsageError} 格式错或越界。
|
|
188
|
+
*/
|
|
189
|
+
export function parseLinespec(spec, text) {
|
|
190
|
+
const cleaned = String(spec).trim()
|
|
191
|
+
const range = /^(-?\d+)\s*[:,-]\s*(-?\d+)$/.exec(cleaned)
|
|
192
|
+
const single = /^-?\d+$/.exec(cleaned)
|
|
193
|
+
let a
|
|
194
|
+
let b
|
|
195
|
+
if (range) {
|
|
196
|
+
a = Number(range[1])
|
|
197
|
+
b = Number(range[2])
|
|
198
|
+
} else if (single) {
|
|
199
|
+
a = Number(single[0])
|
|
200
|
+
b = a
|
|
201
|
+
} else {
|
|
202
|
+
throw new UsageError(`lines 格式应为 263 或 263:270,收到 ${JSON.stringify(spec)}`)
|
|
203
|
+
}
|
|
204
|
+
const total = splitLines(text).lines.length
|
|
205
|
+
if (a < 0) a = total + 1 + a
|
|
206
|
+
if (b < 0) b = total + 1 + b
|
|
207
|
+
if (a < 1 || b < 1 || a > total || b > total) {
|
|
208
|
+
throw new UsageError(`lines ${spec} 超出范围(该文件共 ${total} 行)`)
|
|
209
|
+
}
|
|
210
|
+
return a > b ? { start: b, end: a } : { start: a, end: b }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 取第 startLine..endLine 行的**原文**(含各行的换行符,因此替换/删除不会留下空行)。 */
|
|
214
|
+
function rawRange(text, starts, startLine, endLine) {
|
|
215
|
+
const from = starts[startLine - 1]
|
|
216
|
+
const to = endLine < starts.length ? starts[endLine] : text.length
|
|
217
|
+
return text.slice(from, to)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 正则锚点:命中的那一行(多行正则取整块)。命中多处且未声明 count 时报错并列出候选行号。
|
|
222
|
+
* @throws {UsageError} 未命中或多处歧义。
|
|
223
|
+
*/
|
|
224
|
+
export function grepSpan(pattern, text, ctx = 0, count = undefined) {
|
|
225
|
+
let re
|
|
226
|
+
try {
|
|
227
|
+
re = new RegExp(pattern, 'gm')
|
|
228
|
+
} catch (error) {
|
|
229
|
+
throw new UsageError(`grep 不是合法正则:${error.message}`)
|
|
230
|
+
}
|
|
231
|
+
const starts = lineStarts(text)
|
|
232
|
+
const { lines } = splitLines(text)
|
|
233
|
+
const hits = []
|
|
234
|
+
let match
|
|
235
|
+
while ((match = re.exec(text)) !== null) {
|
|
236
|
+
hits.push({ start: match.index, end: match.index + match[0].length })
|
|
237
|
+
if (match[0].length === 0) re.lastIndex += 1
|
|
238
|
+
if (hits.length > 5000) break
|
|
239
|
+
}
|
|
240
|
+
if (hits.length === 0) throw new UsageError(`grep ${JSON.stringify(pattern)} 在目标文件中没有命中`)
|
|
241
|
+
if (hits.length > 1 && count !== hits.length) {
|
|
242
|
+
const where = hits.slice(0, 8).map((h) => offsetToLine(starts, h.start)).join('、')
|
|
243
|
+
throw new UsageError(`grep ${JSON.stringify(pattern)} 命中 ${hits.length} 处(行 ${where})——写得更精确,或改用 lines/old_text,或用 count 声明命中数`)
|
|
244
|
+
}
|
|
245
|
+
const spans = hits.map((h) => ({
|
|
246
|
+
start: Math.max(1, offsetToLine(starts, h.start) - ctx),
|
|
247
|
+
end: Math.min(lines.length, offsetToLine(starts, Math.max(h.start, h.end - 1)) + ctx),
|
|
248
|
+
}))
|
|
249
|
+
if (spans.length === 1) {
|
|
250
|
+
return { ...spans[0], raw: rawRange(text, starts, spans[0].start, spans[0].end) }
|
|
251
|
+
}
|
|
252
|
+
spans.sort((x, y) => x.start - y.start)
|
|
253
|
+
const merged = []
|
|
254
|
+
for (const span of spans) {
|
|
255
|
+
const last = merged[merged.length - 1]
|
|
256
|
+
if (last && span.start <= last.end) last.end = Math.max(last.end, span.end)
|
|
257
|
+
else merged.push({ ...span })
|
|
258
|
+
}
|
|
259
|
+
const raw = merged.map((span) => rawRange(text, starts, span.start, span.end)).join('')
|
|
260
|
+
return { start: merged[0].start, end: merged[merged.length - 1].end, raw }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** `lines`(纯数字/区间)与 `grep`(正则)统一入口。 */
|
|
264
|
+
export function resolveAnchor(spec, text, ctx = 0, count = undefined) {
|
|
265
|
+
const cleaned = String(spec).trim()
|
|
266
|
+
const numeric = /^-?\d+(\s*[:,-]\s*-?\d+)?$/.test(cleaned)
|
|
267
|
+
const span = numeric ? parseLinespec(cleaned, text) : grepSpan(cleaned, text, ctx, count)
|
|
268
|
+
const starts = lineStarts(text)
|
|
269
|
+
return { start: span.start, end: span.end, raw: span.raw ?? rawRange(text, starts, span.start, span.end) }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
// 四、匹配:精确 → 宽松 → 最接近候选
|
|
274
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
function normalizeLine(kind, line) {
|
|
277
|
+
if (kind === 'ws') return line.replace(/[ \t]/g, '')
|
|
278
|
+
if (kind === 'trail') return line.replace(/\s+$/, '')
|
|
279
|
+
return line.trim()
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 两段文本的相似度(0..1),基于字符级 LCS。 */
|
|
283
|
+
export function similarity(a, b) {
|
|
284
|
+
if (a === b) return 1
|
|
285
|
+
if (a.length === 0 || b.length === 0) return 0
|
|
286
|
+
const n = a.length
|
|
287
|
+
const m = b.length
|
|
288
|
+
if (n * m > MAX_DIFF_CELLS) {
|
|
289
|
+
// 太大就不做 DP:用长度比当粗略下界
|
|
290
|
+
return Math.min(n, m) / Math.max(n, m)
|
|
291
|
+
}
|
|
292
|
+
const width = m + 1
|
|
293
|
+
const dp = new Int32Array((n + 1) * width)
|
|
294
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
295
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
296
|
+
dp[i * width + j] = a[i] === b[j]
|
|
297
|
+
? dp[(i + 1) * width + (j + 1)] + 1
|
|
298
|
+
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)])
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return (2 * dp[0]) / (n + m)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function findAllExact(text, old) {
|
|
305
|
+
const spans = []
|
|
306
|
+
let from = 0
|
|
307
|
+
while (true) {
|
|
308
|
+
const index = text.indexOf(old, from)
|
|
309
|
+
if (index < 0) break
|
|
310
|
+
spans.push([index, index + old.length])
|
|
311
|
+
from = index + Math.max(1, old.length)
|
|
312
|
+
}
|
|
313
|
+
return spans
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 宽松匹配:滑窗比较归一化后的行块,相似度 ≥ 0.9 视为命中。 */
|
|
317
|
+
function relaxedMatch(text, old, kind) {
|
|
318
|
+
const { lines } = splitLines(text)
|
|
319
|
+
const oldLines = splitLines(old).lines
|
|
320
|
+
if (oldLines.length === 0) return null
|
|
321
|
+
const keys = lines.map((line) => normalizeLine(kind, line))
|
|
322
|
+
const oldKeys = oldLines.map((line) => normalizeLine(kind, line))
|
|
323
|
+
const n = oldKeys.length
|
|
324
|
+
const starts = lineStarts(text)
|
|
325
|
+
let best = null
|
|
326
|
+
for (let i = 0; i + n <= lines.length; i += 1) {
|
|
327
|
+
if (keys[i] !== oldKeys[0] || keys[i + n - 1] !== oldKeys[n - 1]) continue
|
|
328
|
+
const ratio = similarity(keys.slice(i, i + n).join('\n'), oldKeys.join('\n'))
|
|
329
|
+
if (ratio >= 0.9 && (best === null || ratio > best.ratio)) best = { ratio, index: i }
|
|
330
|
+
}
|
|
331
|
+
if (best === null) return null
|
|
332
|
+
const startOffset = starts[best.index]
|
|
333
|
+
const endOffset = best.index + n < starts.length ? starts[best.index + n] : text.length
|
|
334
|
+
return [startOffset, endOffset]
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** 失败时给最接近的几处(行号 + 相似度 + 期望 vs 实际)。 */
|
|
338
|
+
export function nearestCandidates(text, old, limit = 3) {
|
|
339
|
+
const { lines } = splitLines(text)
|
|
340
|
+
const oldLines = splitLines(old).lines.length > 0 ? splitLines(old).lines : [old]
|
|
341
|
+
const n = oldLines.length
|
|
342
|
+
const firstKey = normalizeLine('loose', oldLines[0])
|
|
343
|
+
const scored = []
|
|
344
|
+
for (let i = 0; i + n <= lines.length; i += 1) {
|
|
345
|
+
const ratio = similarity(lines.slice(i, i + n).join('\n'), oldLines.join('\n'))
|
|
346
|
+
const anchored = normalizeLine('loose', lines[i]) === firstKey ? 0.05 : 0
|
|
347
|
+
scored.push({ index: i, ratio: ratio + anchored })
|
|
348
|
+
}
|
|
349
|
+
scored.sort((a, b) => b.ratio - a.ratio)
|
|
350
|
+
return scored
|
|
351
|
+
.filter((entry) => entry.ratio >= 0.35)
|
|
352
|
+
.slice(0, limit)
|
|
353
|
+
.map((entry) => ({
|
|
354
|
+
line: entry.index + 1,
|
|
355
|
+
endLine: Math.min(entry.index + n, lines.length),
|
|
356
|
+
ratio: Math.round(entry.ratio * 1000) / 1000,
|
|
357
|
+
expected: oldLines.slice(0, 12).join('\n'),
|
|
358
|
+
actual: lines.slice(entry.index, Math.min(entry.index + n, lines.length)).slice(0, 12).join('\n'),
|
|
359
|
+
}))
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* 在 text 里定位 old。
|
|
364
|
+
* @param text - 逻辑文本。
|
|
365
|
+
* @param old - 旧片段。
|
|
366
|
+
* @param options - `nth`(只取第 k 次)/ `strict`(禁用宽松匹配)/ `expect`(要求恰好 N 处)。
|
|
367
|
+
* @returns { ok, spans, mode, note, hits, candidates }
|
|
368
|
+
*/
|
|
369
|
+
export function matchLiteral(text, old, options = {}) {
|
|
370
|
+
const { nth = 0, strict = false, expect } = options
|
|
371
|
+
const hits = findAllExact(text, old)
|
|
372
|
+
if (old === '') return { ok: false, spans: [], mode: 'miss', hits, note: 'old 不能为空', candidates: [] }
|
|
373
|
+
if (nth > 0) {
|
|
374
|
+
if (hits.length < nth) {
|
|
375
|
+
return { ok: false, spans: [], mode: 'miss', hits, note: `old 只出现 ${hits.length} 次,取不到第 ${nth} 次`, candidates: nearestCandidates(text, old) }
|
|
376
|
+
}
|
|
377
|
+
return { ok: true, spans: [hits[nth - 1]], mode: nth === 1 ? 'exact' : `exact:nth(${nth})`, hits, note: nth === 1 ? '' : `取第 ${nth} 次出现`, candidates: [] }
|
|
378
|
+
}
|
|
379
|
+
if (expect !== undefined && hits.length === expect) {
|
|
380
|
+
return { ok: true, spans: hits, mode: expect === 1 ? 'exact' : `exact:count(${expect})`, hits, note: expect === 1 ? '' : `命中 ${expect} 处,全部替换`, candidates: [] }
|
|
381
|
+
}
|
|
382
|
+
if (hits.length === 1) return { ok: true, spans: hits, mode: 'exact', hits, note: '', candidates: [] }
|
|
383
|
+
if (hits.length > 1) {
|
|
384
|
+
const starts = lineStarts(text)
|
|
385
|
+
const where = hits.slice(0, 10).map(([s]) => offsetToLine(starts, s)).join('、')
|
|
386
|
+
const reason = expect !== undefined
|
|
387
|
+
? `old 出现 ${hits.length} 次(行 ${where}),与要求的 ${expect} 次不符 —— 已拒绝写盘`
|
|
388
|
+
: `old 出现 ${hits.length} 次(行 ${where})—— 用 nth 指定第几次,用 count 声明命中数,或写更长的 old`
|
|
389
|
+
return { ok: false, spans: [], mode: 'ambiguous', hits, note: reason, candidates: [] }
|
|
390
|
+
}
|
|
391
|
+
if (expect !== undefined) {
|
|
392
|
+
return { ok: false, spans: [], mode: 'miss', hits, note: `old 精确出现 0 处,要求 ${expect} 处`, candidates: nearestCandidates(text, old) }
|
|
393
|
+
}
|
|
394
|
+
if (strict) {
|
|
395
|
+
return { ok: false, spans: [], mode: 'miss', hits, note: 'old 在目标文件中不存在(strict 已禁用宽松匹配)', candidates: nearestCandidates(text, old) }
|
|
396
|
+
}
|
|
397
|
+
for (const [kind, label] of [['trail', '忽略行尾空白'], ['loose', '忽略行首/行尾空白'], ['ws', '忽略全部空白差异']]) {
|
|
398
|
+
const span = relaxedMatch(text, old, kind)
|
|
399
|
+
if (span) {
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
spans: [span],
|
|
403
|
+
mode: kind,
|
|
404
|
+
hits,
|
|
405
|
+
note: `精确匹配失败,已用宽松模式命中(${label})—— 请核对 diff 的行号`,
|
|
406
|
+
candidates: [],
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return { ok: false, spans: [], mode: 'miss', hits, note: 'old 在目标文件中不存在(精确与宽松匹配均失败)', candidates: nearestCandidates(text, old) }
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
414
|
+
// 五、unified diff
|
|
415
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
/** 行级 LCS 编辑脚本。 */
|
|
418
|
+
function lineOps(a, b) {
|
|
419
|
+
const n = a.length
|
|
420
|
+
const m = b.length
|
|
421
|
+
if (n === 0) return b.map((_, bi) => ({ t: '+', bi }))
|
|
422
|
+
if (m === 0) return a.map((_, ai) => ({ t: '-', ai }))
|
|
423
|
+
if (n * m > MAX_DIFF_CELLS) {
|
|
424
|
+
const ops = a.map((_, ai) => ({ t: '-', ai }))
|
|
425
|
+
for (let bi = 0; bi < m; bi += 1) ops.push({ t: '+', bi })
|
|
426
|
+
return ops
|
|
427
|
+
}
|
|
428
|
+
const width = m + 1
|
|
429
|
+
const dp = new Int32Array((n + 1) * width)
|
|
430
|
+
for (let i = n - 1; i >= 0; i -= 1) {
|
|
431
|
+
for (let j = m - 1; j >= 0; j -= 1) {
|
|
432
|
+
dp[i * width + j] = a[i] === b[j]
|
|
433
|
+
? dp[(i + 1) * width + (j + 1)] + 1
|
|
434
|
+
: Math.max(dp[(i + 1) * width + j], dp[i * width + (j + 1)])
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const ops = []
|
|
438
|
+
let i = 0
|
|
439
|
+
let j = 0
|
|
440
|
+
while (i < n && j < m) {
|
|
441
|
+
if (a[i] === b[j]) {
|
|
442
|
+
ops.push({ t: '=', ai: i, bi: j })
|
|
443
|
+
i += 1
|
|
444
|
+
j += 1
|
|
445
|
+
} else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) {
|
|
446
|
+
ops.push({ t: '-', ai: i })
|
|
447
|
+
i += 1
|
|
448
|
+
} else {
|
|
449
|
+
ops.push({ t: '+', bi: j })
|
|
450
|
+
j += 1
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
while (i < n) { ops.push({ t: '-', ai: i }); i += 1 }
|
|
454
|
+
while (j < m) { ops.push({ t: '+', bi: j }); j += 1 }
|
|
455
|
+
return ops
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function diffOpsFor(a, b) {
|
|
459
|
+
let prefix = 0
|
|
460
|
+
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix += 1
|
|
461
|
+
let suffix = 0
|
|
462
|
+
while (
|
|
463
|
+
suffix < a.length - prefix
|
|
464
|
+
&& suffix < b.length - prefix
|
|
465
|
+
&& a[a.length - 1 - suffix] === b[b.length - 1 - suffix]
|
|
466
|
+
) suffix += 1
|
|
467
|
+
const midA = a.slice(prefix, a.length - suffix)
|
|
468
|
+
const midB = b.slice(prefix, b.length - suffix)
|
|
469
|
+
const ops = []
|
|
470
|
+
for (let i = 0; i < prefix; i += 1) ops.push({ t: '=', ai: i, bi: i })
|
|
471
|
+
for (const op of lineOps(midA, midB)) {
|
|
472
|
+
if (op.t === '=') ops.push({ t: '=', ai: prefix + op.ai, bi: prefix + op.bi })
|
|
473
|
+
else if (op.t === '-') ops.push({ t: '-', ai: prefix + op.ai })
|
|
474
|
+
else ops.push({ t: '+', bi: prefix + op.bi })
|
|
475
|
+
}
|
|
476
|
+
for (let i = 0; i < suffix; i += 1) {
|
|
477
|
+
ops.push({ t: '=', ai: a.length - suffix + i, bi: b.length - suffix + i })
|
|
478
|
+
}
|
|
479
|
+
return ops
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* unified diff 文本;`label` 只用于 `a/` `b/` 头。
|
|
484
|
+
* 与 GNU diff 一样标出"末尾没有换行"的那一侧。
|
|
485
|
+
*/
|
|
486
|
+
export function unifiedDiff(label, before, after, context = DEFAULT_CONTEXT) {
|
|
487
|
+
if (before === after) return ''
|
|
488
|
+
const a = splitLines(before)
|
|
489
|
+
const b = splitLines(after)
|
|
490
|
+
const ops = diffOpsFor(a.lines, b.lines)
|
|
491
|
+
const noEolA = a.lines.length > 0 && !a.endsWithNl
|
|
492
|
+
const noEolB = b.lines.length > 0 && !b.endsWithNl
|
|
493
|
+
const changeIndexes = []
|
|
494
|
+
for (let i = 0; i < ops.length; i += 1) if (ops[i].t !== '=') changeIndexes.push(i)
|
|
495
|
+
if (changeIndexes.length === 0 && noEolA === noEolB) return ''
|
|
496
|
+
|
|
497
|
+
// 逐行内容相同、只差"文件末尾那个换行符":LCS 看不出差异,手工补一个 hunk
|
|
498
|
+
if (changeIndexes.length === 0) {
|
|
499
|
+
const last = a.lines.length - 1
|
|
500
|
+
const from = Math.max(0, last - context)
|
|
501
|
+
const count = last - from + 1
|
|
502
|
+
const out = [`--- a/${label}`, `+++ b/${label}`, `@@ -${from + 1},${count} +${from + 1},${count} @@`]
|
|
503
|
+
for (let i = from; i < last; i += 1) out.push(' ' + a.lines[i])
|
|
504
|
+
out.push('-' + a.lines[last])
|
|
505
|
+
if (noEolA) out.push('\')
|
|
506
|
+
out.push('+' + b.lines[last])
|
|
507
|
+
if (noEolB) out.push('\')
|
|
508
|
+
return out.join('\n') + '\n'
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const blocks = []
|
|
512
|
+
for (const index of changeIndexes) {
|
|
513
|
+
const last = blocks[blocks.length - 1]
|
|
514
|
+
if (last && index - last[last.length - 1] <= context * 2 + 1) last.push(index)
|
|
515
|
+
else blocks.push([index])
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
const out = [`--- a/${label}`, `+++ b/${label}`]
|
|
519
|
+
for (const block of blocks) {
|
|
520
|
+
const first = block[0]
|
|
521
|
+
const last = block[block.length - 1]
|
|
522
|
+
const from = Math.max(0, first - context)
|
|
523
|
+
const to = Math.min(ops.length - 1, last + context)
|
|
524
|
+
let aConsumed = 0
|
|
525
|
+
let bConsumed = 0
|
|
526
|
+
for (let i = 0; i < from; i += 1) {
|
|
527
|
+
if (ops[i].t !== '+') aConsumed += 1
|
|
528
|
+
if (ops[i].t !== '-') bConsumed += 1
|
|
529
|
+
}
|
|
530
|
+
let aLen = 0
|
|
531
|
+
let bLen = 0
|
|
532
|
+
for (let i = from; i <= to; i += 1) {
|
|
533
|
+
if (ops[i].t !== '+') aLen += 1
|
|
534
|
+
if (ops[i].t !== '-') bLen += 1
|
|
535
|
+
}
|
|
536
|
+
const aStart = aLen === 0 ? aConsumed : aConsumed + 1
|
|
537
|
+
const bStart = bLen === 0 ? bConsumed : bConsumed + 1
|
|
538
|
+
out.push(`@@ -${aStart},${aLen} +${bStart},${bLen} @@`)
|
|
539
|
+
for (let i = from; i <= to; i += 1) {
|
|
540
|
+
const op = ops[i]
|
|
541
|
+
if (op.t === '=') {
|
|
542
|
+
out.push(' ' + a.lines[op.ai])
|
|
543
|
+
if (noEolA && op.ai === a.lines.length - 1) out.push('\')
|
|
544
|
+
} else if (op.t === '-') {
|
|
545
|
+
out.push('-' + a.lines[op.ai])
|
|
546
|
+
if (noEolA && op.ai === a.lines.length - 1) out.push('\')
|
|
547
|
+
} else {
|
|
548
|
+
out.push('+' + b.lines[op.bi])
|
|
549
|
+
if (noEolB && op.bi === b.lines.length - 1) out.push('\')
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return out.join('\n') + '\n'
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** +/- 行数统计:直接数 diff 里的增删行。 */
|
|
557
|
+
export function diffStat(before, after) {
|
|
558
|
+
let added = 0
|
|
559
|
+
let removed = 0
|
|
560
|
+
for (const line of (before === after ? '' : unifiedDiff('stat', before, after)).split('\n')) {
|
|
561
|
+
if (line.startsWith('+++') || line.startsWith('---')) continue
|
|
562
|
+
if (line.startsWith('+')) added += 1
|
|
563
|
+
else if (line.startsWith('-')) removed += 1
|
|
564
|
+
}
|
|
565
|
+
return { added, removed }
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
569
|
+
// 六、备份、台账、原子写、同目标串行
|
|
570
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
function two(n, width = 2) {
|
|
573
|
+
return String(n).padStart(width, '0')
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function stamp(date) {
|
|
577
|
+
return `${date.getFullYear()}${two(date.getMonth() + 1)}${two(date.getDate())}-`
|
|
578
|
+
+ `${two(date.getHours())}${two(date.getMinutes())}${two(date.getSeconds())}-`
|
|
579
|
+
+ `${two(date.getMilliseconds(), 3)}`
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** 备份文件名:<绝对路径扁平化>@<时间戳>。 */
|
|
583
|
+
export function backupFileNameFor(absPath, date = new Date()) {
|
|
584
|
+
const flat = resolve(absPath).replace(/:/g, '').replace(/[\\/]/g, '_')
|
|
585
|
+
return `${flat}@${stamp(date)}`
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** 落盘前的原件备份。 */
|
|
589
|
+
export function makeBackup(artifactsDir, absPath, bytes, date = new Date()) {
|
|
590
|
+
const dir = join(artifactsDir, 'backups')
|
|
591
|
+
mkdirSync(dir, { recursive: true })
|
|
592
|
+
const name = backupFileNameFor(absPath, date)
|
|
593
|
+
writeFileSync(join(dir, name), bytes)
|
|
594
|
+
return name
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** 追加一条 JSONL 台账:每行一个对象,字段见 record。 */
|
|
598
|
+
export function appendLedger(artifactsDir, entry) {
|
|
599
|
+
const file = join(artifactsDir, 'edits.log')
|
|
600
|
+
mkdirSync(artifactsDir, { recursive: true })
|
|
601
|
+
const time = new Date()
|
|
602
|
+
const record = {
|
|
603
|
+
time: `${time.getFullYear()}-${two(time.getMonth() + 1)}-${two(time.getDate())} `
|
|
604
|
+
+ `${two(time.getHours())}:${two(time.getMinutes())}:${two(time.getSeconds())}`,
|
|
605
|
+
...entry,
|
|
606
|
+
}
|
|
607
|
+
let id = 1
|
|
608
|
+
if (existsSync(file)) {
|
|
609
|
+
const raw = readFileSync(file, 'utf8')
|
|
610
|
+
id = raw.split('\n').filter((line) => line.trim() !== '').length + 1
|
|
611
|
+
}
|
|
612
|
+
record.id = id
|
|
613
|
+
writeFileSync(file, JSON.stringify(record) + '\n', { encoding: 'utf8', flag: 'a' })
|
|
614
|
+
return record
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* 原子写:同目录临时文件 → fsync → rename 覆盖。
|
|
619
|
+
* 中途被杀只会留下一个 `.tmp`,目标文件永远是完整的旧内容或完整的新内容。
|
|
620
|
+
*/
|
|
621
|
+
export function writeFileAtomic(absPath, buffer) {
|
|
622
|
+
const dir = dirname(absPath)
|
|
623
|
+
let mode
|
|
624
|
+
try {
|
|
625
|
+
mode = statSync(absPath).mode
|
|
626
|
+
} catch {
|
|
627
|
+
mode = undefined
|
|
628
|
+
}
|
|
629
|
+
const tmp = join(dir, `.${basename(absPath)}.${process.pid.toString(36)}${Date.now().toString(36)}.tmp`)
|
|
630
|
+
let fd
|
|
631
|
+
try {
|
|
632
|
+
fd = openSync(tmp, 'wx', mode)
|
|
633
|
+
writeSync(fd, buffer)
|
|
634
|
+
fsyncSync(fd)
|
|
635
|
+
} finally {
|
|
636
|
+
if (fd !== undefined) closeSync(fd)
|
|
637
|
+
}
|
|
638
|
+
try {
|
|
639
|
+
renameSync(tmp, absPath)
|
|
640
|
+
} catch (error) {
|
|
641
|
+
try { unlinkSync(tmp) } catch { /* 尽力清理 */ }
|
|
642
|
+
throw error
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
const locks = new Map()
|
|
647
|
+
|
|
648
|
+
/** 同一目标路径的编辑在进程内串行(并行工具调用不会互相覆盖)。 */
|
|
649
|
+
export function withTargetLock(key, fn) {
|
|
650
|
+
const previous = locks.get(key) ?? Promise.resolve()
|
|
651
|
+
const next = previous.then(fn, fn)
|
|
652
|
+
const tail = next.then(() => {}, () => {})
|
|
653
|
+
locks.set(key, tail)
|
|
654
|
+
tail.finally(() => {
|
|
655
|
+
if (locks.get(key) === tail) locks.delete(key)
|
|
656
|
+
})
|
|
657
|
+
return next
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** 新建文件的行尾:同目录**多数派**(同扩展名优先),没有依据时 LF。 */
|
|
661
|
+
export function inferNewline(absPath) {
|
|
662
|
+
const configured = (process.env.DSH_TEXT_EDITOR_EOL ?? '').toLowerCase()
|
|
663
|
+
if (configured === 'lf') return '\n'
|
|
664
|
+
if (configured === 'crlf' || configured === 'cr') return '\r\n'
|
|
665
|
+
const dir = dirname(absPath)
|
|
666
|
+
const ext = basename(absPath).includes('.') ? basename(absPath).slice(basename(absPath).lastIndexOf('.')) : ''
|
|
667
|
+
let sameExtCrlf = 0
|
|
668
|
+
let sameExtLf = 0
|
|
669
|
+
let otherCrlf = 0
|
|
670
|
+
let otherLf = 0
|
|
671
|
+
let names
|
|
672
|
+
try {
|
|
673
|
+
names = readdirSync(dir).slice(0, 400)
|
|
674
|
+
} catch {
|
|
675
|
+
return '\n'
|
|
676
|
+
}
|
|
677
|
+
for (const name of names) {
|
|
678
|
+
const full = join(dir, name)
|
|
679
|
+
if (full === absPath) continue
|
|
680
|
+
let stat
|
|
681
|
+
try {
|
|
682
|
+
stat = statSync(full)
|
|
683
|
+
} catch {
|
|
684
|
+
continue
|
|
685
|
+
}
|
|
686
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > 1024 * 1024) continue
|
|
687
|
+
let sample
|
|
688
|
+
try {
|
|
689
|
+
sample = readFileSync(full).subarray(0, 8192)
|
|
690
|
+
} catch {
|
|
691
|
+
continue
|
|
692
|
+
}
|
|
693
|
+
if (sample.includes(0)) continue
|
|
694
|
+
const crlf = countCrlf(sample)
|
|
695
|
+
const lf = countLf(sample) - crlf
|
|
696
|
+
if (crlf === 0 && lf === 0) continue
|
|
697
|
+
const isCrlf = crlf > 0 && crlf >= lf
|
|
698
|
+
if (name.endsWith(ext)) {
|
|
699
|
+
if (isCrlf) sameExtCrlf += 1
|
|
700
|
+
else sameExtLf += 1
|
|
701
|
+
} else if (isCrlf) otherCrlf += 1
|
|
702
|
+
else otherLf += 1
|
|
703
|
+
}
|
|
704
|
+
if (sameExtCrlf + sameExtLf > 0) return sameExtCrlf >= sameExtLf ? '\r\n' : '\n'
|
|
705
|
+
if (otherCrlf + otherLf > 0) return otherCrlf >= otherLf ? '\r\n' : '\n'
|
|
706
|
+
return '\n'
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function countCrlf(buffer) {
|
|
710
|
+
let count = 0
|
|
711
|
+
for (let i = 1; i < buffer.length; i += 1) if (buffer[i] === 0x0a && buffer[i - 1] === 0x0d) count += 1
|
|
712
|
+
return count
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function countLf(buffer) {
|
|
716
|
+
let count = 0
|
|
717
|
+
for (const byte of buffer) if (byte === 0x0a) count += 1
|
|
718
|
+
return count
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
722
|
+
// 七、编辑模型
|
|
723
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
724
|
+
|
|
725
|
+
function fail(path, message) {
|
|
726
|
+
return { path, ok: false, wrote: false, dryRun: false, stdout: '', stderr: message }
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function hintText(match) {
|
|
730
|
+
if (match.mode === 'ambiguous') {
|
|
731
|
+
const spans = match.hits.slice(0, 12).map(([s]) => s)
|
|
732
|
+
return ` 候选:old 命中 ${match.hits.length} 处。用 nth 指定第几次,或 count 声明命中数。`
|
|
733
|
+
}
|
|
734
|
+
if (!match.candidates || match.candidates.length === 0) {
|
|
735
|
+
return ' 没有近似候选:核对文件是否搞错,或用 grep/lines 直接从目标取锚点(不必手抄)。'
|
|
736
|
+
}
|
|
737
|
+
const lines = [' 最接近的候选(行号 | 相似度):']
|
|
738
|
+
for (const candidate of match.candidates) {
|
|
739
|
+
lines.push(` - 行 ${candidate.line}-${candidate.endLine} 相似度 ${candidate.ratio.toFixed(2)}`)
|
|
740
|
+
lines.push(` 目标实际:${JSON.stringify(candidate.actual.slice(0, 200))}`)
|
|
741
|
+
lines.push(` 你给的 old:${JSON.stringify(candidate.expected.slice(0, 200))}`)
|
|
742
|
+
}
|
|
743
|
+
return lines.join('\n')
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* 执行一次编辑/写入。后端无关:调用方只需给出归一后的 plan。
|
|
748
|
+
*
|
|
749
|
+
* plan(edit):`{ kind:'edit', filePath, mode, newText, oldText, anchor:{value}|null, count, nth, strict, dryRun, note }`
|
|
750
|
+
* plan(write):`{ kind:'write', filePath, content, dryRun, note }`
|
|
751
|
+
*
|
|
752
|
+
* @param context - `{ root, artifactsDir, backup, log, tool, context, newFileBom }`
|
|
753
|
+
* @returns 规范结果 `{ path, ok, wrote, dryRun, stdout, stderr }`
|
|
754
|
+
*/
|
|
755
|
+
export async function applyPlan(plan, context) {
|
|
756
|
+
const {
|
|
757
|
+
root,
|
|
758
|
+
artifactsDir = join(root, '.dsh'),
|
|
759
|
+
backup = true,
|
|
760
|
+
log = true,
|
|
761
|
+
tool = 'edit_text',
|
|
762
|
+
context: diffContext = DEFAULT_CONTEXT,
|
|
763
|
+
newFileBom = false,
|
|
764
|
+
} = context
|
|
765
|
+
const absPath = isAbsolute(plan.filePath) ? resolve(plan.filePath) : resolve(root, plan.filePath)
|
|
766
|
+
const label = relativeLabel(root, absPath)
|
|
767
|
+
|
|
768
|
+
const guarded = guardTarget(absPath, root)
|
|
769
|
+
if (guarded) return fail(plan.filePath, guarded)
|
|
770
|
+
|
|
771
|
+
return await withTargetLock(absPath, async () => {
|
|
772
|
+
const exists = existsSync(absPath)
|
|
773
|
+
const kind = plan.kind === 'write' ? 'write' : 'edit'
|
|
774
|
+
if (kind === 'edit' && !exists) {
|
|
775
|
+
return fail(plan.filePath, `目标不存在:${plan.filePath}(新建请用 write_text)`)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
let original = ''
|
|
779
|
+
let info
|
|
780
|
+
let bytesBefore = Buffer.alloc(0)
|
|
781
|
+
if (exists) {
|
|
782
|
+
bytesBefore = readFileSync(absPath)
|
|
783
|
+
try {
|
|
784
|
+
const decoded = decodeText(bytesBefore, label)
|
|
785
|
+
original = decoded.text
|
|
786
|
+
info = decoded.info
|
|
787
|
+
} catch (error) {
|
|
788
|
+
if (error instanceof UsageError) return fail(plan.filePath, error.message)
|
|
789
|
+
throw error
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
info = { bom: newFileBom, eol: inferNewline(absPath), crlf: 0, lf: 0, mixed: false, binary: false }
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const warnings = []
|
|
796
|
+
if (info.mixed) {
|
|
797
|
+
warnings.push(`[warn] 目标文件行尾混用;写回统一为 ${info.eol === '\r\n' ? 'CRLF' : 'LF'}`)
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
let output
|
|
801
|
+
const applied = []
|
|
802
|
+
|
|
803
|
+
if (kind === 'write') {
|
|
804
|
+
output = toLf(plan.content ?? '')
|
|
805
|
+
if (output === original && exists) {
|
|
806
|
+
return fail(plan.filePath, '没有产生任何变化(新内容与现有内容一致)')
|
|
807
|
+
}
|
|
808
|
+
if (!output.endsWith('\n') && output !== '') {
|
|
809
|
+
warnings.push('[warn] 新内容不以换行结尾,文件末尾将没有换行符')
|
|
810
|
+
}
|
|
811
|
+
applied.push(['write', 1, Math.max(1, splitLines(output).lines.length), 'write', plan.note ?? ''])
|
|
812
|
+
} else {
|
|
813
|
+
const usage = []
|
|
814
|
+
const { lines } = splitLines(original)
|
|
815
|
+
if (plan.mode === 'append' || plan.mode === 'prepend') {
|
|
816
|
+
output = plan.mode === 'append' ? original : ''
|
|
817
|
+
if (plan.mode === 'append') {
|
|
818
|
+
let add = plan.newText
|
|
819
|
+
if (output !== '' && !output.endsWith('\n')) {
|
|
820
|
+
add = '\n' + add
|
|
821
|
+
warnings.push('[warn] 目标末尾本来没有换行符,已在追加前补一个')
|
|
822
|
+
}
|
|
823
|
+
if (add !== '' && !add.endsWith('\n')) warnings.push('[warn] 追加内容不以换行结尾,文件末尾将没有换行符')
|
|
824
|
+
output += add
|
|
825
|
+
} else {
|
|
826
|
+
let add = plan.newText
|
|
827
|
+
if (original !== '' && add !== '' && !add.endsWith('\n')) add += '\n'
|
|
828
|
+
output += add + original
|
|
829
|
+
}
|
|
830
|
+
applied.push([plan.mode, 1, 1, plan.mode, plan.note ?? ''])
|
|
831
|
+
} else {
|
|
832
|
+
const plans = []
|
|
833
|
+
if (plan.mode === 'replace') {
|
|
834
|
+
let target = plan.oldText
|
|
835
|
+
let match
|
|
836
|
+
if (plan.anchor) {
|
|
837
|
+
let anchorSpan
|
|
838
|
+
try {
|
|
839
|
+
anchorSpan = resolveAnchor(plan.anchor.value, original, 0)
|
|
840
|
+
} catch (error) {
|
|
841
|
+
usage.push(error instanceof UsageError ? error.message : String(error))
|
|
842
|
+
anchorSpan = null
|
|
843
|
+
}
|
|
844
|
+
if (anchorSpan === null) return fail(plan.filePath, usage.join('\n'))
|
|
845
|
+
target = anchorSpan.raw
|
|
846
|
+
const exact = matchLiteral(original, target, { nth: plan.nth ?? 0, strict: true })
|
|
847
|
+
match = exact.ok ? exact : matchLiteral(original, target, { nth: plan.nth ?? 0, strict: false })
|
|
848
|
+
if (match.ok) match.mode = `anchor:${match.mode}`
|
|
849
|
+
} else {
|
|
850
|
+
match = matchLiteral(original, target ?? '', {
|
|
851
|
+
nth: plan.nth ?? 0,
|
|
852
|
+
strict: plan.strict === true,
|
|
853
|
+
expect: plan.count,
|
|
854
|
+
})
|
|
855
|
+
}
|
|
856
|
+
if (!match.ok) {
|
|
857
|
+
return fail(plan.filePath, `${label}:${match.note}\n${hintText(match)}`)
|
|
858
|
+
}
|
|
859
|
+
if (plan.count !== undefined && (plan.nth ?? 0) === 0 && match.hits.length !== plan.count) {
|
|
860
|
+
return fail(plan.filePath, `${label}:old 出现 ${match.hits.length} 次,要求 ${plan.count} 次 —— 拒绝写盘`)
|
|
861
|
+
}
|
|
862
|
+
for (const [start, end] of match.spans) {
|
|
863
|
+
const startLine = offsetToLine(lineStarts(original), start)
|
|
864
|
+
const endLine = end > start ? offsetToLine(lineStarts(original), end - 1) : startLine
|
|
865
|
+
plans.push({ start, end, mode: match.mode, startLine, endLine })
|
|
866
|
+
}
|
|
867
|
+
if (match.mode === 'trail' || match.mode === 'loose' || match.mode === 'ws' || String(match.mode).includes('trail') || String(match.mode).includes('loose')) {
|
|
868
|
+
warnings.push(`[warn] ${match.note}`)
|
|
869
|
+
}
|
|
870
|
+
} else {
|
|
871
|
+
let anchorSpan
|
|
872
|
+
try {
|
|
873
|
+
anchorSpan = resolveAnchor(plan.anchor.value, original, 0)
|
|
874
|
+
} catch (error) {
|
|
875
|
+
return fail(plan.filePath, error instanceof UsageError ? error.message : String(error))
|
|
876
|
+
}
|
|
877
|
+
const starts = lineStarts(original)
|
|
878
|
+
const position = plan.mode === 'after'
|
|
879
|
+
? (anchorSpan.end < starts.length ? starts[anchorSpan.end] : original.length)
|
|
880
|
+
: starts[anchorSpan.start - 1]
|
|
881
|
+
plans.push({
|
|
882
|
+
start: position,
|
|
883
|
+
end: position,
|
|
884
|
+
mode: plan.mode,
|
|
885
|
+
startLine: anchorSpan.start,
|
|
886
|
+
endLine: anchorSpan.end,
|
|
887
|
+
})
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const sorted = [...plans].sort((a, b) => a.start - b.start || a.end - b.end)
|
|
891
|
+
for (let i = 1; i < sorted.length; i += 1) {
|
|
892
|
+
const previous = sorted[i - 1]
|
|
893
|
+
const current = sorted[i]
|
|
894
|
+
if (current.start < previous.end) {
|
|
895
|
+
return fail(plan.filePath, `两次编辑区间重叠(行 ${previous.startLine} 与 ${current.startLine})——请拆成两次调用`)
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
output = original
|
|
900
|
+
for (const item of [...plans].sort((a, b) => b.start - a.start)) {
|
|
901
|
+
output = output.slice(0, item.start) + plan.newText + output.slice(item.end)
|
|
902
|
+
applied.push(['replace', item.startLine, item.endLine, item.mode, plan.note ?? ''])
|
|
903
|
+
}
|
|
904
|
+
void lines
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
if (output === original && exists) {
|
|
909
|
+
return fail(plan.filePath, '没有产生任何变化(old 与 new 相同,或内容已一致)')
|
|
910
|
+
}
|
|
911
|
+
if (!exists && output === '') {
|
|
912
|
+
return fail(plan.filePath, '新建内容为空 —— 没有产生任何变化')
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
const diff = unifiedDiff(label, original, output, diffContext)
|
|
916
|
+
const stat = diffStat(original, output)
|
|
917
|
+
const created = !exists
|
|
918
|
+
const head = plan.dryRun
|
|
919
|
+
? `=== ${label}${created ? '(新建)' : ''} | DRY RUN(未落盘)===`
|
|
920
|
+
: `=== ${label}${created ? '(新建)' : ''} | 已写入 ===`
|
|
921
|
+
const kinds = [...new Set(applied.map(([k, s, e]) => (k === 'replace' ? `replace@${s}${e !== s ? `-${e}` : ''}` : k)))]
|
|
922
|
+
.join('、')
|
|
923
|
+
|
|
924
|
+
if (plan.dryRun) {
|
|
925
|
+
return {
|
|
926
|
+
path: plan.filePath,
|
|
927
|
+
ok: true,
|
|
928
|
+
wrote: false,
|
|
929
|
+
dryRun: true,
|
|
930
|
+
stdout: [...warnings, head, diff.trimEnd(), `DRY RUN ${label}:${kinds}(+${stat.added}/-${stat.removed})`].filter((s) => s !== '').join('\n') + '\n',
|
|
931
|
+
stderr: '',
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
let backupName = null
|
|
936
|
+
if (exists && backup) {
|
|
937
|
+
backupName = makeBackup(artifactsDir, absPath, bytesBefore)
|
|
938
|
+
}
|
|
939
|
+
try {
|
|
940
|
+
writeFileAtomic(absPath, encodeText(output, info))
|
|
941
|
+
} catch (error) {
|
|
942
|
+
return fail(plan.filePath, `写入失败:${error && error.message ? error.message : String(error)}`)
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (log) {
|
|
946
|
+
const first = applied[0] ?? ['?', 0, 0, '', '']
|
|
947
|
+
appendLedger(artifactsDir, {
|
|
948
|
+
tool,
|
|
949
|
+
file: label,
|
|
950
|
+
abspath: absPath,
|
|
951
|
+
action: created ? 'create' : 'write',
|
|
952
|
+
kinds: applied.map(([k]) => k),
|
|
953
|
+
line_start: first[1],
|
|
954
|
+
line_end: first[2],
|
|
955
|
+
added: stat.added,
|
|
956
|
+
removed: stat.removed,
|
|
957
|
+
bom: info.bom,
|
|
958
|
+
eol: info.eol === '\r\n' ? 'CRLF' : 'LF',
|
|
959
|
+
backup: backupName,
|
|
960
|
+
summary: `${kinds}${plan.note ? `;${plan.note}` : ''}`,
|
|
961
|
+
})
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
return {
|
|
965
|
+
path: plan.filePath,
|
|
966
|
+
ok: true,
|
|
967
|
+
wrote: true,
|
|
968
|
+
dryRun: false,
|
|
969
|
+
stdout: [...warnings, head, diff.trimEnd(), `OK ${label}:${kinds}(+${stat.added}/-${stat.removed})${backupName ? `;备份 ${backupName}` : ''}`].filter((s) => s !== '').join('\n') + '\n',
|
|
970
|
+
stderr: '',
|
|
971
|
+
}
|
|
972
|
+
})
|
|
973
|
+
}
|