dsh-ops 0.0.0-stage → 0.2.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.
- package/CHANGELOG.md +202 -0
- package/LICENSE +30 -0
- package/NOTICE +106 -0
- package/PROVENANCE.md +435 -0
- package/README.en.md +126 -0
- package/README.md +115 -2
- package/README.zh.md +116 -0
- package/bin/dsh-ops.mjs +1216 -0
- package/cordis.patch.yml +160 -0
- package/docs/manual-validation.md +53 -0
- package/docs/release-0.2.1.md +72 -0
- package/docs/schema-baseline.json +64 -0
- package/docs/schema-current.json +84 -0
- package/docs/schema-measurement.md +17 -0
- package/dsh-plugin.json +88 -0
- package/icon.svg +12 -0
- package/lib/binary.js +409 -0
- package/lib/config.js +198 -0
- package/lib/handshake.js +252 -0
- package/lib/index.js +108 -0
- package/lib/jobs.js +42 -0
- package/lib/policy.js +64 -0
- package/lib/presentation.js +63 -0
- package/lib/profile-install.js +61 -0
- package/lib/rust.js +194 -0
- package/lib/session-shells.js +78 -0
- package/lib/shells.js +998 -0
- package/lib/tools.js +657 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +114 -4
- package/vendor/fastctx/Cargo.lock +3210 -0
- package/vendor/fastctx/Cargo.toml +94 -0
- package/vendor/fastctx/FORK.md +119 -0
- package/vendor/fastctx/LICENSE-APACHE +201 -0
- package/vendor/fastctx/NOTICE +40 -0
- package/vendor/fastctx/README.md +439 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
- package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
- package/vendor/fastctx/UPSTREAM.md +49 -0
- package/vendor/fastctx/build.rs +413 -0
- package/vendor/fastctx/src/background_status.rs +403 -0
- package/vendor/fastctx/src/binary.rs +75 -0
- package/vendor/fastctx/src/bounded_sort.rs +500 -0
- package/vendor/fastctx/src/budget.rs +781 -0
- package/vendor/fastctx/src/cli/mod.rs +110 -0
- package/vendor/fastctx/src/context_guard.rs +289 -0
- package/vendor/fastctx/src/control/mod.rs +6 -0
- package/vendor/fastctx/src/control/paths.rs +49 -0
- package/vendor/fastctx/src/control/settings.rs +753 -0
- package/vendor/fastctx/src/control/transaction.rs +531 -0
- package/vendor/fastctx/src/edit/document.rs +535 -0
- package/vendor/fastctx/src/edit/locks.rs +371 -0
- package/vendor/fastctx/src/edit/mod.rs +213 -0
- package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
- package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
- package/vendor/fastctx/src/edit/private_storage.rs +234 -0
- package/vendor/fastctx/src/edit/replace.rs +1030 -0
- package/vendor/fastctx/src/edit_server.rs +53 -0
- package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
- package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
- package/vendor/fastctx/src/encoding.rs +1118 -0
- package/vendor/fastctx/src/file_executor.rs +1151 -0
- package/vendor/fastctx/src/file_snapshot.rs +1491 -0
- package/vendor/fastctx/src/glob_filter.rs +98 -0
- package/vendor/fastctx/src/glob_tool.rs +653 -0
- package/vendor/fastctx/src/grep_sink.rs +1162 -0
- package/vendor/fastctx/src/grep_tool.rs +2449 -0
- package/vendor/fastctx/src/lib.rs +45 -0
- package/vendor/fastctx/src/main.rs +15 -0
- package/vendor/fastctx/src/model.rs +51 -0
- package/vendor/fastctx/src/model_guidance.rs +62 -0
- package/vendor/fastctx/src/operation.rs +356 -0
- package/vendor/fastctx/src/ordered_window.rs +1235 -0
- package/vendor/fastctx/src/os_environment.rs +414 -0
- package/vendor/fastctx/src/path_codec.rs +850 -0
- package/vendor/fastctx/src/paths.rs +244 -0
- package/vendor/fastctx/src/process_identity.rs +763 -0
- package/vendor/fastctx/src/process_policy.rs +74 -0
- package/vendor/fastctx/src/read_tool/batch.rs +496 -0
- package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
- package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
- package/vendor/fastctx/src/read_tool/mod.rs +245 -0
- package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
- package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
- package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
- package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
- package/vendor/fastctx/src/render_plan.rs +468 -0
- package/vendor/fastctx/src/runtime/activity.rs +159 -0
- package/vendor/fastctx/src/runtime/hosts.rs +99 -0
- package/vendor/fastctx/src/runtime/journal.rs +556 -0
- package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
- package/vendor/fastctx/src/runtime/mod.rs +746 -0
- package/vendor/fastctx/src/runtime/protocol.rs +296 -0
- package/vendor/fastctx/src/runtime/session.rs +536 -0
- package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
- package/vendor/fastctx/src/search_parallelism.rs +106 -0
- package/vendor/fastctx/src/search_text.rs +227 -0
- package/vendor/fastctx/src/server.rs +359 -0
- package/vendor/fastctx/src/server_manifest.rs +468 -0
- package/vendor/fastctx/src/server_support.rs +826 -0
- package/vendor/fastctx/src/session.rs +629 -0
- package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
- package/vendor/fastctx/src/shell/bash.rs +263 -0
- package/vendor/fastctx/src/shell/buffer.rs +108 -0
- package/vendor/fastctx/src/shell/encoding.rs +403 -0
- package/vendor/fastctx/src/shell/foreground.rs +115 -0
- package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
- package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
- package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
- package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
- package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
- package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
- package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
- package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
- package/vendor/fastctx/src/shell/mod.rs +345 -0
- package/vendor/fastctx/src/shell/normalize.rs +389 -0
- package/vendor/fastctx/src/shell/output.rs +406 -0
- package/vendor/fastctx/src/shell/process.rs +493 -0
- package/vendor/fastctx/src/shell_server.rs +156 -0
- package/vendor/fastctx/src/skip_report.rs +83 -0
- package/vendor/fastctx/src/stdio_transport.rs +177 -0
- package/vendor/fastctx/src/tool_schema.rs +204 -0
- package/vendor/fastctx/src/traversal.rs +846 -0
- package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
- package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
//! Two-pass batch replacement with full blast-radius counting and per-file CAS commits.
|
|
2
|
+
|
|
3
|
+
use super::document::TextDocument;
|
|
4
|
+
use super::locks::{FilePathLock, PathIdentity};
|
|
5
|
+
use super::{ReplaceRequest, ReplaceService, edit_token_budget, plural};
|
|
6
|
+
use crate::budget::{
|
|
7
|
+
GLOBAL_TOKEN_BUDGET_ENV, assemble_text, estimate_tokens, tool_token_budget_for_required,
|
|
8
|
+
};
|
|
9
|
+
use crate::glob_filter::{GlobPatterns, PathGlobFilter};
|
|
10
|
+
use crate::model::ToolResponse;
|
|
11
|
+
use crate::skip_report::{SkipTally, terminal_with_skips};
|
|
12
|
+
use regex::{Captures, Regex, RegexBuilder};
|
|
13
|
+
use std::collections::BTreeMap;
|
|
14
|
+
use std::fs;
|
|
15
|
+
use std::path::{Path, PathBuf};
|
|
16
|
+
|
|
17
|
+
/// The paths a walk never entered: those worth listing, and how many there were.
|
|
18
|
+
#[derive(Clone, Copy)]
|
|
19
|
+
struct Unreachable<'a> {
|
|
20
|
+
issues: &'a [Issue],
|
|
21
|
+
total: usize,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_CANDIDATES: usize = 10_000;
|
|
25
|
+
const MAX_STORED_PREVIEWS: usize = 100_000;
|
|
26
|
+
|
|
27
|
+
#[derive(Debug)]
|
|
28
|
+
struct AnalyzedFile {
|
|
29
|
+
path: String,
|
|
30
|
+
name_identity: PathIdentity,
|
|
31
|
+
identity: PathIdentity,
|
|
32
|
+
revision: String,
|
|
33
|
+
matches: usize,
|
|
34
|
+
previews: Vec<String>,
|
|
35
|
+
previews_truncated: bool,
|
|
36
|
+
used_fallback: bool,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
#[derive(Debug)]
|
|
40
|
+
struct Issue {
|
|
41
|
+
path: String,
|
|
42
|
+
message: String,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
#[derive(Debug)]
|
|
46
|
+
struct ReportGroup {
|
|
47
|
+
lines: Vec<String>,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
pub(super) fn replace(
|
|
51
|
+
editor: &ReplaceService,
|
|
52
|
+
request: ReplaceRequest,
|
|
53
|
+
max_file_size_mib: u64,
|
|
54
|
+
) -> ToolResponse {
|
|
55
|
+
let budget = match edit_token_budget() {
|
|
56
|
+
Ok(budget) => budget,
|
|
57
|
+
Err(error) => return ToolResponse::error(error),
|
|
58
|
+
};
|
|
59
|
+
if request.path.is_empty() {
|
|
60
|
+
return ToolResponse::error(
|
|
61
|
+
"The path parameter is required. Give the absolute file or directory to edit.",
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
let root = match resolve_root(&request.path) {
|
|
65
|
+
Ok(root) => root,
|
|
66
|
+
Err(error) => return ToolResponse::error(error),
|
|
67
|
+
};
|
|
68
|
+
let single_file = root.is_file();
|
|
69
|
+
if single_file && request.fallback_encoding.is_some() {
|
|
70
|
+
return ToolResponse::error(
|
|
71
|
+
"The fallback_encoding parameter only applies to directory targets; use encoding for a single file.",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if !single_file && request.encoding.is_some() {
|
|
75
|
+
return ToolResponse::error(
|
|
76
|
+
"The encoding parameter only applies to single-file targets; use fallback_encoding for a directory.",
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if let Some(encoding) = request.encoding.as_deref()
|
|
80
|
+
&& let Err(rejection) = crate::encoding::canonical_encoding_label(encoding)
|
|
81
|
+
{
|
|
82
|
+
return ToolResponse::error(rejection.message(&crate::paths::display_path(&root)));
|
|
83
|
+
}
|
|
84
|
+
if let Some(encoding) = request.fallback_encoding.as_deref()
|
|
85
|
+
&& let Err(rejection) = crate::encoding::canonical_encoding_label(encoding)
|
|
86
|
+
{
|
|
87
|
+
return ToolResponse::error(rejection.message(&crate::paths::display_path(&root)));
|
|
88
|
+
}
|
|
89
|
+
let compiled = match build_regex(&request) {
|
|
90
|
+
Ok(compiled) => compiled,
|
|
91
|
+
Err(error) => return ToolResponse::error(error),
|
|
92
|
+
};
|
|
93
|
+
if compiled.can_match_empty && request.max_replacements.is_none() {
|
|
94
|
+
return ToolResponse::error(
|
|
95
|
+
"This pattern can match empty (zero-width) and would insert at every position. Set max_replacements to cap the blast radius, then retry.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if let Err(error) = validate_replacement_references(&compiled.regex, &request.replacement) {
|
|
99
|
+
return ToolResponse::error(error);
|
|
100
|
+
}
|
|
101
|
+
let regex = compiled.regex;
|
|
102
|
+
let glob = match build_glob(request.glob.as_ref()) {
|
|
103
|
+
Ok(glob) => glob,
|
|
104
|
+
Err(error) => return ToolResponse::error(error),
|
|
105
|
+
};
|
|
106
|
+
let collected = match crate::traversal::collect_project_candidates(&root, glob.as_ref(), None) {
|
|
107
|
+
Ok(collected) => collected,
|
|
108
|
+
Err(error) => return ToolResponse::error(error),
|
|
109
|
+
};
|
|
110
|
+
// Paths the walk never entered hide an unknown number of files. For a tool
|
|
111
|
+
// that writes, that is a coverage hole, not a footnote.
|
|
112
|
+
let unreachable_issues = collected
|
|
113
|
+
.skipped
|
|
114
|
+
.listed()
|
|
115
|
+
.map(|path| Issue {
|
|
116
|
+
path: path.display.to_string(),
|
|
117
|
+
message: path.reason.to_string(),
|
|
118
|
+
})
|
|
119
|
+
.collect::<Vec<_>>();
|
|
120
|
+
let unreachable = Unreachable {
|
|
121
|
+
issues: &unreachable_issues,
|
|
122
|
+
total: collected.skipped.total(),
|
|
123
|
+
};
|
|
124
|
+
let candidates = collected.items;
|
|
125
|
+
if candidates.len() > MAX_CANDIDATES {
|
|
126
|
+
return ToolResponse::error(
|
|
127
|
+
"Too many candidate files: over 10000 matched. Narrow the path or glob.",
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let mut analyzed = Vec::new();
|
|
132
|
+
let mut skipped = Vec::new();
|
|
133
|
+
let mut planning_failures = Vec::new();
|
|
134
|
+
let mut seen_identities = BTreeMap::new();
|
|
135
|
+
let mut total_matches = 0_usize;
|
|
136
|
+
let mut preview_slots = budget
|
|
137
|
+
.saturating_mul(4)
|
|
138
|
+
.saturating_add(32)
|
|
139
|
+
.clamp(1, MAX_STORED_PREVIEWS);
|
|
140
|
+
for candidate in candidates {
|
|
141
|
+
let opened = open_candidate(
|
|
142
|
+
&candidate.display,
|
|
143
|
+
request.encoding.as_deref(),
|
|
144
|
+
request.fallback_encoding.as_deref(),
|
|
145
|
+
max_file_size_mib,
|
|
146
|
+
);
|
|
147
|
+
let (document, used_fallback) = match opened {
|
|
148
|
+
Ok(opened) => opened,
|
|
149
|
+
Err(error) if is_binary_error(&error) => {
|
|
150
|
+
if single_file {
|
|
151
|
+
return ToolResponse::error(error);
|
|
152
|
+
}
|
|
153
|
+
skipped.push(Issue {
|
|
154
|
+
path: candidate.display,
|
|
155
|
+
message: "binary file".to_string(),
|
|
156
|
+
});
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
Err(error) if is_skippable_error(&error) => {
|
|
160
|
+
if single_file {
|
|
161
|
+
return ToolResponse::error(error);
|
|
162
|
+
}
|
|
163
|
+
skipped.push(Issue {
|
|
164
|
+
path: candidate.display,
|
|
165
|
+
message: short_issue(&error),
|
|
166
|
+
});
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
Err(error) => {
|
|
170
|
+
if single_file {
|
|
171
|
+
return ToolResponse::error(error);
|
|
172
|
+
}
|
|
173
|
+
planning_failures.push(Issue {
|
|
174
|
+
path: candidate.display,
|
|
175
|
+
message: error,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
let name_identity = match PathIdentity::for_name(document.target_path()) {
|
|
181
|
+
Ok(identity) => identity,
|
|
182
|
+
Err(error) => {
|
|
183
|
+
if single_file {
|
|
184
|
+
return ToolResponse::error(error);
|
|
185
|
+
}
|
|
186
|
+
planning_failures.push(Issue {
|
|
187
|
+
path: candidate.display,
|
|
188
|
+
message: error,
|
|
189
|
+
});
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
let identity = match PathIdentity::for_existing(document.target_path()) {
|
|
194
|
+
Ok(identity) => identity,
|
|
195
|
+
Err(error) => {
|
|
196
|
+
if single_file {
|
|
197
|
+
return ToolResponse::error(error);
|
|
198
|
+
}
|
|
199
|
+
planning_failures.push(Issue {
|
|
200
|
+
path: candidate.display,
|
|
201
|
+
message: error,
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
if seen_identities
|
|
207
|
+
.insert(identity.clone(), document.display_path())
|
|
208
|
+
.is_some()
|
|
209
|
+
{
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
let analysis = analyze_file(&document, ®ex, &request.replacement, preview_slots);
|
|
213
|
+
preview_slots = preview_slots.saturating_sub(analysis.previews.len());
|
|
214
|
+
total_matches = total_matches.saturating_add(analysis.matches);
|
|
215
|
+
if analysis.matches == 0 {
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
if let Err(message) =
|
|
219
|
+
validate_replacement(&document, ®ex, &request.replacement, max_file_size_mib)
|
|
220
|
+
{
|
|
221
|
+
if single_file {
|
|
222
|
+
return ToolResponse::error(message);
|
|
223
|
+
}
|
|
224
|
+
planning_failures.push(Issue {
|
|
225
|
+
path: document.display_path(),
|
|
226
|
+
message,
|
|
227
|
+
});
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
analyzed.push(AnalyzedFile {
|
|
231
|
+
path: document.display_path(),
|
|
232
|
+
name_identity,
|
|
233
|
+
identity,
|
|
234
|
+
revision: document.revision(),
|
|
235
|
+
matches: analysis.matches,
|
|
236
|
+
previews_truncated: analysis.matches > analysis.previews.len(),
|
|
237
|
+
previews: analysis.previews,
|
|
238
|
+
used_fallback,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if let Some(maximum) = request.max_replacements
|
|
243
|
+
&& total_matches > maximum
|
|
244
|
+
{
|
|
245
|
+
return ToolResponse::error(format!(
|
|
246
|
+
"Refusing to write: {total_matches} matches exceed max_replacements={maximum}. Raise the cap or narrow the pattern; nothing was written."
|
|
247
|
+
));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
let dry_run = request.dry_run.unwrap_or(false);
|
|
251
|
+
let fallback_label = request
|
|
252
|
+
.fallback_encoding
|
|
253
|
+
.as_deref()
|
|
254
|
+
.and_then(|value| crate::encoding::canonical_encoding_label(value).ok());
|
|
255
|
+
if dry_run {
|
|
256
|
+
return format_dry_run(
|
|
257
|
+
&analyzed,
|
|
258
|
+
&skipped,
|
|
259
|
+
&planning_failures,
|
|
260
|
+
unreachable,
|
|
261
|
+
total_matches,
|
|
262
|
+
budget,
|
|
263
|
+
fallback_label,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let mut successes = Vec::new();
|
|
268
|
+
let mut failures = planning_failures;
|
|
269
|
+
let mut written_replacements = 0_usize;
|
|
270
|
+
let mut ordered = analyzed.iter().enumerate().collect::<Vec<_>>();
|
|
271
|
+
ordered.sort_by(|(_, left), (_, right)| {
|
|
272
|
+
left.identity
|
|
273
|
+
.cmp(&right.identity)
|
|
274
|
+
.then_with(|| left.path.as_bytes().cmp(right.path.as_bytes()))
|
|
275
|
+
});
|
|
276
|
+
for (original_index, file) in ordered {
|
|
277
|
+
let path = Path::new(&file.path);
|
|
278
|
+
let name_process_lock = editor.path_locks.for_identity(&file.name_identity);
|
|
279
|
+
let _name_process_guard = name_process_lock.lock().unwrap();
|
|
280
|
+
let _name_file_guard = match FilePathLock::acquire(&file.name_identity, path) {
|
|
281
|
+
Ok(guard) => guard,
|
|
282
|
+
Err(error) => {
|
|
283
|
+
failures.push(Issue {
|
|
284
|
+
path: file.path.clone(),
|
|
285
|
+
message: error,
|
|
286
|
+
});
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
let target_process_lock = editor.path_locks.for_identity(&file.identity);
|
|
291
|
+
let _target_process_guard = target_process_lock.lock().unwrap();
|
|
292
|
+
let _target_file_guard = match FilePathLock::acquire(&file.identity, path) {
|
|
293
|
+
Ok(guard) => guard,
|
|
294
|
+
Err(error) => {
|
|
295
|
+
failures.push(Issue {
|
|
296
|
+
path: file.path.clone(),
|
|
297
|
+
message: error,
|
|
298
|
+
});
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
let document = match TextDocument::open(
|
|
303
|
+
&file.path,
|
|
304
|
+
if single_file {
|
|
305
|
+
request.encoding.as_deref()
|
|
306
|
+
} else if file.used_fallback {
|
|
307
|
+
request.fallback_encoding.as_deref()
|
|
308
|
+
} else {
|
|
309
|
+
None
|
|
310
|
+
},
|
|
311
|
+
max_file_size_mib,
|
|
312
|
+
) {
|
|
313
|
+
Ok(document) => document,
|
|
314
|
+
Err(error) => {
|
|
315
|
+
failures.push(Issue {
|
|
316
|
+
path: file.path.clone(),
|
|
317
|
+
message: error,
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
let current_identity = match PathIdentity::for_existing(document.target_path()) {
|
|
323
|
+
Ok(identity) => identity,
|
|
324
|
+
Err(error) => {
|
|
325
|
+
failures.push(Issue {
|
|
326
|
+
path: file.path.clone(),
|
|
327
|
+
message: error,
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
if current_identity != file.identity {
|
|
333
|
+
failures.push(Issue {
|
|
334
|
+
path: file.path.clone(),
|
|
335
|
+
message: format!(
|
|
336
|
+
"{} changed on disk during the edit; nothing was written. Re-read it and retry.",
|
|
337
|
+
file.path
|
|
338
|
+
),
|
|
339
|
+
});
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if document.revision() != file.revision {
|
|
343
|
+
failures.push(Issue {
|
|
344
|
+
path: file.path.clone(),
|
|
345
|
+
message: format!(
|
|
346
|
+
"{} changed on disk during the edit; nothing was written. Re-read it and retry.",
|
|
347
|
+
file.path
|
|
348
|
+
),
|
|
349
|
+
});
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
let built =
|
|
353
|
+
match build_replacement(&document, ®ex, &request.replacement, max_file_size_mib) {
|
|
354
|
+
Ok(built) => built,
|
|
355
|
+
Err(error) => {
|
|
356
|
+
failures.push(Issue {
|
|
357
|
+
path: file.path.clone(),
|
|
358
|
+
message: error,
|
|
359
|
+
});
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
if built.matches != file.matches {
|
|
364
|
+
failures.push(Issue {
|
|
365
|
+
path: file.path.clone(),
|
|
366
|
+
message: format!(
|
|
367
|
+
"{} changed on disk during the edit; nothing was written. Re-read it and retry.",
|
|
368
|
+
file.path
|
|
369
|
+
),
|
|
370
|
+
});
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if built.bytes == document.original_bytes() {
|
|
374
|
+
successes.push((original_index, file.path.clone(), built.matches));
|
|
375
|
+
written_replacements = written_replacements.saturating_add(built.matches);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
match document.commit(&built.bytes) {
|
|
379
|
+
Ok(()) => {
|
|
380
|
+
successes.push((original_index, file.path.clone(), built.matches));
|
|
381
|
+
written_replacements = written_replacements.saturating_add(built.matches);
|
|
382
|
+
}
|
|
383
|
+
Err(error) => failures.push(Issue {
|
|
384
|
+
path: file.path.clone(),
|
|
385
|
+
message: error,
|
|
386
|
+
}),
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
successes.sort_by_key(|(index, _, _)| *index);
|
|
391
|
+
let successes = successes
|
|
392
|
+
.into_iter()
|
|
393
|
+
.map(|(_, path, matches)| (path, matches))
|
|
394
|
+
.collect::<Vec<_>>();
|
|
395
|
+
failures.sort_by(|left, right| {
|
|
396
|
+
left.path
|
|
397
|
+
.as_bytes()
|
|
398
|
+
.cmp(right.path.as_bytes())
|
|
399
|
+
.then_with(|| left.message.as_bytes().cmp(right.message.as_bytes()))
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
format_apply(
|
|
403
|
+
&successes,
|
|
404
|
+
&skipped,
|
|
405
|
+
&failures,
|
|
406
|
+
unreachable,
|
|
407
|
+
written_replacements,
|
|
408
|
+
budget,
|
|
409
|
+
&fallback_note(&analyzed, fallback_label),
|
|
410
|
+
)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
struct FileAnalysis {
|
|
414
|
+
matches: usize,
|
|
415
|
+
previews: Vec<String>,
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
fn analyze_file(
|
|
419
|
+
document: &TextDocument,
|
|
420
|
+
regex: &Regex,
|
|
421
|
+
replacement: &str,
|
|
422
|
+
preview_limit: usize,
|
|
423
|
+
) -> FileAnalysis {
|
|
424
|
+
let mut matches = 0_usize;
|
|
425
|
+
let mut previews = Vec::new();
|
|
426
|
+
for captures in regex.captures_iter(document.logical_text()) {
|
|
427
|
+
let matched = captures.get(0).expect("every capture set has group zero");
|
|
428
|
+
let expanded = expand(&captures, replacement);
|
|
429
|
+
if matched.start() == matched.end() && expanded.is_empty() {
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
matches = matches.saturating_add(1);
|
|
433
|
+
if previews.len() < preview_limit {
|
|
434
|
+
let line = document.logical_text()[..matched.start()]
|
|
435
|
+
.bytes()
|
|
436
|
+
.filter(|byte| *byte == b'\n')
|
|
437
|
+
.count()
|
|
438
|
+
+ 1;
|
|
439
|
+
previews.push(format!(
|
|
440
|
+
"{line}: {} -> {}",
|
|
441
|
+
preview_text(matched.as_str()),
|
|
442
|
+
preview_text(&expanded)
|
|
443
|
+
));
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
FileAnalysis { matches, previews }
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
struct BuiltReplacement {
|
|
450
|
+
bytes: Vec<u8>,
|
|
451
|
+
matches: usize,
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
struct ReplacementOutput {
|
|
455
|
+
bytes: Option<Vec<u8>>,
|
|
456
|
+
len: usize,
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
impl ReplacementOutput {
|
|
460
|
+
fn new(capacity: usize, materialize: bool) -> Self {
|
|
461
|
+
Self {
|
|
462
|
+
bytes: materialize.then(|| Vec::with_capacity(capacity)),
|
|
463
|
+
len: 0,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
fn extend(&mut self, bytes: &[u8], path: &str, max_result_size_mib: u64) -> Result<(), String> {
|
|
468
|
+
self.len = checked_result_size(self.len, bytes.len(), path, max_result_size_mib)?;
|
|
469
|
+
if let Some(output) = &mut self.bytes {
|
|
470
|
+
output.extend_from_slice(bytes);
|
|
471
|
+
}
|
|
472
|
+
Ok(())
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
fn remove_suffix(&mut self, suffix: &[u8]) {
|
|
476
|
+
let suffix_is_present = self
|
|
477
|
+
.bytes
|
|
478
|
+
.as_ref()
|
|
479
|
+
.is_none_or(|output| output.ends_with(suffix));
|
|
480
|
+
if suffix_is_present && self.len >= suffix.len() {
|
|
481
|
+
self.len -= suffix.len();
|
|
482
|
+
if let Some(output) = &mut self.bytes {
|
|
483
|
+
output.truncate(self.len);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
fn validate_replacement(
|
|
490
|
+
document: &TextDocument,
|
|
491
|
+
regex: &Regex,
|
|
492
|
+
replacement: &str,
|
|
493
|
+
max_result_size_mib: u64,
|
|
494
|
+
) -> Result<(), String> {
|
|
495
|
+
process_replacement(document, regex, replacement, max_result_size_mib, false).map(drop)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
fn build_replacement(
|
|
499
|
+
document: &TextDocument,
|
|
500
|
+
regex: &Regex,
|
|
501
|
+
replacement: &str,
|
|
502
|
+
max_result_size_mib: u64,
|
|
503
|
+
) -> Result<BuiltReplacement, String> {
|
|
504
|
+
let (output, matches) =
|
|
505
|
+
process_replacement(document, regex, replacement, max_result_size_mib, true)?;
|
|
506
|
+
Ok(BuiltReplacement {
|
|
507
|
+
bytes: output
|
|
508
|
+
.bytes
|
|
509
|
+
.expect("materialized replacement always has an output buffer"),
|
|
510
|
+
matches,
|
|
511
|
+
})
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
fn process_replacement(
|
|
515
|
+
document: &TextDocument,
|
|
516
|
+
regex: &Regex,
|
|
517
|
+
replacement: &str,
|
|
518
|
+
max_result_size_mib: u64,
|
|
519
|
+
materialize: bool,
|
|
520
|
+
) -> Result<(ReplacementOutput, usize), String> {
|
|
521
|
+
let mut output = ReplacementOutput::new(document.original_bytes().len(), materialize);
|
|
522
|
+
let mut previous_raw = 0_usize;
|
|
523
|
+
let mut previous_logical = 0_usize;
|
|
524
|
+
let mut matches = 0_usize;
|
|
525
|
+
let mut result_ends_newline = false;
|
|
526
|
+
let mut raw_cursor = document.raw_offset_cursor()?;
|
|
527
|
+
for captures in regex.captures_iter(document.logical_text()) {
|
|
528
|
+
let matched = captures.get(0).expect("every capture set has group zero");
|
|
529
|
+
let expanded = expand(&captures, replacement);
|
|
530
|
+
if matched.start() == matched.end() && expanded.is_empty() {
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
let raw_start = raw_cursor.advance_to(matched.start())?;
|
|
534
|
+
let raw_end = raw_cursor.advance_to(matched.end())?;
|
|
535
|
+
output.extend(
|
|
536
|
+
&document.original_bytes()[previous_raw..raw_start],
|
|
537
|
+
&document.display_path(),
|
|
538
|
+
max_result_size_mib,
|
|
539
|
+
)?;
|
|
540
|
+
let unchanged = &document.logical_text()[previous_logical..matched.start()];
|
|
541
|
+
observe_tail(unchanged, &mut result_ends_newline);
|
|
542
|
+
let encoded = document.encode_for_target(&expanded)?;
|
|
543
|
+
output.extend(&encoded, &document.display_path(), max_result_size_mib)?;
|
|
544
|
+
observe_tail(&expanded, &mut result_ends_newline);
|
|
545
|
+
previous_raw = raw_end;
|
|
546
|
+
previous_logical = matched.end();
|
|
547
|
+
matches = matches.saturating_add(1);
|
|
548
|
+
}
|
|
549
|
+
output.extend(
|
|
550
|
+
&document.original_bytes()[previous_raw..],
|
|
551
|
+
&document.display_path(),
|
|
552
|
+
max_result_size_mib,
|
|
553
|
+
)?;
|
|
554
|
+
observe_tail(
|
|
555
|
+
&document.logical_text()[previous_logical..],
|
|
556
|
+
&mut result_ends_newline,
|
|
557
|
+
);
|
|
558
|
+
|
|
559
|
+
let newline = document.encode_for_target("\n")?;
|
|
560
|
+
if document.trailing_newline() && !result_ends_newline {
|
|
561
|
+
output.extend(&newline, &document.display_path(), max_result_size_mib)?;
|
|
562
|
+
} else if !document.trailing_newline() && result_ends_newline {
|
|
563
|
+
// With no original trailing newline, only encoded replacement text can introduce this
|
|
564
|
+
// final boundary, so the validation-only pass can prove the same suffix without bytes.
|
|
565
|
+
output.remove_suffix(&newline);
|
|
566
|
+
}
|
|
567
|
+
Ok((output, matches))
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
fn checked_result_size(
|
|
571
|
+
current: usize,
|
|
572
|
+
additional: usize,
|
|
573
|
+
path: &str,
|
|
574
|
+
max_result_size_mib: u64,
|
|
575
|
+
) -> Result<usize, String> {
|
|
576
|
+
let projected = current.saturating_add(additional);
|
|
577
|
+
let maximum_bytes = max_result_size_mib.saturating_mul(1024 * 1024);
|
|
578
|
+
if u64::try_from(projected).unwrap_or(u64::MAX) > maximum_bytes {
|
|
579
|
+
return Err(format!(
|
|
580
|
+
"Refusing to write {path}: the result would be {:.1} MiB, over the {max_result_size_mib} MiB safety limit. Narrow the pattern.",
|
|
581
|
+
projected as f64 / 1_048_576.0
|
|
582
|
+
));
|
|
583
|
+
}
|
|
584
|
+
Ok(projected)
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
fn observe_tail(text: &str, ends_newline: &mut bool) {
|
|
588
|
+
if !text.is_empty() {
|
|
589
|
+
*ends_newline = text.ends_with('\n');
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
fn expand(captures: &Captures<'_>, replacement: &str) -> String {
|
|
594
|
+
let mut expanded = String::new();
|
|
595
|
+
captures.expand(replacement, &mut expanded);
|
|
596
|
+
expanded
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
fn open_candidate(
|
|
600
|
+
path: &str,
|
|
601
|
+
explicit: Option<&str>,
|
|
602
|
+
fallback: Option<&str>,
|
|
603
|
+
max_file_size_mib: u64,
|
|
604
|
+
) -> Result<(TextDocument, bool), String> {
|
|
605
|
+
match TextDocument::open(path, explicit, max_file_size_mib) {
|
|
606
|
+
Ok(document) => Ok((document, false)),
|
|
607
|
+
Err(error)
|
|
608
|
+
if explicit.is_none()
|
|
609
|
+
&& fallback.is_some()
|
|
610
|
+
&& is_encoding_error(&error)
|
|
611
|
+
&& !error.contains("byte order mark") =>
|
|
612
|
+
{
|
|
613
|
+
TextDocument::open(path, fallback, max_file_size_mib).map(|document| (document, true))
|
|
614
|
+
}
|
|
615
|
+
Err(error) => Err(error),
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
#[derive(Debug)]
|
|
620
|
+
struct CompiledRegex {
|
|
621
|
+
regex: Regex,
|
|
622
|
+
can_match_empty: bool,
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
fn build_regex(request: &ReplaceRequest) -> Result<CompiledRegex, String> {
|
|
626
|
+
if request.pattern.is_empty() {
|
|
627
|
+
return Err(
|
|
628
|
+
"An empty pattern matches at every position and is almost always a mistake. Give a non-empty pattern."
|
|
629
|
+
.to_string(),
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
let pattern = if request.literal.unwrap_or(false) {
|
|
633
|
+
regex::escape(&request.pattern)
|
|
634
|
+
} else {
|
|
635
|
+
request.pattern.clone()
|
|
636
|
+
};
|
|
637
|
+
let regex = RegexBuilder::new(&pattern)
|
|
638
|
+
.case_insensitive(request.case_insensitive.unwrap_or(false))
|
|
639
|
+
.dot_matches_new_line(request.dot_all.unwrap_or(false))
|
|
640
|
+
.build()
|
|
641
|
+
.map_err(|error| {
|
|
642
|
+
format!(
|
|
643
|
+
"Invalid regex pattern: {error}\nNote: Rust regex syntax — no lookaround or backreferences; escape literal braces."
|
|
644
|
+
)
|
|
645
|
+
})?;
|
|
646
|
+
let hir = regex_syntax::Parser::new().parse(&pattern).map_err(|error| {
|
|
647
|
+
format!(
|
|
648
|
+
"Invalid regex pattern: {error}\nNote: Rust regex syntax — no lookaround or backreferences; escape literal braces."
|
|
649
|
+
)
|
|
650
|
+
})?;
|
|
651
|
+
Ok(CompiledRegex {
|
|
652
|
+
regex,
|
|
653
|
+
can_match_empty: hir.properties().minimum_len() == Some(0),
|
|
654
|
+
})
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
658
|
+
enum ReplacementReference<'a> {
|
|
659
|
+
Number(usize),
|
|
660
|
+
Named(&'a str),
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
664
|
+
struct ReplacementToken<'a> {
|
|
665
|
+
token: &'a str,
|
|
666
|
+
reference: ReplacementReference<'a>,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
fn validate_replacement_references(regex: &Regex, replacement: &str) -> Result<(), String> {
|
|
670
|
+
let names = regex.capture_names().flatten().collect::<Vec<_>>();
|
|
671
|
+
for token in replacement_tokens(replacement) {
|
|
672
|
+
let defined = match token.reference {
|
|
673
|
+
ReplacementReference::Number(index) => index < regex.captures_len(),
|
|
674
|
+
ReplacementReference::Named(name) => names.contains(&name),
|
|
675
|
+
};
|
|
676
|
+
if !defined {
|
|
677
|
+
return Err(format!(
|
|
678
|
+
"Replacement references an undefined capture group: {}. The pattern defines {}. Fix the replacement; nothing was written.",
|
|
679
|
+
token.token,
|
|
680
|
+
available_groups(regex, &names)
|
|
681
|
+
));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
Ok(())
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
fn available_groups(regex: &Regex, names: &[&str]) -> String {
|
|
688
|
+
let numbered = regex.captures_len().saturating_sub(1);
|
|
689
|
+
match (numbered, names) {
|
|
690
|
+
(0, []) => "no capture groups".to_string(),
|
|
691
|
+
(1, []) => "group 1".to_string(),
|
|
692
|
+
(count, []) => format!("groups 1-{count}"),
|
|
693
|
+
(0, [name]) => format!("named group: {name}"),
|
|
694
|
+
(0, names) => format!("named groups: {}", names.join(", ")),
|
|
695
|
+
(1, [name]) => format!("group 1; named group: {name}"),
|
|
696
|
+
(count, [name]) => format!("groups 1-{count}; named group: {name}"),
|
|
697
|
+
(1, names) => format!("group 1; named groups: {}", names.join(", ")),
|
|
698
|
+
(count, names) => format!("groups 1-{count}; named groups: {}", names.join(", ")),
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
fn replacement_tokens(replacement: &str) -> Vec<ReplacementToken<'_>> {
|
|
703
|
+
let bytes = replacement.as_bytes();
|
|
704
|
+
let mut tokens = Vec::new();
|
|
705
|
+
let mut cursor = 0_usize;
|
|
706
|
+
while cursor < bytes.len() {
|
|
707
|
+
let Some(relative) = bytes[cursor..].iter().position(|byte| *byte == b'$') else {
|
|
708
|
+
break;
|
|
709
|
+
};
|
|
710
|
+
let start = cursor + relative;
|
|
711
|
+
if bytes.get(start + 1) == Some(&b'$') {
|
|
712
|
+
cursor = start + 2;
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
let Some(next) = bytes.get(start + 1).copied() else {
|
|
716
|
+
break;
|
|
717
|
+
};
|
|
718
|
+
let (reference_text, end) = if next == b'{' {
|
|
719
|
+
let content_start = start + 2;
|
|
720
|
+
let Some(relative_end) = bytes[content_start..].iter().position(|byte| *byte == b'}')
|
|
721
|
+
else {
|
|
722
|
+
cursor = start + 1;
|
|
723
|
+
continue;
|
|
724
|
+
};
|
|
725
|
+
let content_end = content_start + relative_end;
|
|
726
|
+
(&replacement[content_start..content_end], content_end + 1)
|
|
727
|
+
} else {
|
|
728
|
+
let content_start = start + 1;
|
|
729
|
+
let mut content_end = content_start;
|
|
730
|
+
while bytes
|
|
731
|
+
.get(content_end)
|
|
732
|
+
.is_some_and(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'_'))
|
|
733
|
+
{
|
|
734
|
+
content_end += 1;
|
|
735
|
+
}
|
|
736
|
+
if content_end == content_start {
|
|
737
|
+
cursor = start + 1;
|
|
738
|
+
continue;
|
|
739
|
+
}
|
|
740
|
+
(&replacement[content_start..content_end], content_end)
|
|
741
|
+
};
|
|
742
|
+
let reference = reference_text
|
|
743
|
+
.parse::<usize>()
|
|
744
|
+
.map(ReplacementReference::Number)
|
|
745
|
+
.unwrap_or(ReplacementReference::Named(reference_text));
|
|
746
|
+
tokens.push(ReplacementToken {
|
|
747
|
+
token: &replacement[start..end],
|
|
748
|
+
reference,
|
|
749
|
+
});
|
|
750
|
+
cursor = end;
|
|
751
|
+
}
|
|
752
|
+
tokens
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
fn build_glob(patterns: Option<&GlobPatterns>) -> Result<Option<PathGlobFilter>, String> {
|
|
756
|
+
let Some(patterns) = patterns else {
|
|
757
|
+
return Ok(None);
|
|
758
|
+
};
|
|
759
|
+
PathGlobFilter::compile(patterns, false)
|
|
760
|
+
.map(Some)
|
|
761
|
+
.map_err(|error| {
|
|
762
|
+
format!(
|
|
763
|
+
"Invalid glob pattern: {error}. Use forms like \"*.rs\" or \"**/*.{{ts,tsx}}\"."
|
|
764
|
+
)
|
|
765
|
+
})
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
fn resolve_root(input: &str) -> Result<PathBuf, String> {
|
|
769
|
+
let parsed = crate::paths::parse_local_path_input(input)?;
|
|
770
|
+
let input_display = crate::paths::display_path(&parsed);
|
|
771
|
+
if !parsed.is_absolute() || !parsed.exists() {
|
|
772
|
+
return Err(crate::paths::missing_search_path_message(&input_display));
|
|
773
|
+
}
|
|
774
|
+
fs::metadata(&parsed).map_err(|error| crate::paths::io_error_message(&parsed, &error))?;
|
|
775
|
+
crate::paths::canonical_existing(&parsed)
|
|
776
|
+
.map_err(|error| crate::paths::io_error_message(&parsed, &error))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
fn format_dry_run(
|
|
780
|
+
analyzed: &[AnalyzedFile],
|
|
781
|
+
skipped: &[Issue],
|
|
782
|
+
failures: &[Issue],
|
|
783
|
+
unreachable: Unreachable<'_>,
|
|
784
|
+
total_matches: usize,
|
|
785
|
+
budget: usize,
|
|
786
|
+
fallback_label: Option<&str>,
|
|
787
|
+
) -> ToolResponse {
|
|
788
|
+
let mut groups = analyzed
|
|
789
|
+
.iter()
|
|
790
|
+
.map(|file| {
|
|
791
|
+
let mut lines = vec![file.path.clone()];
|
|
792
|
+
lines.extend(file.previews.iter().cloned());
|
|
793
|
+
ReportGroup { lines }
|
|
794
|
+
})
|
|
795
|
+
.collect::<Vec<_>>();
|
|
796
|
+
groups.extend(issue_groups(skipped, "skipped"));
|
|
797
|
+
groups.extend(issue_groups(failures, "failed"));
|
|
798
|
+
groups.extend(issue_groups(unreachable.issues, "unreachable"));
|
|
799
|
+
let matched_files = analyzed.len();
|
|
800
|
+
let terminal = if total_matches == 0 {
|
|
801
|
+
"(Complete: dry run — no matches found.)".to_string()
|
|
802
|
+
} else {
|
|
803
|
+
format!(
|
|
804
|
+
"(Complete: dry run — {total_matches} {} in {matched_files} {}; nothing written.)",
|
|
805
|
+
plural(total_matches, "match", "matches"),
|
|
806
|
+
plural(matched_files, "file", "files")
|
|
807
|
+
)
|
|
808
|
+
};
|
|
809
|
+
let terminal = downgrade_if_unreachable(terminal, unreachable.total);
|
|
810
|
+
let tally = SkipTally {
|
|
811
|
+
files: skipped.len() + failures.len(),
|
|
812
|
+
unreachable: unreachable.total,
|
|
813
|
+
listed: 0,
|
|
814
|
+
};
|
|
815
|
+
let terminal = terminal_with_skips(&terminal, &tally, 0).unwrap_or(terminal);
|
|
816
|
+
render_report(
|
|
817
|
+
&groups,
|
|
818
|
+
&terminal,
|
|
819
|
+
&fallback_note(analyzed, fallback_label),
|
|
820
|
+
budget,
|
|
821
|
+
analyzed.iter().any(|file| file.previews_truncated),
|
|
822
|
+
)
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
fn format_apply(
|
|
826
|
+
successes: &[(String, usize)],
|
|
827
|
+
skipped: &[Issue],
|
|
828
|
+
failures: &[Issue],
|
|
829
|
+
unreachable: Unreachable<'_>,
|
|
830
|
+
replacements: usize,
|
|
831
|
+
budget: usize,
|
|
832
|
+
extra_notes: &[String],
|
|
833
|
+
) -> ToolResponse {
|
|
834
|
+
let mut groups = successes
|
|
835
|
+
.iter()
|
|
836
|
+
.map(|(path, count)| ReportGroup {
|
|
837
|
+
lines: vec![format!(
|
|
838
|
+
"{path}: {count} {}",
|
|
839
|
+
plural(*count, "replacement", "replacements")
|
|
840
|
+
)],
|
|
841
|
+
})
|
|
842
|
+
.collect::<Vec<_>>();
|
|
843
|
+
groups.extend(issue_groups(skipped, "skipped"));
|
|
844
|
+
groups.extend(issue_groups(failures, "failed"));
|
|
845
|
+
groups.extend(issue_groups(unreachable.issues, "unreachable"));
|
|
846
|
+
let terminal = if replacements == 0 && failures.is_empty() {
|
|
847
|
+
"(Complete: no matches found; nothing written.)".to_string()
|
|
848
|
+
} else if failures.is_empty() {
|
|
849
|
+
format!(
|
|
850
|
+
"(Complete: {replacements} {} in {} {}.)",
|
|
851
|
+
plural(replacements, "replacement", "replacements"),
|
|
852
|
+
successes.len(),
|
|
853
|
+
plural(successes.len(), "file", "files")
|
|
854
|
+
)
|
|
855
|
+
} else {
|
|
856
|
+
format!(
|
|
857
|
+
"(Partial: {replacements} {} written in {} {}; {} {} failed — see the report above.)",
|
|
858
|
+
plural(replacements, "replacement", "replacements"),
|
|
859
|
+
successes.len(),
|
|
860
|
+
plural(successes.len(), "file", "files"),
|
|
861
|
+
failures.len(),
|
|
862
|
+
plural(failures.len(), "file", "files")
|
|
863
|
+
)
|
|
864
|
+
};
|
|
865
|
+
let terminal = downgrade_if_unreachable(terminal, unreachable.total);
|
|
866
|
+
let tally = SkipTally {
|
|
867
|
+
files: skipped.len(),
|
|
868
|
+
unreachable: unreachable.total,
|
|
869
|
+
listed: 0,
|
|
870
|
+
};
|
|
871
|
+
let terminal = terminal_with_skips(&terminal, &tally, 0).unwrap_or(terminal);
|
|
872
|
+
render_report(&groups, &terminal, extra_notes, budget, false)
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/// A run that could not enter every path did not finish the job, however well
|
|
876
|
+
/// the files it did reach turned out. `Complete` would overstate the coverage,
|
|
877
|
+
/// and for a tool that writes, overstated coverage is the dangerous direction.
|
|
878
|
+
fn downgrade_if_unreachable(terminal: String, unreachable: usize) -> String {
|
|
879
|
+
if unreachable == 0 {
|
|
880
|
+
return terminal;
|
|
881
|
+
}
|
|
882
|
+
if let Some(rest) = terminal.strip_prefix("(Complete:") {
|
|
883
|
+
return format!("(Partial:{rest}");
|
|
884
|
+
}
|
|
885
|
+
terminal
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
fn render_report(
|
|
889
|
+
groups: &[ReportGroup],
|
|
890
|
+
terminal: &str,
|
|
891
|
+
extra_notes: &[String],
|
|
892
|
+
budget: usize,
|
|
893
|
+
force_truncated: bool,
|
|
894
|
+
) -> ToolResponse {
|
|
895
|
+
let all_lines = groups
|
|
896
|
+
.iter()
|
|
897
|
+
.flat_map(|group| group.lines.iter().cloned())
|
|
898
|
+
.collect::<Vec<_>>();
|
|
899
|
+
let mut notes = extra_notes.to_vec();
|
|
900
|
+
notes.push(terminal.to_string());
|
|
901
|
+
let full = assemble_text(&all_lines, ¬es);
|
|
902
|
+
if !force_truncated && estimate_tokens(&full) <= budget {
|
|
903
|
+
return ToolResponse::text(full);
|
|
904
|
+
}
|
|
905
|
+
let truncated_terminal = append_terminal_clause(terminal, "list truncated, see the note above");
|
|
906
|
+
let mut shown_lines = Vec::new();
|
|
907
|
+
let mut shown_files = 0_usize;
|
|
908
|
+
for group in groups {
|
|
909
|
+
let start_len = shown_lines.len();
|
|
910
|
+
for line in &group.lines {
|
|
911
|
+
shown_lines.push(line.clone());
|
|
912
|
+
let mut trial_notes = vec![format!(
|
|
913
|
+
"(Note: showing {} of {} files; totals below cover all files.)",
|
|
914
|
+
shown_files + 1,
|
|
915
|
+
groups.len()
|
|
916
|
+
)];
|
|
917
|
+
trial_notes.extend(extra_notes.iter().cloned());
|
|
918
|
+
trial_notes.push(truncated_terminal.clone());
|
|
919
|
+
if estimate_tokens(&assemble_text(&shown_lines, &trial_notes)) > budget {
|
|
920
|
+
shown_lines.pop();
|
|
921
|
+
break;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
if shown_lines.len() > start_len {
|
|
925
|
+
shown_files += 1;
|
|
926
|
+
}
|
|
927
|
+
let mut trial_notes = vec![format!(
|
|
928
|
+
"(Note: showing {shown_files} of {} files; totals below cover all files.)",
|
|
929
|
+
groups.len()
|
|
930
|
+
)];
|
|
931
|
+
trial_notes.extend(extra_notes.iter().cloned());
|
|
932
|
+
trial_notes.push(truncated_terminal.clone());
|
|
933
|
+
if estimate_tokens(&assemble_text(&shown_lines, &trial_notes)) >= budget {
|
|
934
|
+
break;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
let mut truncated_notes = vec![format!(
|
|
938
|
+
"(Note: showing {shown_files} of {} files; totals below cover all files.)",
|
|
939
|
+
groups.len()
|
|
940
|
+
)];
|
|
941
|
+
truncated_notes.extend(extra_notes.iter().cloned());
|
|
942
|
+
truncated_notes.push(truncated_terminal);
|
|
943
|
+
let output = assemble_text(&shown_lines, &truncated_notes);
|
|
944
|
+
if estimate_tokens(&output) <= budget {
|
|
945
|
+
ToolResponse::text(output)
|
|
946
|
+
} else {
|
|
947
|
+
let required = estimate_tokens(&output);
|
|
948
|
+
if let Ok(expanded) = tool_token_budget_for_required(GLOBAL_TOKEN_BUDGET_ENV, required)
|
|
949
|
+
&& expanded.value > budget
|
|
950
|
+
{
|
|
951
|
+
return render_report(
|
|
952
|
+
groups,
|
|
953
|
+
terminal,
|
|
954
|
+
extra_notes,
|
|
955
|
+
expanded.value,
|
|
956
|
+
force_truncated,
|
|
957
|
+
);
|
|
958
|
+
}
|
|
959
|
+
ToolResponse::error(format!(
|
|
960
|
+
"FASTCTX_TOKEN_BUDGET={budget} is too small to return the required status note. Increase it and retry."
|
|
961
|
+
))
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
fn issue_groups(issues: &[Issue], label: &str) -> Vec<ReportGroup> {
|
|
966
|
+
issues
|
|
967
|
+
.iter()
|
|
968
|
+
.map(|issue| ReportGroup {
|
|
969
|
+
lines: vec![format!("{} — {label}: {}", issue.path, issue.message)],
|
|
970
|
+
})
|
|
971
|
+
.collect()
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
fn fallback_note(analyzed: &[AnalyzedFile], encoding: Option<&str>) -> Vec<String> {
|
|
975
|
+
let count = analyzed.iter().filter(|file| file.used_fallback).count();
|
|
976
|
+
if count == 0 {
|
|
977
|
+
Vec::new()
|
|
978
|
+
} else {
|
|
979
|
+
let encoding = encoding.unwrap_or("the requested fallback");
|
|
980
|
+
vec![format!(
|
|
981
|
+
"(Note: {count} {} decoded using fallback encoding {encoding}.)",
|
|
982
|
+
plural(count, "file", "files"),
|
|
983
|
+
)]
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
fn append_terminal_clause(terminal: &str, clause: &str) -> String {
|
|
988
|
+
let stem = terminal
|
|
989
|
+
.strip_suffix(".)")
|
|
990
|
+
.expect("replace terminal notes always end with .)");
|
|
991
|
+
format!("{stem}; {clause}.)")
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
fn preview_text(text: &str) -> String {
|
|
995
|
+
let escaped = text
|
|
996
|
+
.replace("\r\n", "\\n")
|
|
997
|
+
.replace('\n', "\\n")
|
|
998
|
+
.replace('\r', "\\r");
|
|
999
|
+
let total = escaped.chars().count();
|
|
1000
|
+
let shown = escaped.chars().take(160).collect::<String>();
|
|
1001
|
+
if total > 160 {
|
|
1002
|
+
format!("{shown}…")
|
|
1003
|
+
} else {
|
|
1004
|
+
shown
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
fn is_binary_error(error: &str) -> bool {
|
|
1009
|
+
error.starts_with("Cannot read binary file as text:")
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
fn is_encoding_error(error: &str) -> bool {
|
|
1013
|
+
error.starts_with("Cannot determine the text encoding") || error.starts_with("Cannot decode ")
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
fn is_skippable_error(error: &str) -> bool {
|
|
1017
|
+
is_encoding_error(error) || error.starts_with("File too large for line edits:")
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
fn short_issue(error: &str) -> String {
|
|
1021
|
+
if error.contains("mixed or inconsistent encodings") {
|
|
1022
|
+
"mixed or inconsistent encodings".to_string()
|
|
1023
|
+
} else if error.starts_with("Cannot determine the text encoding") {
|
|
1024
|
+
"ambiguous encoding".to_string()
|
|
1025
|
+
} else if error.starts_with("Cannot decode ") {
|
|
1026
|
+
"undecodable".to_string()
|
|
1027
|
+
} else {
|
|
1028
|
+
error.to_string()
|
|
1029
|
+
}
|
|
1030
|
+
}
|