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,2449 @@
|
|
|
1
|
+
//! grep tool backed by ripgrep engines, ignore traversal, deterministic paging, and content formatting.
|
|
2
|
+
|
|
3
|
+
use crate::budget::{
|
|
4
|
+
ErrorBudgetAdapter, ErrorClass, GREP_TOKEN_BUDGET_ENV, TokenCheckpoint, error_budget_hint,
|
|
5
|
+
tool_token_budget,
|
|
6
|
+
};
|
|
7
|
+
use crate::encoding::{
|
|
8
|
+
ByteSource, EncodingDecision, EncodingPipelineFailure, EncodingRejection,
|
|
9
|
+
canonical_encoding_label, validate_snapshot_encoding,
|
|
10
|
+
};
|
|
11
|
+
use crate::file_executor::GrepGlobExecutor;
|
|
12
|
+
use crate::file_snapshot::{CaptureDisposition, CaptureFailure, capture_classify};
|
|
13
|
+
use crate::glob_filter::{GlobPatterns, PathGlobFilter};
|
|
14
|
+
use crate::grep_sink::{
|
|
15
|
+
CapturedLine, ContentEntry, ContentSpec, FileResult, GrepSearchPlan, GrepSinkError,
|
|
16
|
+
LineMatchSpan, PlanSink,
|
|
17
|
+
};
|
|
18
|
+
use crate::model::ToolResponse;
|
|
19
|
+
use crate::operation::{
|
|
20
|
+
OpError, OperationCtx, RequestWorkGuard, WorkCheckpoint, WorkCtx, WorkStop,
|
|
21
|
+
};
|
|
22
|
+
use crate::ordered_window::{OrderedError, for_each_ordered};
|
|
23
|
+
use crate::path_codec::{
|
|
24
|
+
PathRecord as Candidate, RootRequirement, io_error_message, resolve_search_root,
|
|
25
|
+
};
|
|
26
|
+
use crate::render_plan::{
|
|
27
|
+
DetailRenderGraph, LineRenderGraph, LineRenderView, RenderPlanError, SharedLineRenderGraph,
|
|
28
|
+
};
|
|
29
|
+
use crate::search_text::{SearchText, SearchTextFailure};
|
|
30
|
+
use crate::skip_report::{SkipTally, detail_line};
|
|
31
|
+
use crate::traversal::{SkippedPaths, collect_search_candidates};
|
|
32
|
+
use grep_matcher::LineTerminator;
|
|
33
|
+
use grep_regex::{RegexMatcher, RegexMatcherBuilder};
|
|
34
|
+
use grep_searcher::SearcherBuilder;
|
|
35
|
+
use schemars::JsonSchema;
|
|
36
|
+
use serde::Deserialize;
|
|
37
|
+
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
|
38
|
+
use std::io;
|
|
39
|
+
use std::ops::ControlFlow;
|
|
40
|
+
use std::sync::Arc;
|
|
41
|
+
use tokio_util::sync::CancellationToken;
|
|
42
|
+
|
|
43
|
+
const DEFAULT_HEAD_LIMIT: usize = 250;
|
|
44
|
+
const LONG_LINE_BYTES: usize = 500;
|
|
45
|
+
const MATCH_WINDOW_SIDE_CHARS: usize = 100;
|
|
46
|
+
const MAX_MATCH_CHARS: usize = 2_000;
|
|
47
|
+
const SEARCH_HEAP_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
|
48
|
+
const CAPTURE_HEAP_LIMIT_BYTES: usize = 64 * 1024 * 1024;
|
|
49
|
+
|
|
50
|
+
/// The four grep output modes.
|
|
51
|
+
#[derive(Clone, Copy, Debug, Default, Deserialize, JsonSchema, Eq, PartialEq)]
|
|
52
|
+
#[serde(rename_all = "snake_case")]
|
|
53
|
+
pub enum OutputMode {
|
|
54
|
+
/// Return matching lines with optional context.
|
|
55
|
+
Content,
|
|
56
|
+
/// Return only paths of files containing at least one match.
|
|
57
|
+
#[default]
|
|
58
|
+
FilesWithMatches,
|
|
59
|
+
/// Return per-file occurrence counts and their aggregate.
|
|
60
|
+
Count,
|
|
61
|
+
/// Scan the full scope and return only global occurrence and file totals.
|
|
62
|
+
Summary,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/// Parameters for the grep tool.
|
|
66
|
+
#[derive(Clone, Debug, Deserialize, JsonSchema)]
|
|
67
|
+
pub struct GrepRequest {
|
|
68
|
+
/// The regular expression to search for (Rust regex syntax; escape literal braces like `interface\{\}`).
|
|
69
|
+
pub pattern: String,
|
|
70
|
+
/// File or directory to search; omit for the session working directory.
|
|
71
|
+
#[schemars(description = crate::model_guidance::local_path_description(
|
|
72
|
+
"File or directory to search. Omit for the session working directory."
|
|
73
|
+
))]
|
|
74
|
+
pub path: Option<String>,
|
|
75
|
+
/// Globs to filter files, e.g. ["**/*.rs", "!tests/**"]. A leading `!` excludes and always wins; negative-only lists include every other file.
|
|
76
|
+
// Published as a plain string array; see the note on GlobRequest::pattern. (2026-08-17)
|
|
77
|
+
#[schemars(with = "Option<Vec<String>>")]
|
|
78
|
+
pub glob: Option<GlobPatterns>,
|
|
79
|
+
/// File type filter, e.g. "js", "py", "rust" (equivalent to rg --type; more efficient than glob for standard types).
|
|
80
|
+
#[serde(rename = "type")]
|
|
81
|
+
#[schemars(rename = "type")]
|
|
82
|
+
pub file_type: Option<String>,
|
|
83
|
+
/// "content" = matching lines with optional context; "files_with_matches" (default) = matching paths only; "count" = per-file counts plus their total; "summary" = global totals from a full scan (ignores head_limit/offset).
|
|
84
|
+
pub output_mode: Option<OutputMode>,
|
|
85
|
+
/// Case-insensitive search (rg -i).
|
|
86
|
+
pub case_insensitive: Option<bool>,
|
|
87
|
+
/// Show line numbers in content mode (rg -n). Ignored in other modes.
|
|
88
|
+
pub line_numbers: Option<bool>,
|
|
89
|
+
/// Print only the matched parts, one per line (rg -o). Content mode only.
|
|
90
|
+
pub only_matching: Option<bool>,
|
|
91
|
+
/// Lines to show before each match (rg -B). Content mode only.
|
|
92
|
+
pub before_context: Option<usize>,
|
|
93
|
+
/// Lines to show after each match (rg -A). Content mode only.
|
|
94
|
+
pub after_context: Option<usize>,
|
|
95
|
+
/// Lines before and after each match (rg -C); overrides before/after_context. Content mode only.
|
|
96
|
+
pub context: Option<usize>,
|
|
97
|
+
/// Patterns may span lines; `.` matches newlines. `\n` also matches `\r\n`.
|
|
98
|
+
pub multiline: Option<bool>,
|
|
99
|
+
/// Max output entries. 0 removes the entry limit but not the token limit.
|
|
100
|
+
pub head_limit: Option<usize>,
|
|
101
|
+
/// Skip the first N entries before applying head_limit.
|
|
102
|
+
pub offset: Option<usize>,
|
|
103
|
+
/// Single-file target only: decode that file with this WHATWG encoding label (e.g. "gbk"), same semantics as inspect_local_file's encoding. On a directory target use fallback_encoding instead.
|
|
104
|
+
pub encoding: Option<String>,
|
|
105
|
+
/// Directory target: WHATWG encoding to assume only for files auto-detection can't determine — never overrides BOM, valid UTF-8, or already-resolved files. Strict-decoded; files that also fail under it stay in the skip report.
|
|
106
|
+
pub fallback_encoding: Option<String>,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
struct SearchOutcome {
|
|
110
|
+
result: Option<FileResult>,
|
|
111
|
+
entries_seen: usize,
|
|
112
|
+
skip: Option<CandidateSkip>,
|
|
113
|
+
transcoding_note: Option<String>,
|
|
114
|
+
used_fallback: bool,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
enum CandidateSkip {
|
|
118
|
+
Encoding(EncodingRejection),
|
|
119
|
+
ChangedWhileSearched,
|
|
120
|
+
SearchFailure {
|
|
121
|
+
reason: String,
|
|
122
|
+
single_file_message: String,
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
impl CandidateSkip {
|
|
127
|
+
fn reason(&self) -> String {
|
|
128
|
+
match self {
|
|
129
|
+
Self::Encoding(rejection) => rejection.skip_reason(),
|
|
130
|
+
Self::ChangedWhileSearched => "changed while being searched".to_string(),
|
|
131
|
+
Self::SearchFailure { reason, .. } => reason.clone(),
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fn single_file_message(&self, candidate: &Candidate) -> String {
|
|
136
|
+
match self {
|
|
137
|
+
Self::Encoding(rejection) => rejection.message(candidate.display.as_ref()),
|
|
138
|
+
Self::ChangedWhileSearched => format!(
|
|
139
|
+
"File changed while it was being searched: {}. Retry the grep request.",
|
|
140
|
+
candidate.display
|
|
141
|
+
),
|
|
142
|
+
Self::SearchFailure {
|
|
143
|
+
single_file_message,
|
|
144
|
+
..
|
|
145
|
+
} => single_file_message.clone(),
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/// Why one candidate's search failed; the ordered reduce decides whether the
|
|
151
|
+
/// failure is even reachable before formatting it.
|
|
152
|
+
enum SearchFailure {
|
|
153
|
+
/// Captured matches and context crossed the 64 MiB safety valve. Kept as a
|
|
154
|
+
/// distinct variant so the paged reduce can retry with the exact live
|
|
155
|
+
/// pagination window before giving up.
|
|
156
|
+
CaptureOverflow,
|
|
157
|
+
Cancelled,
|
|
158
|
+
EpochRetired,
|
|
159
|
+
Candidate(CandidateSkip),
|
|
160
|
+
Fatal(String),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn failure_message(candidate: &Candidate, failure: SearchFailure) -> String {
|
|
164
|
+
match failure {
|
|
165
|
+
SearchFailure::CaptureOverflow => capture_limit_error(candidate),
|
|
166
|
+
SearchFailure::Cancelled => "Request cancelled.".to_string(),
|
|
167
|
+
SearchFailure::EpochRetired => {
|
|
168
|
+
unreachable!("retired speculative work is never delivered to the reducer")
|
|
169
|
+
}
|
|
170
|
+
SearchFailure::Candidate(skip) => skip.single_file_message(candidate),
|
|
171
|
+
SearchFailure::Fatal(message) => message,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
fn candidate_failure(candidate: &Candidate, message: String) -> SearchFailure {
|
|
176
|
+
let prefixes = [
|
|
177
|
+
format!("Cannot search file {}: ", candidate.display),
|
|
178
|
+
format!(
|
|
179
|
+
"Cannot create a stable search snapshot for {}: ",
|
|
180
|
+
candidate.display
|
|
181
|
+
),
|
|
182
|
+
];
|
|
183
|
+
let remainder = prefixes
|
|
184
|
+
.iter()
|
|
185
|
+
.find_map(|prefix| message.strip_prefix(prefix))
|
|
186
|
+
.unwrap_or(&message);
|
|
187
|
+
let reason = remainder
|
|
188
|
+
.split_once(". ")
|
|
189
|
+
.map_or(remainder, |(first, _)| first)
|
|
190
|
+
.trim_end_matches('.')
|
|
191
|
+
.to_string();
|
|
192
|
+
SearchFailure::Candidate(CandidateSkip::SearchFailure {
|
|
193
|
+
reason,
|
|
194
|
+
single_file_message: message,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
fn capture_overflow_skip(candidate: &Candidate) -> CandidateSkip {
|
|
199
|
+
CandidateSkip::SearchFailure {
|
|
200
|
+
reason: "matching content and context exceed the 64 MiB safety limit".to_string(),
|
|
201
|
+
single_file_message: capture_limit_error(candidate),
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
struct PageFormat<'a> {
|
|
206
|
+
offset: usize,
|
|
207
|
+
head_limit: usize,
|
|
208
|
+
budget: usize,
|
|
209
|
+
budget_variable: &'a str,
|
|
210
|
+
scan_complete: bool,
|
|
211
|
+
total_entries_seen: usize,
|
|
212
|
+
skipped_files: &'a SkippedFiles,
|
|
213
|
+
transcoding_notes: &'a BTreeSet<String>,
|
|
214
|
+
fallback_usage: &'a FallbackUsage,
|
|
215
|
+
single_file_target: bool,
|
|
216
|
+
operation: Option<&'a OperationCtx>,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
#[derive(Default)]
|
|
220
|
+
struct SkippedFiles {
|
|
221
|
+
entries: Vec<SkippedFile>,
|
|
222
|
+
/// Leading entries contributed by traversal rather than by searching.
|
|
223
|
+
unreachable_listed: usize,
|
|
224
|
+
/// Unreachable paths the traversal detail cap counted but dropped.
|
|
225
|
+
unreachable_unlisted: usize,
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
impl SkippedFiles {
|
|
229
|
+
/// Seeds the report with paths the walk never entered. Traversal happens
|
|
230
|
+
/// before any file is searched, so its entries lead the detail list and the
|
|
231
|
+
/// split point stays a simple prefix length.
|
|
232
|
+
fn from_traversal(skipped: &SkippedPaths) -> Self {
|
|
233
|
+
let entries = skipped
|
|
234
|
+
.listed()
|
|
235
|
+
.map(|path| SkippedFile {
|
|
236
|
+
path: path.display.to_string(),
|
|
237
|
+
reason: path.reason.to_string(),
|
|
238
|
+
})
|
|
239
|
+
.collect::<Vec<_>>();
|
|
240
|
+
Self {
|
|
241
|
+
unreachable_listed: entries.len(),
|
|
242
|
+
unreachable_unlisted: skipped.unlisted(),
|
|
243
|
+
entries,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
fn record(&mut self, path: &str, skip: &CandidateSkip) {
|
|
248
|
+
self.entries.push(SkippedFile {
|
|
249
|
+
path: path.to_string(),
|
|
250
|
+
reason: skip.reason(),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
fn tally(&self) -> SkipTally {
|
|
255
|
+
SkipTally {
|
|
256
|
+
files: self.entries.len().saturating_sub(self.unreachable_listed),
|
|
257
|
+
unreachable: self
|
|
258
|
+
.unreachable_listed
|
|
259
|
+
.saturating_add(self.unreachable_unlisted),
|
|
260
|
+
listed: self.entries.len(),
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
struct SkippedFile {
|
|
266
|
+
path: String,
|
|
267
|
+
reason: String,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
#[derive(Default)]
|
|
271
|
+
struct FallbackUsage {
|
|
272
|
+
count: usize,
|
|
273
|
+
encoding: Option<&'static str>,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
impl FallbackUsage {
|
|
277
|
+
fn record(&mut self, encoding: &'static str) {
|
|
278
|
+
self.count = self.count.saturating_add(1);
|
|
279
|
+
self.encoding = Some(encoding);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fn note(&self) -> Option<String> {
|
|
283
|
+
let encoding = self.encoding?;
|
|
284
|
+
Some(format!(
|
|
285
|
+
"(Note: {} decoded using fallback encoding {encoding}.)",
|
|
286
|
+
counted(self.count, "file", "files")
|
|
287
|
+
))
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
#[derive(Clone, Debug)]
|
|
292
|
+
struct SearchEncoding {
|
|
293
|
+
explicit: Option<String>,
|
|
294
|
+
fallback: Option<String>,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/// Executes a grep query within a caller-owned cancellation scope.
|
|
298
|
+
///
|
|
299
|
+
/// Cancellation is checked throughout admission, traversal, capture, decoding,
|
|
300
|
+
/// matching, sorting, rendering, and token verification. A cancelled operation
|
|
301
|
+
/// returns an error response and never exposes a partial success body.
|
|
302
|
+
pub fn grep_files(request: GrepRequest, cancellation: CancellationToken) -> ToolResponse {
|
|
303
|
+
let budget = match tool_token_budget(GREP_TOKEN_BUDGET_ENV) {
|
|
304
|
+
Ok(budget) => budget,
|
|
305
|
+
Err(message) => {
|
|
306
|
+
return ErrorBudgetAdapter::new(
|
|
307
|
+
error_budget_hint(GREP_TOKEN_BUDGET_ENV),
|
|
308
|
+
GREP_TOKEN_BUDGET_ENV,
|
|
309
|
+
)
|
|
310
|
+
.error(ErrorClass::Budget, message);
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
let (mut guard, operation) = RequestWorkGuard::new(
|
|
314
|
+
rmcp::model::RequestId::String(Arc::from("direct-grep")),
|
|
315
|
+
cancellation,
|
|
316
|
+
);
|
|
317
|
+
let response = grep_files_with_budget_source_and_execution(
|
|
318
|
+
request,
|
|
319
|
+
budget.value,
|
|
320
|
+
budget.variable,
|
|
321
|
+
CAPTURE_HEAP_LIMIT_BYTES,
|
|
322
|
+
operation,
|
|
323
|
+
GrepGlobExecutor::shared(),
|
|
324
|
+
);
|
|
325
|
+
guard.disarm();
|
|
326
|
+
response
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/// Runs grep on the server's request cancellation scope and shared executor.
|
|
330
|
+
pub(crate) fn grep_files_cancellable(
|
|
331
|
+
operation: OperationCtx,
|
|
332
|
+
executor: Arc<GrepGlobExecutor>,
|
|
333
|
+
request: GrepRequest,
|
|
334
|
+
) -> Result<ToolResponse, OpError> {
|
|
335
|
+
let work = operation.inline_work();
|
|
336
|
+
work.check_inline()?;
|
|
337
|
+
let budget = match tool_token_budget(GREP_TOKEN_BUDGET_ENV) {
|
|
338
|
+
Ok(budget) => budget,
|
|
339
|
+
Err(message) => {
|
|
340
|
+
return Ok(ErrorBudgetAdapter::new(
|
|
341
|
+
error_budget_hint(GREP_TOKEN_BUDGET_ENV),
|
|
342
|
+
GREP_TOKEN_BUDGET_ENV,
|
|
343
|
+
)
|
|
344
|
+
.error(ErrorClass::Budget, message));
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
let response = grep_files_with_budget_source_and_execution(
|
|
348
|
+
request,
|
|
349
|
+
budget.value,
|
|
350
|
+
budget.variable,
|
|
351
|
+
CAPTURE_HEAP_LIMIT_BYTES,
|
|
352
|
+
operation.clone(),
|
|
353
|
+
executor,
|
|
354
|
+
);
|
|
355
|
+
work.check_inline()?;
|
|
356
|
+
Ok(response)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
fn grep_files_with_budget_source_and_execution(
|
|
360
|
+
request: GrepRequest,
|
|
361
|
+
budget: usize,
|
|
362
|
+
budget_variable: &str,
|
|
363
|
+
capture_heap_limit_bytes: usize,
|
|
364
|
+
operation: OperationCtx,
|
|
365
|
+
executor: Arc<GrepGlobExecutor>,
|
|
366
|
+
) -> ToolResponse {
|
|
367
|
+
let adapter = ErrorBudgetAdapter::new(budget, budget_variable);
|
|
368
|
+
adapter.adapt(grep_files_with_budget_source_and_execution_unadapted(
|
|
369
|
+
request,
|
|
370
|
+
budget,
|
|
371
|
+
budget_variable,
|
|
372
|
+
capture_heap_limit_bytes,
|
|
373
|
+
operation,
|
|
374
|
+
executor,
|
|
375
|
+
))
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
fn grep_files_with_budget_source_and_execution_unadapted(
|
|
379
|
+
request: GrepRequest,
|
|
380
|
+
budget: usize,
|
|
381
|
+
budget_variable: &str,
|
|
382
|
+
capture_heap_limit_bytes: usize,
|
|
383
|
+
operation: OperationCtx,
|
|
384
|
+
executor: Arc<GrepGlobExecutor>,
|
|
385
|
+
) -> ToolResponse {
|
|
386
|
+
let root_input = request.path.clone();
|
|
387
|
+
let root = match resolve_search_root(root_input.as_deref(), RootRequirement::FileOrDirectory) {
|
|
388
|
+
Ok(root) => root,
|
|
389
|
+
Err(message) => return ToolResponse::error(message),
|
|
390
|
+
};
|
|
391
|
+
let single_file_target = root.is_file();
|
|
392
|
+
if single_file_target && request.fallback_encoding.is_some() {
|
|
393
|
+
return ToolResponse::error(
|
|
394
|
+
"The fallback_encoding parameter only applies to directory targets; use encoding for a single file.",
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
if !single_file_target && request.encoding.is_some() {
|
|
398
|
+
return ToolResponse::error(
|
|
399
|
+
"The encoding parameter only applies to single-file targets; use fallback_encoding for a directory.",
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if let Some(encoding) = request.encoding.as_deref()
|
|
403
|
+
&& let Err(rejection) = canonical_encoding_label(encoding)
|
|
404
|
+
{
|
|
405
|
+
return ToolResponse::error(rejection.message(root.display.as_ref()));
|
|
406
|
+
}
|
|
407
|
+
let fallback_encoding_label = match request.fallback_encoding.as_deref() {
|
|
408
|
+
Some(encoding) => match canonical_encoding_label(encoding) {
|
|
409
|
+
Ok(label) => Some(label),
|
|
410
|
+
Err(rejection) => {
|
|
411
|
+
return ToolResponse::error(rejection.message(root.display.as_ref()));
|
|
412
|
+
}
|
|
413
|
+
},
|
|
414
|
+
None => None,
|
|
415
|
+
};
|
|
416
|
+
let search_encoding = SearchEncoding {
|
|
417
|
+
explicit: request.encoding.clone(),
|
|
418
|
+
fallback: request.fallback_encoding.clone(),
|
|
419
|
+
};
|
|
420
|
+
let multiline = request.multiline.unwrap_or(false);
|
|
421
|
+
let pattern = if multiline {
|
|
422
|
+
normalize_multiline_pattern(&request.pattern)
|
|
423
|
+
} else {
|
|
424
|
+
request.pattern.clone()
|
|
425
|
+
};
|
|
426
|
+
let matcher = match build_matcher(
|
|
427
|
+
&pattern,
|
|
428
|
+
request.case_insensitive.unwrap_or(false),
|
|
429
|
+
multiline,
|
|
430
|
+
) {
|
|
431
|
+
Ok(matcher) => Arc::new(matcher),
|
|
432
|
+
Err(error) => {
|
|
433
|
+
return ToolResponse::error(format!(
|
|
434
|
+
"Invalid regex pattern: {error}\nNote: Rust regex syntax — no lookaround or backreferences; escape literal braces."
|
|
435
|
+
));
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
let glob = match build_glob(request.glob.as_ref()) {
|
|
439
|
+
Ok(glob) => glob,
|
|
440
|
+
Err(message) => return ToolResponse::error(message),
|
|
441
|
+
};
|
|
442
|
+
let collected = match collect_search_candidates(
|
|
443
|
+
&root,
|
|
444
|
+
glob.as_ref(),
|
|
445
|
+
request.file_type.as_deref(),
|
|
446
|
+
Some(&operation),
|
|
447
|
+
Some(&executor),
|
|
448
|
+
) {
|
|
449
|
+
Ok(collected) => collected,
|
|
450
|
+
Err(message) => return ToolResponse::error(message),
|
|
451
|
+
};
|
|
452
|
+
let traversal_skips = collected.skipped;
|
|
453
|
+
let candidates = Arc::<[Candidate]>::from(collected.items);
|
|
454
|
+
let offset = request.offset.unwrap_or(0);
|
|
455
|
+
let head_limit = request.head_limit.unwrap_or(DEFAULT_HEAD_LIMIT);
|
|
456
|
+
let mode = request.output_mode.unwrap_or_default();
|
|
457
|
+
let only_matching = request.only_matching.unwrap_or(false);
|
|
458
|
+
let (before_context, after_context) = if mode == OutputMode::Content {
|
|
459
|
+
if let Some(context) = request.context {
|
|
460
|
+
(context, context)
|
|
461
|
+
} else {
|
|
462
|
+
(
|
|
463
|
+
request.before_context.unwrap_or(0),
|
|
464
|
+
request.after_context.unwrap_or(0),
|
|
465
|
+
)
|
|
466
|
+
}
|
|
467
|
+
} else {
|
|
468
|
+
(0, 0)
|
|
469
|
+
};
|
|
470
|
+
if mode == OutputMode::Summary {
|
|
471
|
+
let mut occurrence_total = 0_usize;
|
|
472
|
+
let mut file_total = 0_usize;
|
|
473
|
+
let mut skipped_files = SkippedFiles::from_traversal(&traversal_skips);
|
|
474
|
+
let mut transcoding_notes = BTreeSet::new();
|
|
475
|
+
let mut fallback_usage = FallbackUsage::default();
|
|
476
|
+
let plan = GrepSearchPlan::Count;
|
|
477
|
+
let mut failure: Option<ToolResponse> = None;
|
|
478
|
+
let worker_matcher = Arc::clone(&matcher);
|
|
479
|
+
let worker_encoding = search_encoding.clone();
|
|
480
|
+
let panic_candidates = Arc::clone(&candidates);
|
|
481
|
+
let ordered = for_each_ordered(
|
|
482
|
+
Arc::clone(&candidates),
|
|
483
|
+
operation.clone(),
|
|
484
|
+
Arc::clone(&executor),
|
|
485
|
+
move |_, candidate, work| {
|
|
486
|
+
search_candidate_for_work(
|
|
487
|
+
candidate,
|
|
488
|
+
&worker_matcher,
|
|
489
|
+
plan,
|
|
490
|
+
multiline,
|
|
491
|
+
&worker_encoding,
|
|
492
|
+
work,
|
|
493
|
+
)
|
|
494
|
+
},
|
|
495
|
+
move |index, _| {
|
|
496
|
+
Err(SearchFailure::Fatal(format!(
|
|
497
|
+
"Search worker panicked while processing {}.",
|
|
498
|
+
panic_candidates[index].display
|
|
499
|
+
)))
|
|
500
|
+
},
|
|
501
|
+
|index, outcome, _| {
|
|
502
|
+
let candidate = &candidates[index];
|
|
503
|
+
let outcome = match outcome {
|
|
504
|
+
Ok(outcome) => outcome,
|
|
505
|
+
Err(SearchFailure::Candidate(skip)) if !single_file_target => {
|
|
506
|
+
skipped_files.record(candidate.display.as_ref(), &skip);
|
|
507
|
+
return ControlFlow::Continue(());
|
|
508
|
+
}
|
|
509
|
+
Err(kind) => {
|
|
510
|
+
failure = Some(ToolResponse::error(failure_message(candidate, kind)));
|
|
511
|
+
return ControlFlow::Break(());
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
if let Some(skip) = outcome.skip {
|
|
515
|
+
if single_file_target {
|
|
516
|
+
failure = Some(ToolResponse::error(skip.single_file_message(candidate)));
|
|
517
|
+
return ControlFlow::Break(());
|
|
518
|
+
}
|
|
519
|
+
skipped_files.record(candidate.display.as_ref(), &skip);
|
|
520
|
+
return ControlFlow::Continue(());
|
|
521
|
+
}
|
|
522
|
+
if let Some(note) = outcome.transcoding_note {
|
|
523
|
+
transcoding_notes.insert(note);
|
|
524
|
+
}
|
|
525
|
+
if outcome.used_fallback
|
|
526
|
+
&& let Some(encoding) = fallback_encoding_label
|
|
527
|
+
{
|
|
528
|
+
fallback_usage.record(encoding);
|
|
529
|
+
}
|
|
530
|
+
if let Some(result) = outcome.result {
|
|
531
|
+
file_total = file_total.saturating_add(1);
|
|
532
|
+
occurrence_total = occurrence_total.saturating_add(result.occurrence_count());
|
|
533
|
+
}
|
|
534
|
+
ControlFlow::Continue(())
|
|
535
|
+
},
|
|
536
|
+
);
|
|
537
|
+
if let Err(error) = ordered {
|
|
538
|
+
return ToolResponse::error(ordered_error_message(error));
|
|
539
|
+
}
|
|
540
|
+
if let Some(response) = failure {
|
|
541
|
+
return response;
|
|
542
|
+
}
|
|
543
|
+
let page = PageFormat {
|
|
544
|
+
offset: 0,
|
|
545
|
+
head_limit: 0,
|
|
546
|
+
budget,
|
|
547
|
+
budget_variable,
|
|
548
|
+
scan_complete: true,
|
|
549
|
+
total_entries_seen: 0,
|
|
550
|
+
skipped_files: &skipped_files,
|
|
551
|
+
transcoding_notes: &transcoding_notes,
|
|
552
|
+
fallback_usage: &fallback_usage,
|
|
553
|
+
single_file_target: false,
|
|
554
|
+
operation: Some(&operation),
|
|
555
|
+
};
|
|
556
|
+
return format_summary(occurrence_total, file_total, &page);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
let budget_entry_limit = budget.saturating_mul(4).saturating_add(1).max(1);
|
|
560
|
+
let effective_head_limit = if head_limit == 0 {
|
|
561
|
+
budget_entry_limit
|
|
562
|
+
} else {
|
|
563
|
+
head_limit.min(budget_entry_limit)
|
|
564
|
+
};
|
|
565
|
+
let probe_entry_limit = effective_head_limit.saturating_add(1);
|
|
566
|
+
|
|
567
|
+
let mut results = Vec::new();
|
|
568
|
+
let mut collected_entries = 0_usize;
|
|
569
|
+
let mut skip_remaining = offset;
|
|
570
|
+
let mut total_entries_seen = 0_usize;
|
|
571
|
+
let mut scan_complete = true;
|
|
572
|
+
let mut skipped_files = SkippedFiles::from_traversal(&traversal_skips);
|
|
573
|
+
let mut transcoding_notes = BTreeSet::new();
|
|
574
|
+
let mut fallback_usage = FallbackUsage::default();
|
|
575
|
+
// Every candidate is searched with identical options so files can run in
|
|
576
|
+
// parallel. Content mode over-captures (no skip, worst-case cap of
|
|
577
|
+
// offset + probe); the ordered reduce below trims each file back to
|
|
578
|
+
// exactly the entries the sequential pagination would have selected, so
|
|
579
|
+
// the observable output stays byte-identical to a serial scan.
|
|
580
|
+
let worker_plan = match mode {
|
|
581
|
+
OutputMode::FilesWithMatches => GrepSearchPlan::Exists,
|
|
582
|
+
OutputMode::Count => GrepSearchPlan::Count,
|
|
583
|
+
OutputMode::Content => {
|
|
584
|
+
let spec = ContentSpec {
|
|
585
|
+
multiline,
|
|
586
|
+
skip_entries: 0,
|
|
587
|
+
max_selected_entries: Some(offset.saturating_add(probe_entry_limit)),
|
|
588
|
+
capture_match_text: only_matching,
|
|
589
|
+
before_context,
|
|
590
|
+
after_context,
|
|
591
|
+
capture_heap_limit_bytes,
|
|
592
|
+
};
|
|
593
|
+
if only_matching || multiline {
|
|
594
|
+
GrepSearchPlan::ContentOccurrence(spec)
|
|
595
|
+
} else {
|
|
596
|
+
GrepSearchPlan::ContentLine(spec)
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
OutputMode::Summary => unreachable!("summary is handled before paging"),
|
|
600
|
+
};
|
|
601
|
+
let mut failure: Option<String> = None;
|
|
602
|
+
let worker_matcher = Arc::clone(&matcher);
|
|
603
|
+
let worker_encoding = search_encoding.clone();
|
|
604
|
+
let panic_candidates = Arc::clone(&candidates);
|
|
605
|
+
let ordered = for_each_ordered(
|
|
606
|
+
Arc::clone(&candidates),
|
|
607
|
+
operation.clone(),
|
|
608
|
+
Arc::clone(&executor),
|
|
609
|
+
move |_, candidate, work| {
|
|
610
|
+
search_candidate_for_work(
|
|
611
|
+
candidate,
|
|
612
|
+
&worker_matcher,
|
|
613
|
+
worker_plan,
|
|
614
|
+
multiline,
|
|
615
|
+
&worker_encoding,
|
|
616
|
+
work,
|
|
617
|
+
)
|
|
618
|
+
},
|
|
619
|
+
move |index, _| {
|
|
620
|
+
Err(SearchFailure::Fatal(format!(
|
|
621
|
+
"Search worker panicked while processing {}.",
|
|
622
|
+
panic_candidates[index].display
|
|
623
|
+
)))
|
|
624
|
+
},
|
|
625
|
+
|index, outcome, reducer| {
|
|
626
|
+
let candidate = &candidates[index];
|
|
627
|
+
let (outcome, exact_form) = match outcome {
|
|
628
|
+
Ok(outcome) => (outcome, false),
|
|
629
|
+
Err(SearchFailure::CaptureOverflow) => {
|
|
630
|
+
// The over-capture cap can cross the 64 MiB capture valve
|
|
631
|
+
// where the live window would not; retry with the exact
|
|
632
|
+
// sequential options before surfacing the error.
|
|
633
|
+
if let Err(error) = reducer.retire_generation() {
|
|
634
|
+
failure = Some(ordered_error_message(error));
|
|
635
|
+
return ControlFlow::Break(());
|
|
636
|
+
}
|
|
637
|
+
let exact = if mode == OutputMode::Content {
|
|
638
|
+
worker_plan.with_content_window(
|
|
639
|
+
skip_remaining,
|
|
640
|
+
Some(probe_entry_limit.saturating_sub(collected_entries)),
|
|
641
|
+
)
|
|
642
|
+
} else {
|
|
643
|
+
worker_plan
|
|
644
|
+
};
|
|
645
|
+
let exact_work = operation.inline_work();
|
|
646
|
+
match search_candidate(
|
|
647
|
+
candidate,
|
|
648
|
+
&matcher,
|
|
649
|
+
exact,
|
|
650
|
+
multiline,
|
|
651
|
+
&search_encoding,
|
|
652
|
+
Some(&exact_work),
|
|
653
|
+
) {
|
|
654
|
+
Ok(outcome) => (outcome, true),
|
|
655
|
+
Err(SearchFailure::CaptureOverflow) if !single_file_target => {
|
|
656
|
+
let skip = capture_overflow_skip(candidate);
|
|
657
|
+
skipped_files.record(candidate.display.as_ref(), &skip);
|
|
658
|
+
return ControlFlow::Continue(());
|
|
659
|
+
}
|
|
660
|
+
Err(SearchFailure::Candidate(skip)) if !single_file_target => {
|
|
661
|
+
skipped_files.record(candidate.display.as_ref(), &skip);
|
|
662
|
+
return ControlFlow::Continue(());
|
|
663
|
+
}
|
|
664
|
+
Err(kind) => {
|
|
665
|
+
failure = Some(failure_message(candidate, kind));
|
|
666
|
+
return ControlFlow::Break(());
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
Err(SearchFailure::Candidate(skip)) if !single_file_target => {
|
|
671
|
+
skipped_files.record(candidate.display.as_ref(), &skip);
|
|
672
|
+
return ControlFlow::Continue(());
|
|
673
|
+
}
|
|
674
|
+
Err(kind) => {
|
|
675
|
+
failure = Some(failure_message(candidate, kind));
|
|
676
|
+
return ControlFlow::Break(());
|
|
677
|
+
}
|
|
678
|
+
};
|
|
679
|
+
if let Some(skip) = outcome.skip {
|
|
680
|
+
if single_file_target {
|
|
681
|
+
failure = Some(skip.single_file_message(candidate));
|
|
682
|
+
return ControlFlow::Break(());
|
|
683
|
+
}
|
|
684
|
+
skipped_files.record(candidate.display.as_ref(), &skip);
|
|
685
|
+
return ControlFlow::Continue(());
|
|
686
|
+
}
|
|
687
|
+
if let Some(note) = outcome.transcoding_note {
|
|
688
|
+
transcoding_notes.insert(note);
|
|
689
|
+
}
|
|
690
|
+
if outcome.used_fallback
|
|
691
|
+
&& let Some(encoding) = fallback_encoding_label
|
|
692
|
+
{
|
|
693
|
+
fallback_usage.record(encoding);
|
|
694
|
+
}
|
|
695
|
+
if mode == OutputMode::Content {
|
|
696
|
+
if exact_form {
|
|
697
|
+
// The retried search already applied the live window, so
|
|
698
|
+
// account for it exactly like the sequential loop did.
|
|
699
|
+
total_entries_seen = total_entries_seen.saturating_add(outcome.entries_seen);
|
|
700
|
+
skip_remaining = skip_remaining.saturating_sub(outcome.entries_seen);
|
|
701
|
+
if let Some(result) = outcome.result {
|
|
702
|
+
collected_entries = collected_entries.saturating_add(result.entry_count());
|
|
703
|
+
results.push(result);
|
|
704
|
+
}
|
|
705
|
+
} else if let Some(mut result) = outcome.result {
|
|
706
|
+
// Under the over-capture options every seen entry was
|
|
707
|
+
// selected, so entries[..start] is this file's share of
|
|
708
|
+
// the remaining offset and entries[start..end] is what a
|
|
709
|
+
// sequential scan would have delivered; `end` is also the
|
|
710
|
+
// number of entries that scan would have seen here.
|
|
711
|
+
let available = result.entry_count();
|
|
712
|
+
let need = probe_entry_limit.saturating_sub(collected_entries);
|
|
713
|
+
let start = skip_remaining.min(available);
|
|
714
|
+
let end = available.min(start.saturating_add(need));
|
|
715
|
+
total_entries_seen = total_entries_seen.saturating_add(end);
|
|
716
|
+
skip_remaining = skip_remaining.saturating_sub(start);
|
|
717
|
+
if end > start {
|
|
718
|
+
result.trim_entries(start, end);
|
|
719
|
+
collected_entries = collected_entries.saturating_add(end - start);
|
|
720
|
+
results.push(result);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
} else {
|
|
724
|
+
let Some(result) = outcome.result else {
|
|
725
|
+
return ControlFlow::Continue(());
|
|
726
|
+
};
|
|
727
|
+
total_entries_seen = total_entries_seen.saturating_add(1);
|
|
728
|
+
if skip_remaining > 0 {
|
|
729
|
+
skip_remaining -= 1;
|
|
730
|
+
return ControlFlow::Continue(());
|
|
731
|
+
}
|
|
732
|
+
collected_entries = collected_entries.saturating_add(1);
|
|
733
|
+
results.push(result);
|
|
734
|
+
}
|
|
735
|
+
if collected_entries >= probe_entry_limit {
|
|
736
|
+
scan_complete = false;
|
|
737
|
+
return ControlFlow::Break(());
|
|
738
|
+
}
|
|
739
|
+
ControlFlow::Continue(())
|
|
740
|
+
},
|
|
741
|
+
);
|
|
742
|
+
if let Err(error) = ordered {
|
|
743
|
+
return ToolResponse::error(ordered_error_message(error));
|
|
744
|
+
}
|
|
745
|
+
if let Some(message) = failure {
|
|
746
|
+
return ToolResponse::error(message);
|
|
747
|
+
}
|
|
748
|
+
let page = PageFormat {
|
|
749
|
+
offset,
|
|
750
|
+
head_limit: effective_head_limit,
|
|
751
|
+
budget,
|
|
752
|
+
budget_variable,
|
|
753
|
+
scan_complete,
|
|
754
|
+
total_entries_seen,
|
|
755
|
+
skipped_files: &skipped_files,
|
|
756
|
+
transcoding_notes: &transcoding_notes,
|
|
757
|
+
fallback_usage: &fallback_usage,
|
|
758
|
+
single_file_target,
|
|
759
|
+
operation: Some(&operation),
|
|
760
|
+
};
|
|
761
|
+
if results.is_empty() {
|
|
762
|
+
return if total_entries_seen == 0 {
|
|
763
|
+
zero_result(mode, &page)
|
|
764
|
+
} else {
|
|
765
|
+
offset_exhausted(mode, &page)
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
match mode {
|
|
770
|
+
OutputMode::FilesWithMatches => format_files_mode(&results, &page),
|
|
771
|
+
OutputMode::Count => format_count_mode(&results, &page),
|
|
772
|
+
OutputMode::Content => format_content_mode(&results, &request, &page),
|
|
773
|
+
OutputMode::Summary => unreachable!("summary is handled before paging"),
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
fn build_matcher(
|
|
778
|
+
pattern: &str,
|
|
779
|
+
case_insensitive: bool,
|
|
780
|
+
multiline: bool,
|
|
781
|
+
) -> Result<RegexMatcher, grep_regex::Error> {
|
|
782
|
+
let mut builder = RegexMatcherBuilder::new();
|
|
783
|
+
builder
|
|
784
|
+
.case_insensitive(case_insensitive)
|
|
785
|
+
.multi_line(true)
|
|
786
|
+
.crlf(true)
|
|
787
|
+
.dot_matches_new_line(multiline);
|
|
788
|
+
if multiline {
|
|
789
|
+
builder.line_terminator(None);
|
|
790
|
+
}
|
|
791
|
+
builder.build(pattern)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
fn build_glob(patterns: Option<&GlobPatterns>) -> Result<Option<PathGlobFilter>, String> {
|
|
795
|
+
let Some(patterns) = patterns else {
|
|
796
|
+
return Ok(None);
|
|
797
|
+
};
|
|
798
|
+
PathGlobFilter::compile(patterns, true)
|
|
799
|
+
.map(Some)
|
|
800
|
+
.map_err(|error| {
|
|
801
|
+
format!(
|
|
802
|
+
"Invalid glob pattern: {error}. Use forms like \"*.rs\" or \"**/*.{{ts,tsx}}\"."
|
|
803
|
+
)
|
|
804
|
+
})
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
fn search_candidate(
|
|
808
|
+
candidate: &Candidate,
|
|
809
|
+
matcher: &RegexMatcher,
|
|
810
|
+
plan: GrepSearchPlan,
|
|
811
|
+
multiline: bool,
|
|
812
|
+
encoding: &SearchEncoding,
|
|
813
|
+
operation: Option<&dyn WorkCheckpoint>,
|
|
814
|
+
) -> Result<SearchOutcome, SearchFailure> {
|
|
815
|
+
if let Some(content_multiline) = plan.content_multiline() {
|
|
816
|
+
debug_assert_eq!(content_multiline, multiline);
|
|
817
|
+
}
|
|
818
|
+
let snapshot = match capture_classify(
|
|
819
|
+
candidate,
|
|
820
|
+
encoding.explicit.as_deref(),
|
|
821
|
+
encoding.fallback.as_deref(),
|
|
822
|
+
operation,
|
|
823
|
+
) {
|
|
824
|
+
Ok(CaptureDisposition::Searchable(snapshot)) => snapshot,
|
|
825
|
+
Ok(CaptureDisposition::BinarySkipped(proof)) => {
|
|
826
|
+
debug_assert!(matches!(
|
|
827
|
+
proof,
|
|
828
|
+
crate::file_snapshot::TerminalProof::NulWithinFrozenProbe
|
|
829
|
+
| crate::file_snapshot::TerminalProof::BinaryMagicAfterUtf8Failure
|
|
830
|
+
));
|
|
831
|
+
return Ok(SearchOutcome {
|
|
832
|
+
result: None,
|
|
833
|
+
entries_seen: 0,
|
|
834
|
+
skip: None,
|
|
835
|
+
transcoding_note: None,
|
|
836
|
+
used_fallback: false,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
Ok(CaptureDisposition::EncodingRejected { rejection, proof }) => {
|
|
840
|
+
debug_assert!(proof.rejection().is_some());
|
|
841
|
+
return Ok(SearchOutcome {
|
|
842
|
+
result: None,
|
|
843
|
+
entries_seen: 0,
|
|
844
|
+
skip: Some(CandidateSkip::Encoding(rejection)),
|
|
845
|
+
transcoding_note: None,
|
|
846
|
+
used_fallback: false,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
Ok(CaptureDisposition::FileChanged) => {
|
|
850
|
+
return Ok(SearchOutcome {
|
|
851
|
+
result: None,
|
|
852
|
+
entries_seen: 0,
|
|
853
|
+
skip: Some(CandidateSkip::ChangedWhileSearched),
|
|
854
|
+
transcoding_note: None,
|
|
855
|
+
used_fallback: false,
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
Err(CaptureFailure::Cancelled) => return Err(SearchFailure::Cancelled),
|
|
859
|
+
Err(CaptureFailure::EpochRetired) => return Err(SearchFailure::EpochRetired),
|
|
860
|
+
Err(CaptureFailure::InvalidEncoding(rejection)) => {
|
|
861
|
+
return Err(candidate_failure(
|
|
862
|
+
candidate,
|
|
863
|
+
rejection.message(candidate.display.as_ref()),
|
|
864
|
+
));
|
|
865
|
+
}
|
|
866
|
+
Err(CaptureFailure::Io(error)) => {
|
|
867
|
+
return Err(candidate_failure(
|
|
868
|
+
candidate,
|
|
869
|
+
io_error_message(&candidate.native, &error),
|
|
870
|
+
));
|
|
871
|
+
}
|
|
872
|
+
Err(CaptureFailure::Snapshot(error)) => {
|
|
873
|
+
return Err(candidate_failure(
|
|
874
|
+
candidate,
|
|
875
|
+
snapshot_error_message(candidate, &error),
|
|
876
|
+
));
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
debug_assert_eq!(snapshot.path().native, candidate.native);
|
|
880
|
+
if let Some(bytes) = snapshot.memory_bytes() {
|
|
881
|
+
debug_assert_eq!(snapshot.len(), bytes.len() as u64);
|
|
882
|
+
}
|
|
883
|
+
let source = ByteSource::Snapshot(&snapshot);
|
|
884
|
+
check_search_operation(operation)?;
|
|
885
|
+
let initial = validate_search_encoding(
|
|
886
|
+
&snapshot,
|
|
887
|
+
candidate,
|
|
888
|
+
encoding.explicit.as_deref(),
|
|
889
|
+
operation,
|
|
890
|
+
)?;
|
|
891
|
+
check_search_operation(operation)?;
|
|
892
|
+
let (validated, used_fallback) = match initial {
|
|
893
|
+
EncodingDecision::Text(validated) => (validated, false),
|
|
894
|
+
EncodingDecision::Binary => {
|
|
895
|
+
return Ok(SearchOutcome {
|
|
896
|
+
result: None,
|
|
897
|
+
entries_seen: 0,
|
|
898
|
+
skip: None,
|
|
899
|
+
transcoding_note: None,
|
|
900
|
+
used_fallback: false,
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
EncodingDecision::Rejected(rejection) => match encoding.fallback.as_deref() {
|
|
904
|
+
Some(fallback)
|
|
905
|
+
if encoding.explicit.is_none()
|
|
906
|
+
&& !matches!(rejection, EncodingRejection::BomMismatch { .. }) =>
|
|
907
|
+
{
|
|
908
|
+
match validate_search_encoding(&snapshot, candidate, Some(fallback), operation)? {
|
|
909
|
+
EncodingDecision::Text(validated) => (validated, true),
|
|
910
|
+
EncodingDecision::Binary | EncodingDecision::Rejected(_) => {
|
|
911
|
+
return Ok(SearchOutcome {
|
|
912
|
+
result: None,
|
|
913
|
+
entries_seen: 0,
|
|
914
|
+
skip: Some(CandidateSkip::Encoding(rejection)),
|
|
915
|
+
transcoding_note: None,
|
|
916
|
+
used_fallback: false,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
_ => {
|
|
922
|
+
return Ok(SearchOutcome {
|
|
923
|
+
result: None,
|
|
924
|
+
entries_seen: 0,
|
|
925
|
+
skip: Some(CandidateSkip::Encoding(rejection)),
|
|
926
|
+
transcoding_note: None,
|
|
927
|
+
used_fallback: false,
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
};
|
|
932
|
+
check_search_operation(operation)?;
|
|
933
|
+
let transcoding_note = (!used_fallback)
|
|
934
|
+
.then(|| validated.transcoding_note())
|
|
935
|
+
.flatten();
|
|
936
|
+
let mut searcher = SearcherBuilder::new();
|
|
937
|
+
searcher
|
|
938
|
+
.line_number(true)
|
|
939
|
+
.line_terminator(LineTerminator::crlf())
|
|
940
|
+
.multi_line(multiline)
|
|
941
|
+
.before_context(plan.before_context())
|
|
942
|
+
.after_context(plan.after_context())
|
|
943
|
+
.heap_limit(Some(SEARCH_HEAP_LIMIT_BYTES));
|
|
944
|
+
let mut searcher = searcher.build();
|
|
945
|
+
let content_backing = if plan.content_multiline().is_some() {
|
|
946
|
+
if let Some(start) = validated.utf8_snapshot_start() {
|
|
947
|
+
let range = snapshot.shared_range(start).map_err(|error| {
|
|
948
|
+
candidate_failure(candidate, snapshot_error_message(candidate, &error))
|
|
949
|
+
})?;
|
|
950
|
+
Some(SearchText::from_snapshot(range))
|
|
951
|
+
} else {
|
|
952
|
+
let reader = validated.open_source_reader(source).map_err(|error| {
|
|
953
|
+
candidate_failure(candidate, snapshot_error_message(candidate, &error))
|
|
954
|
+
})?;
|
|
955
|
+
Some(
|
|
956
|
+
SearchText::capture(reader, operation).map_err(|failure| match failure {
|
|
957
|
+
SearchTextFailure::Io(error) => {
|
|
958
|
+
candidate_failure(candidate, snapshot_error_message(candidate, &error))
|
|
959
|
+
}
|
|
960
|
+
SearchTextFailure::Stopped(WorkStop::RequestCancelled) => {
|
|
961
|
+
SearchFailure::Cancelled
|
|
962
|
+
}
|
|
963
|
+
SearchTextFailure::Stopped(WorkStop::EpochRetired) => {
|
|
964
|
+
SearchFailure::EpochRetired
|
|
965
|
+
}
|
|
966
|
+
})?,
|
|
967
|
+
)
|
|
968
|
+
}
|
|
969
|
+
} else {
|
|
970
|
+
None
|
|
971
|
+
};
|
|
972
|
+
let mut sink = PlanSink::new(matcher, plan, operation, content_backing.clone());
|
|
973
|
+
check_search_operation(operation)?;
|
|
974
|
+
#[cfg(test)]
|
|
975
|
+
if let Some(operation) = operation {
|
|
976
|
+
operation.stage(crate::operation::TestStage::BeforeRegexSearch);
|
|
977
|
+
}
|
|
978
|
+
check_search_operation(operation)?;
|
|
979
|
+
let search_result = if let Some(backing) = content_backing {
|
|
980
|
+
match backing.memory_bytes() {
|
|
981
|
+
Some(bytes) => searcher.search_slice(matcher, bytes, &mut sink),
|
|
982
|
+
None => {
|
|
983
|
+
let reader = backing.open_reader().map_err(|error| {
|
|
984
|
+
candidate_failure(candidate, snapshot_error_message(candidate, &error))
|
|
985
|
+
})?;
|
|
986
|
+
searcher.search_reader(matcher, reader, &mut sink)
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
} else {
|
|
990
|
+
match snapshot.memory_bytes() {
|
|
991
|
+
Some(bytes) => {
|
|
992
|
+
let Some(decoded) = validated.decode_for_search(bytes) else {
|
|
993
|
+
return Err(candidate_failure(
|
|
994
|
+
candidate,
|
|
995
|
+
validated
|
|
996
|
+
.malformed_rejection()
|
|
997
|
+
.message(candidate.display.as_ref()),
|
|
998
|
+
));
|
|
999
|
+
};
|
|
1000
|
+
searcher.search_slice(matcher, &decoded, &mut sink)
|
|
1001
|
+
}
|
|
1002
|
+
None => {
|
|
1003
|
+
let reader = validated.open_source_reader(source).map_err(|error| {
|
|
1004
|
+
candidate_failure(candidate, snapshot_error_message(candidate, &error))
|
|
1005
|
+
})?;
|
|
1006
|
+
searcher.search_reader(matcher, reader, &mut sink)
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
};
|
|
1010
|
+
check_search_operation(operation)?;
|
|
1011
|
+
if let Err(error) = search_result {
|
|
1012
|
+
return Err(match error {
|
|
1013
|
+
GrepSinkError::Stopped(WorkStop::RequestCancelled) => SearchFailure::Cancelled,
|
|
1014
|
+
GrepSinkError::Stopped(WorkStop::EpochRetired) => SearchFailure::EpochRetired,
|
|
1015
|
+
GrepSinkError::CaptureOverflow => SearchFailure::CaptureOverflow,
|
|
1016
|
+
GrepSinkError::CountOverflow => candidate_failure(
|
|
1017
|
+
candidate,
|
|
1018
|
+
format!(
|
|
1019
|
+
"Cannot search file {}: the occurrence count overflowed.",
|
|
1020
|
+
candidate.display
|
|
1021
|
+
),
|
|
1022
|
+
),
|
|
1023
|
+
GrepSinkError::Search(message) => candidate_failure(
|
|
1024
|
+
candidate,
|
|
1025
|
+
format!("Cannot search file {}: {message}", candidate.display),
|
|
1026
|
+
),
|
|
1027
|
+
GrepSinkError::Io(error) if error.kind() == io::ErrorKind::InvalidData => {
|
|
1028
|
+
candidate_failure(
|
|
1029
|
+
candidate,
|
|
1030
|
+
validated
|
|
1031
|
+
.malformed_rejection()
|
|
1032
|
+
.message(candidate.display.as_ref()),
|
|
1033
|
+
)
|
|
1034
|
+
}
|
|
1035
|
+
GrepSinkError::Io(error) => {
|
|
1036
|
+
let message = error.to_string().to_ascii_lowercase();
|
|
1037
|
+
candidate_failure(
|
|
1038
|
+
candidate,
|
|
1039
|
+
if message.contains("heap limit") || message.contains("allocation limit") {
|
|
1040
|
+
search_error_message(candidate, &error)
|
|
1041
|
+
} else {
|
|
1042
|
+
snapshot_error_message(candidate, &error)
|
|
1043
|
+
},
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
let sink_output = sink.into_output(
|
|
1049
|
+
candidate.display.to_string(),
|
|
1050
|
+
validated.total_lines,
|
|
1051
|
+
validated.has_trailing_newline,
|
|
1052
|
+
);
|
|
1053
|
+
Ok(SearchOutcome {
|
|
1054
|
+
result: sink_output.result,
|
|
1055
|
+
entries_seen: sink_output.entries_seen,
|
|
1056
|
+
skip: None,
|
|
1057
|
+
transcoding_note,
|
|
1058
|
+
used_fallback,
|
|
1059
|
+
})
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
fn search_candidate_for_work(
|
|
1063
|
+
candidate: &Candidate,
|
|
1064
|
+
matcher: &RegexMatcher,
|
|
1065
|
+
plan: GrepSearchPlan,
|
|
1066
|
+
multiline: bool,
|
|
1067
|
+
encoding: &SearchEncoding,
|
|
1068
|
+
work: &WorkCtx,
|
|
1069
|
+
) -> Result<Result<SearchOutcome, SearchFailure>, WorkStop> {
|
|
1070
|
+
match search_candidate(candidate, matcher, plan, multiline, encoding, Some(work)) {
|
|
1071
|
+
Err(SearchFailure::Cancelled) => Err(WorkStop::RequestCancelled),
|
|
1072
|
+
Err(SearchFailure::EpochRetired) => Err(WorkStop::EpochRetired),
|
|
1073
|
+
outcome => Ok(outcome),
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
fn check_search_operation(operation: Option<&dyn WorkCheckpoint>) -> Result<(), SearchFailure> {
|
|
1078
|
+
match operation.map(WorkCheckpoint::check_work) {
|
|
1079
|
+
Some(Err(WorkStop::RequestCancelled)) => Err(SearchFailure::Cancelled),
|
|
1080
|
+
Some(Err(WorkStop::EpochRetired)) => Err(SearchFailure::EpochRetired),
|
|
1081
|
+
Some(Ok(())) | None => Ok(()),
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
fn validate_search_encoding(
|
|
1086
|
+
snapshot: &crate::file_snapshot::SealedSnapshot,
|
|
1087
|
+
candidate: &Candidate,
|
|
1088
|
+
explicit_encoding: Option<&str>,
|
|
1089
|
+
operation: Option<&dyn WorkCheckpoint>,
|
|
1090
|
+
) -> Result<EncodingDecision, SearchFailure> {
|
|
1091
|
+
validate_snapshot_encoding(snapshot, explicit_encoding, operation).map_err(|failure| {
|
|
1092
|
+
match failure {
|
|
1093
|
+
EncodingPipelineFailure::Io(error) => {
|
|
1094
|
+
candidate_failure(candidate, snapshot_error_message(candidate, &error))
|
|
1095
|
+
}
|
|
1096
|
+
EncodingPipelineFailure::Stopped(WorkStop::RequestCancelled) => {
|
|
1097
|
+
SearchFailure::Cancelled
|
|
1098
|
+
}
|
|
1099
|
+
EncodingPipelineFailure::Stopped(WorkStop::EpochRetired) => SearchFailure::EpochRetired,
|
|
1100
|
+
}
|
|
1101
|
+
})
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
fn ordered_error_message(error: OrderedError) -> String {
|
|
1105
|
+
match error {
|
|
1106
|
+
OrderedError::Cancelled => "Request cancelled.".to_string(),
|
|
1107
|
+
OrderedError::GenerationOverflow => {
|
|
1108
|
+
"Cannot continue ordered search because its generation counter overflowed.".to_string()
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
fn snapshot_error_message(candidate: &Candidate, error: &io::Error) -> String {
|
|
1114
|
+
stable_snapshot_error(candidate.display.as_ref(), error)
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
fn stable_snapshot_error(path: &str, error: &io::Error) -> String {
|
|
1118
|
+
format!(
|
|
1119
|
+
"Cannot create a stable search snapshot for {path}: {error}. Free temporary-disk space or retry after the file stops changing."
|
|
1120
|
+
)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
fn search_error_message(candidate: &Candidate, error: &io::Error) -> String {
|
|
1124
|
+
let message = error.to_string();
|
|
1125
|
+
let lower = message.to_ascii_lowercase();
|
|
1126
|
+
if lower.contains("heap limit") || lower.contains("allocation limit") {
|
|
1127
|
+
format!(
|
|
1128
|
+
"Cannot search file {}: a line or multiline buffer exceeds the 64 MiB safety limit. Narrow the path or search without multiline.",
|
|
1129
|
+
candidate.display
|
|
1130
|
+
)
|
|
1131
|
+
} else {
|
|
1132
|
+
format!("Cannot search file {}: {error}", candidate.display)
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
fn capture_limit_error(candidate: &Candidate) -> String {
|
|
1137
|
+
format!(
|
|
1138
|
+
"Cannot search file {}: matching content and context exceed the 64 MiB safety limit. Narrow the pattern or reduce context.",
|
|
1139
|
+
candidate.display
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
impl PageFormat<'_> {
|
|
1144
|
+
fn work(&self) -> Option<&dyn WorkCheckpoint> {
|
|
1145
|
+
self.operation
|
|
1146
|
+
.map(|operation| operation as &dyn WorkCheckpoint)
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
struct GrepNoteUnits {
|
|
1151
|
+
fixed: Vec<Arc<str>>,
|
|
1152
|
+
details: Vec<Arc<str>>,
|
|
1153
|
+
fallback: Option<Arc<str>>,
|
|
1154
|
+
tally: SkipTally,
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
impl GrepNoteUnits {
|
|
1158
|
+
fn new(page: &PageFormat<'_>) -> Self {
|
|
1159
|
+
let fixed = page
|
|
1160
|
+
.transcoding_notes
|
|
1161
|
+
.iter()
|
|
1162
|
+
.map(|line| Arc::<str>::from(line.as_str()))
|
|
1163
|
+
.collect();
|
|
1164
|
+
let details = page
|
|
1165
|
+
.skipped_files
|
|
1166
|
+
.entries
|
|
1167
|
+
.iter()
|
|
1168
|
+
.map(|entry| Arc::<str>::from(detail_line(&entry.path, &entry.reason)))
|
|
1169
|
+
.collect();
|
|
1170
|
+
let fallback = page.fallback_usage.note().map(Arc::<str>::from);
|
|
1171
|
+
Self {
|
|
1172
|
+
fixed,
|
|
1173
|
+
details,
|
|
1174
|
+
fallback,
|
|
1175
|
+
tally: page.skipped_files.tally(),
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
fn tail(&self, terminal: Arc<str>) -> Vec<Arc<str>> {
|
|
1180
|
+
let mut tail = Vec::with_capacity(2);
|
|
1181
|
+
if let Some(fallback) = &self.fallback {
|
|
1182
|
+
tail.push(Arc::clone(fallback));
|
|
1183
|
+
}
|
|
1184
|
+
tail.push(terminal);
|
|
1185
|
+
tail
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
fn final_notes(&self, shown_skips: usize, terminal: Arc<str>) -> Vec<Arc<str>> {
|
|
1189
|
+
let mut notes = Vec::with_capacity(
|
|
1190
|
+
self.fixed
|
|
1191
|
+
.len()
|
|
1192
|
+
.saturating_add(shown_skips)
|
|
1193
|
+
.saturating_add(2),
|
|
1194
|
+
);
|
|
1195
|
+
notes.extend(self.fixed.iter().cloned());
|
|
1196
|
+
notes.extend(self.details[..shown_skips].iter().cloned());
|
|
1197
|
+
if let Some(fallback) = &self.fallback {
|
|
1198
|
+
notes.push(Arc::clone(fallback));
|
|
1199
|
+
}
|
|
1200
|
+
notes.push(terminal);
|
|
1201
|
+
notes
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
fn baseline_notes(&self, terminal: &str) -> Result<Vec<Arc<str>>, RenderPlanError> {
|
|
1205
|
+
let terminal = Arc::<str>::from(terminal_with_skips(terminal, &self.tally, 0)?);
|
|
1206
|
+
Ok(self.final_notes(0, terminal))
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/// Replays v0.1.1's inclusive binary-probe order without unsigned underflow.
|
|
1211
|
+
fn replay_compat_binary_probes<T, E>(
|
|
1212
|
+
mut low: usize,
|
|
1213
|
+
mut high: usize,
|
|
1214
|
+
mut best: Option<T>,
|
|
1215
|
+
mut probe: impl FnMut(usize) -> Result<Option<T>, E>,
|
|
1216
|
+
) -> Result<Option<T>, E> {
|
|
1217
|
+
while low <= high {
|
|
1218
|
+
let middle = low + (high - low) / 2;
|
|
1219
|
+
if let Some(candidate) = probe(middle)? {
|
|
1220
|
+
best = Some(candidate);
|
|
1221
|
+
let Some(next) = middle.checked_add(1) else {
|
|
1222
|
+
break;
|
|
1223
|
+
};
|
|
1224
|
+
low = next;
|
|
1225
|
+
} else {
|
|
1226
|
+
if middle == 0 {
|
|
1227
|
+
break;
|
|
1228
|
+
}
|
|
1229
|
+
high = middle - 1;
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
Ok(best)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
fn select_body_prefix(
|
|
1236
|
+
graph: &mut LineRenderGraph,
|
|
1237
|
+
maximum: usize,
|
|
1238
|
+
page: &PageFormat<'_>,
|
|
1239
|
+
notes: &GrepNoteUnits,
|
|
1240
|
+
mut terminal: impl FnMut(usize) -> String,
|
|
1241
|
+
) -> Result<Option<(usize, String)>, RenderPlanError> {
|
|
1242
|
+
if maximum == 0 {
|
|
1243
|
+
return Ok(None);
|
|
1244
|
+
}
|
|
1245
|
+
let maximum_terminal = terminal(maximum);
|
|
1246
|
+
let maximum_notes = notes.baseline_notes(&maximum_terminal)?;
|
|
1247
|
+
if graph.probe_notes(maximum, &maximum_notes, page.work())? <= page.budget {
|
|
1248
|
+
return Ok(Some((maximum, maximum_terminal)));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
replay_compat_binary_probes(1, maximum - 1, None, |middle| {
|
|
1252
|
+
let candidate_terminal = terminal(middle);
|
|
1253
|
+
let candidate_notes = notes.baseline_notes(&candidate_terminal)?;
|
|
1254
|
+
if graph.probe_notes(middle, &candidate_notes, page.work())? <= page.budget {
|
|
1255
|
+
Ok(Some((middle, candidate_terminal)))
|
|
1256
|
+
} else {
|
|
1257
|
+
Ok(None)
|
|
1258
|
+
}
|
|
1259
|
+
})
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
fn finish_selected_grep_body(
|
|
1263
|
+
graph: &mut LineRenderGraph,
|
|
1264
|
+
selected: Result<Option<(usize, String)>, RenderPlanError>,
|
|
1265
|
+
page: &PageFormat<'_>,
|
|
1266
|
+
notes: &GrepNoteUnits,
|
|
1267
|
+
) -> ToolResponse {
|
|
1268
|
+
let Some((shown, terminal)) = (match selected {
|
|
1269
|
+
Ok(selected) => selected,
|
|
1270
|
+
Err(error) => return grep_render_failure(error),
|
|
1271
|
+
}) else {
|
|
1272
|
+
return budget_too_small(page.budget, page.budget_variable);
|
|
1273
|
+
};
|
|
1274
|
+
match finish_grep_graph(graph, shown, page, notes, &terminal) {
|
|
1275
|
+
Ok(Some(text)) => ToolResponse::text(text),
|
|
1276
|
+
Ok(None) => budget_too_small(page.budget, page.budget_variable),
|
|
1277
|
+
Err(error) => grep_render_failure(error),
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
fn finish_grep_graph(
|
|
1282
|
+
graph: &mut LineRenderGraph,
|
|
1283
|
+
shown: usize,
|
|
1284
|
+
page: &PageFormat<'_>,
|
|
1285
|
+
notes: &GrepNoteUnits,
|
|
1286
|
+
terminal: &str,
|
|
1287
|
+
) -> Result<Option<String>, RenderPlanError> {
|
|
1288
|
+
let body_checkpoint = graph.checkpoint(shown)?;
|
|
1289
|
+
let Some(selected) = select_grep_notes(&body_checkpoint, shown > 0, page, notes, terminal)?
|
|
1290
|
+
else {
|
|
1291
|
+
return Ok(None);
|
|
1292
|
+
};
|
|
1293
|
+
let rendered = graph.finish(
|
|
1294
|
+
shown,
|
|
1295
|
+
&selected.notes,
|
|
1296
|
+
selected.tokens,
|
|
1297
|
+
page.budget,
|
|
1298
|
+
page.work(),
|
|
1299
|
+
)?;
|
|
1300
|
+
Ok(Some(rendered.text))
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
fn finish_content_grep_view(
|
|
1304
|
+
graph: &mut SharedLineRenderGraph,
|
|
1305
|
+
view: &LineRenderView,
|
|
1306
|
+
page: &PageFormat<'_>,
|
|
1307
|
+
notes: &GrepNoteUnits,
|
|
1308
|
+
terminal: &str,
|
|
1309
|
+
) -> Result<Option<String>, RenderPlanError> {
|
|
1310
|
+
let Some(selected) =
|
|
1311
|
+
select_grep_notes(view.checkpoint(), view.len() > 0, page, notes, terminal)?
|
|
1312
|
+
else {
|
|
1313
|
+
return Ok(None);
|
|
1314
|
+
};
|
|
1315
|
+
let rendered = graph.finish(
|
|
1316
|
+
view,
|
|
1317
|
+
&selected.notes,
|
|
1318
|
+
selected.tokens,
|
|
1319
|
+
page.budget,
|
|
1320
|
+
page.work(),
|
|
1321
|
+
)?;
|
|
1322
|
+
Ok(Some(rendered.text))
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
struct SelectedGrepNotes {
|
|
1326
|
+
notes: Vec<Arc<str>>,
|
|
1327
|
+
tokens: usize,
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
fn select_grep_notes(
|
|
1331
|
+
body_checkpoint: &TokenCheckpoint,
|
|
1332
|
+
prefix_has_body: bool,
|
|
1333
|
+
page: &PageFormat<'_>,
|
|
1334
|
+
notes: &GrepNoteUnits,
|
|
1335
|
+
terminal: &str,
|
|
1336
|
+
) -> Result<Option<SelectedGrepNotes>, RenderPlanError> {
|
|
1337
|
+
let mut details = DetailRenderGraph::new(
|
|
1338
|
+
body_checkpoint,
|
|
1339
|
+
prefix_has_body,
|
|
1340
|
+
¬es.fixed,
|
|
1341
|
+
¬es.details,
|
|
1342
|
+
page.work(),
|
|
1343
|
+
)?;
|
|
1344
|
+
let total_skipped = notes.details.len();
|
|
1345
|
+
|
|
1346
|
+
let full_terminal =
|
|
1347
|
+
Arc::<str>::from(terminal_with_skips(terminal, ¬es.tally, total_skipped)?);
|
|
1348
|
+
let full_tail = notes.tail(Arc::clone(&full_terminal));
|
|
1349
|
+
let full_tokens = details.probe_tail(total_skipped, &full_tail, page.work())?;
|
|
1350
|
+
let (shown_skips, selected_terminal, selected_tokens) = if full_tokens <= page.budget {
|
|
1351
|
+
(total_skipped, full_terminal, full_tokens)
|
|
1352
|
+
} else {
|
|
1353
|
+
let baseline_terminal = Arc::<str>::from(terminal_with_skips(terminal, ¬es.tally, 0)?);
|
|
1354
|
+
let baseline_tail = notes.tail(Arc::clone(&baseline_terminal));
|
|
1355
|
+
let baseline_tokens = details.probe_tail(0, &baseline_tail, page.work())?;
|
|
1356
|
+
if baseline_tokens > page.budget {
|
|
1357
|
+
return Ok(None);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
let baseline = (0_usize, baseline_terminal, baseline_tokens);
|
|
1361
|
+
if total_skipped <= 1 {
|
|
1362
|
+
baseline
|
|
1363
|
+
} else {
|
|
1364
|
+
replay_compat_binary_probes(
|
|
1365
|
+
1,
|
|
1366
|
+
total_skipped - 1,
|
|
1367
|
+
Some(baseline.clone()),
|
|
1368
|
+
|middle| -> Result<Option<(usize, Arc<str>, usize)>, RenderPlanError> {
|
|
1369
|
+
let candidate_terminal =
|
|
1370
|
+
Arc::<str>::from(terminal_with_skips(terminal, ¬es.tally, middle)?);
|
|
1371
|
+
let candidate_tail = notes.tail(Arc::clone(&candidate_terminal));
|
|
1372
|
+
let candidate_tokens =
|
|
1373
|
+
details.probe_tail(middle, &candidate_tail, page.work())?;
|
|
1374
|
+
Ok((candidate_tokens <= page.budget).then_some((
|
|
1375
|
+
middle,
|
|
1376
|
+
candidate_terminal,
|
|
1377
|
+
candidate_tokens,
|
|
1378
|
+
)))
|
|
1379
|
+
},
|
|
1380
|
+
)?
|
|
1381
|
+
.unwrap_or(baseline)
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
Ok(Some(SelectedGrepNotes {
|
|
1386
|
+
notes: notes.final_notes(shown_skips, selected_terminal),
|
|
1387
|
+
tokens: selected_tokens,
|
|
1388
|
+
}))
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
fn grep_render_failure(error: RenderPlanError) -> ToolResponse {
|
|
1392
|
+
if error.is_cancelled() {
|
|
1393
|
+
ToolResponse::error("Request cancelled.")
|
|
1394
|
+
} else {
|
|
1395
|
+
ToolResponse::error(format!("Internal grep rendering failure: {error}"))
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
fn format_files_mode(results: &[FileResult], page: &PageFormat<'_>) -> ToolResponse {
|
|
1400
|
+
let initial = if page.head_limit == 0 {
|
|
1401
|
+
results.len()
|
|
1402
|
+
} else {
|
|
1403
|
+
page.head_limit.min(results.len())
|
|
1404
|
+
};
|
|
1405
|
+
let lines = results[..initial]
|
|
1406
|
+
.iter()
|
|
1407
|
+
.map(|result| Arc::<str>::from(result.path()))
|
|
1408
|
+
.collect::<Vec<_>>();
|
|
1409
|
+
let mut graph = match LineRenderGraph::new(lines, page.work()) {
|
|
1410
|
+
Ok(graph) => graph,
|
|
1411
|
+
Err(error) => return grep_render_failure(error),
|
|
1412
|
+
};
|
|
1413
|
+
let notes = GrepNoteUnits::new(page);
|
|
1414
|
+
let selected = select_body_prefix(&mut graph, initial, page, ¬es, |shown| {
|
|
1415
|
+
let has_more = shown < results.len() || !page.scan_complete;
|
|
1416
|
+
paged_terminal(
|
|
1417
|
+
"file",
|
|
1418
|
+
"files",
|
|
1419
|
+
page.offset,
|
|
1420
|
+
shown,
|
|
1421
|
+
has_more,
|
|
1422
|
+
page.total_entries_seen,
|
|
1423
|
+
)
|
|
1424
|
+
});
|
|
1425
|
+
finish_selected_grep_body(&mut graph, selected, page, ¬es)
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
fn format_count_mode(results: &[FileResult], page: &PageFormat<'_>) -> ToolResponse {
|
|
1429
|
+
let initial = if page.head_limit == 0 {
|
|
1430
|
+
results.len()
|
|
1431
|
+
} else {
|
|
1432
|
+
page.head_limit.min(results.len())
|
|
1433
|
+
};
|
|
1434
|
+
let mut occurrence_prefix = Vec::with_capacity(initial.saturating_add(1));
|
|
1435
|
+
occurrence_prefix.push(0_usize);
|
|
1436
|
+
let mut lines = Vec::with_capacity(initial);
|
|
1437
|
+
for result in &results[..initial] {
|
|
1438
|
+
let next = occurrence_prefix
|
|
1439
|
+
.last()
|
|
1440
|
+
.copied()
|
|
1441
|
+
.unwrap_or(0)
|
|
1442
|
+
.saturating_add(result.occurrence_count());
|
|
1443
|
+
occurrence_prefix.push(next);
|
|
1444
|
+
lines.push(Arc::<str>::from(format!(
|
|
1445
|
+
"{}:{}",
|
|
1446
|
+
result.path(),
|
|
1447
|
+
result.occurrence_count()
|
|
1448
|
+
)));
|
|
1449
|
+
}
|
|
1450
|
+
let mut graph = match LineRenderGraph::new(lines, page.work()) {
|
|
1451
|
+
Ok(graph) => graph,
|
|
1452
|
+
Err(error) => return grep_render_failure(error),
|
|
1453
|
+
};
|
|
1454
|
+
let notes = GrepNoteUnits::new(page);
|
|
1455
|
+
let selected = select_body_prefix(&mut graph, initial, page, ¬es, |shown| {
|
|
1456
|
+
let has_more = shown < results.len() || !page.scan_complete;
|
|
1457
|
+
count_terminal(
|
|
1458
|
+
page.offset,
|
|
1459
|
+
shown,
|
|
1460
|
+
occurrence_prefix[shown],
|
|
1461
|
+
has_more,
|
|
1462
|
+
page.total_entries_seen,
|
|
1463
|
+
)
|
|
1464
|
+
});
|
|
1465
|
+
finish_selected_grep_body(&mut graph, selected, page, ¬es)
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
fn format_content_mode(
|
|
1469
|
+
results: &[FileResult],
|
|
1470
|
+
request: &GrepRequest,
|
|
1471
|
+
page: &PageFormat<'_>,
|
|
1472
|
+
) -> ToolResponse {
|
|
1473
|
+
let entries = results
|
|
1474
|
+
.iter()
|
|
1475
|
+
.enumerate()
|
|
1476
|
+
.flat_map(|(file_index, result)| {
|
|
1477
|
+
result
|
|
1478
|
+
.content()
|
|
1479
|
+
.entries
|
|
1480
|
+
.iter()
|
|
1481
|
+
.copied()
|
|
1482
|
+
.map(move |entry| (file_index, entry))
|
|
1483
|
+
})
|
|
1484
|
+
.collect::<Vec<_>>();
|
|
1485
|
+
let initial = if page.head_limit == 0 {
|
|
1486
|
+
entries.len()
|
|
1487
|
+
} else {
|
|
1488
|
+
page.head_limit.min(entries.len())
|
|
1489
|
+
};
|
|
1490
|
+
let notes = GrepNoteUnits::new(page);
|
|
1491
|
+
let mut render_cache = ContentRenderCache::new();
|
|
1492
|
+
let render_page = |shown: usize| {
|
|
1493
|
+
let has_more = shown < entries.len() || !page.scan_complete;
|
|
1494
|
+
let terminal = paged_terminal(
|
|
1495
|
+
"result",
|
|
1496
|
+
"results",
|
|
1497
|
+
page.offset,
|
|
1498
|
+
shown,
|
|
1499
|
+
has_more,
|
|
1500
|
+
page.total_entries_seen,
|
|
1501
|
+
);
|
|
1502
|
+
render_content_page_with_degradation(
|
|
1503
|
+
results,
|
|
1504
|
+
&entries[..shown],
|
|
1505
|
+
request,
|
|
1506
|
+
page,
|
|
1507
|
+
¬es,
|
|
1508
|
+
&mut render_cache,
|
|
1509
|
+
terminal,
|
|
1510
|
+
)
|
|
1511
|
+
};
|
|
1512
|
+
match fit_largest_content_output(initial, render_page) {
|
|
1513
|
+
Ok(Some(candidate)) => {
|
|
1514
|
+
match finish_content_grep_view(
|
|
1515
|
+
&mut render_cache.token_graph,
|
|
1516
|
+
&candidate.view,
|
|
1517
|
+
page,
|
|
1518
|
+
¬es,
|
|
1519
|
+
&candidate.terminal,
|
|
1520
|
+
) {
|
|
1521
|
+
Ok(Some(text)) => ToolResponse::text(text),
|
|
1522
|
+
Ok(None) => budget_too_small(page.budget, page.budget_variable),
|
|
1523
|
+
Err(error) => grep_render_failure(error),
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
Ok(None) => budget_too_small(page.budget, page.budget_variable),
|
|
1527
|
+
Err(ContentFormatError::Source(message)) => ToolResponse::error(message),
|
|
1528
|
+
Err(ContentFormatError::Render(error)) => grep_render_failure(error),
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1532
|
+
struct ContentBodyCandidate {
|
|
1533
|
+
view: LineRenderView,
|
|
1534
|
+
terminal: String,
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
enum ContentFormatError {
|
|
1538
|
+
Source(String),
|
|
1539
|
+
Render(RenderPlanError),
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
impl From<RenderPlanError> for ContentFormatError {
|
|
1543
|
+
fn from(error: RenderPlanError) -> Self {
|
|
1544
|
+
Self::Render(error)
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
fn fit_largest_content_output(
|
|
1549
|
+
maximum: usize,
|
|
1550
|
+
mut render: impl FnMut(usize) -> Result<Option<ContentBodyCandidate>, ContentFormatError>,
|
|
1551
|
+
) -> Result<Option<ContentBodyCandidate>, ContentFormatError> {
|
|
1552
|
+
if maximum == 0 {
|
|
1553
|
+
return Ok(None);
|
|
1554
|
+
}
|
|
1555
|
+
if let Some(output) = render(maximum)? {
|
|
1556
|
+
return Ok(Some(output));
|
|
1557
|
+
}
|
|
1558
|
+
replay_compat_binary_probes(1, maximum - 1, None, render)
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
fn render_content_page_with_degradation(
|
|
1562
|
+
results: &[FileResult],
|
|
1563
|
+
selected: &[(usize, ContentEntry)],
|
|
1564
|
+
request: &GrepRequest,
|
|
1565
|
+
page: &PageFormat<'_>,
|
|
1566
|
+
notes: &GrepNoteUnits,
|
|
1567
|
+
render_cache: &mut ContentRenderCache,
|
|
1568
|
+
terminal: String,
|
|
1569
|
+
) -> Result<Option<ContentBodyCandidate>, ContentFormatError> {
|
|
1570
|
+
let (requested_before, requested_after) = requested_context(request);
|
|
1571
|
+
let maximum_context = requested_before.max(requested_after);
|
|
1572
|
+
let mut render = |context_depth: usize,
|
|
1573
|
+
match_window: usize|
|
|
1574
|
+
-> Result<Option<ContentBodyCandidate>, ContentFormatError> {
|
|
1575
|
+
let lines = render_content_lines(
|
|
1576
|
+
results,
|
|
1577
|
+
selected,
|
|
1578
|
+
request,
|
|
1579
|
+
context_depth,
|
|
1580
|
+
match_window,
|
|
1581
|
+
page.single_file_target,
|
|
1582
|
+
render_cache,
|
|
1583
|
+
)
|
|
1584
|
+
.map_err(ContentFormatError::Source)?;
|
|
1585
|
+
let view = render_cache.token_graph.prepare_view(lines, page.work())?;
|
|
1586
|
+
let baseline_notes = notes.baseline_notes(&terminal)?;
|
|
1587
|
+
let tokens = render_cache
|
|
1588
|
+
.token_graph
|
|
1589
|
+
.probe_notes(&view, &baseline_notes, page.work())?;
|
|
1590
|
+
Ok((tokens <= page.budget).then_some(ContentBodyCandidate {
|
|
1591
|
+
view,
|
|
1592
|
+
terminal: terminal.clone(),
|
|
1593
|
+
}))
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
let full = render(maximum_context, MAX_MATCH_CHARS)?;
|
|
1597
|
+
if full.is_some() {
|
|
1598
|
+
return Ok(full);
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
let no_context = render(0, MAX_MATCH_CHARS)?;
|
|
1602
|
+
if let Some(no_context) = no_context {
|
|
1603
|
+
return replay_compat_binary_probes(0, maximum_context, Some(no_context), |middle| {
|
|
1604
|
+
render(middle, MAX_MATCH_CHARS)
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
replay_compat_binary_probes(1, MAX_MATCH_CHARS - 1, None, |middle| render(0, middle))
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
1612
|
+
struct ContentPlanKey {
|
|
1613
|
+
shown: usize,
|
|
1614
|
+
before: usize,
|
|
1615
|
+
after: usize,
|
|
1616
|
+
only_matching: bool,
|
|
1617
|
+
single_file_target: bool,
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
#[derive(Clone)]
|
|
1621
|
+
enum PlannedContentLine {
|
|
1622
|
+
Empty,
|
|
1623
|
+
Header(usize),
|
|
1624
|
+
Separator,
|
|
1625
|
+
Context {
|
|
1626
|
+
file_index: usize,
|
|
1627
|
+
line_number: usize,
|
|
1628
|
+
},
|
|
1629
|
+
Match {
|
|
1630
|
+
file_index: usize,
|
|
1631
|
+
line_number: usize,
|
|
1632
|
+
spans: Arc<[LineMatchSpan]>,
|
|
1633
|
+
},
|
|
1634
|
+
OnlyMatch {
|
|
1635
|
+
file_index: usize,
|
|
1636
|
+
start_line: usize,
|
|
1637
|
+
occurrence_index: usize,
|
|
1638
|
+
output_line: usize,
|
|
1639
|
+
},
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
|
1643
|
+
struct MatchRenderKey {
|
|
1644
|
+
file_index: usize,
|
|
1645
|
+
line_number: usize,
|
|
1646
|
+
line_numbers: bool,
|
|
1647
|
+
match_window: usize,
|
|
1648
|
+
spans: Vec<(usize, usize)>,
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
|
1652
|
+
struct OnlyMatchRenderKey {
|
|
1653
|
+
file_index: usize,
|
|
1654
|
+
start_line: usize,
|
|
1655
|
+
occurrence_index: usize,
|
|
1656
|
+
output_line: usize,
|
|
1657
|
+
line_numbers: bool,
|
|
1658
|
+
match_window: usize,
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
struct ContentRenderCache {
|
|
1662
|
+
token_graph: SharedLineRenderGraph,
|
|
1663
|
+
plans: HashMap<ContentPlanKey, Arc<[PlannedContentLine]>>,
|
|
1664
|
+
headers: HashMap<usize, Arc<str>>,
|
|
1665
|
+
contexts: HashMap<(usize, usize, bool), Arc<str>>,
|
|
1666
|
+
matches: HashMap<MatchRenderKey, Arc<str>>,
|
|
1667
|
+
only_matches: HashMap<OnlyMatchRenderKey, Arc<str>>,
|
|
1668
|
+
literals: HashMap<&'static str, Arc<str>>,
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
impl ContentRenderCache {
|
|
1672
|
+
fn new() -> Self {
|
|
1673
|
+
Self {
|
|
1674
|
+
token_graph: SharedLineRenderGraph::new(),
|
|
1675
|
+
plans: HashMap::new(),
|
|
1676
|
+
headers: HashMap::new(),
|
|
1677
|
+
contexts: HashMap::new(),
|
|
1678
|
+
matches: HashMap::new(),
|
|
1679
|
+
only_matches: HashMap::new(),
|
|
1680
|
+
literals: HashMap::new(),
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
fn literal(&mut self, value: &'static str) -> Arc<str> {
|
|
1685
|
+
if let Some(line) = self.literals.get(value) {
|
|
1686
|
+
return Arc::clone(line);
|
|
1687
|
+
}
|
|
1688
|
+
let line = Arc::<str>::from(value);
|
|
1689
|
+
self.literals.insert(value, Arc::clone(&line));
|
|
1690
|
+
line
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
fn header(&mut self, file_index: usize, result: &FileResult) -> Arc<str> {
|
|
1694
|
+
if let Some(line) = self.headers.get(&file_index) {
|
|
1695
|
+
return Arc::clone(line);
|
|
1696
|
+
}
|
|
1697
|
+
let line = Arc::<str>::from(result.path());
|
|
1698
|
+
self.headers.insert(file_index, Arc::clone(&line));
|
|
1699
|
+
line
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
fn context(
|
|
1703
|
+
&mut self,
|
|
1704
|
+
file_index: usize,
|
|
1705
|
+
line_number: usize,
|
|
1706
|
+
line_numbers: bool,
|
|
1707
|
+
result: &FileResult,
|
|
1708
|
+
) -> io::Result<Arc<str>> {
|
|
1709
|
+
let key = (file_index, line_number, line_numbers);
|
|
1710
|
+
if let Some(line) = self.contexts.get(&key) {
|
|
1711
|
+
return Ok(Arc::clone(line));
|
|
1712
|
+
}
|
|
1713
|
+
let source = result_line(result, line_number).ok_or_else(|| {
|
|
1714
|
+
io::Error::new(
|
|
1715
|
+
io::ErrorKind::InvalidData,
|
|
1716
|
+
"captured context line is missing",
|
|
1717
|
+
)
|
|
1718
|
+
})?;
|
|
1719
|
+
let rendered = format_context_line(context_prefix(line_number, line_numbers), source)?;
|
|
1720
|
+
let line = Arc::<str>::from(rendered);
|
|
1721
|
+
self.contexts.insert(key, Arc::clone(&line));
|
|
1722
|
+
Ok(line)
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
fn matching(
|
|
1726
|
+
&mut self,
|
|
1727
|
+
file_index: usize,
|
|
1728
|
+
line_number: usize,
|
|
1729
|
+
line_numbers: bool,
|
|
1730
|
+
match_window: usize,
|
|
1731
|
+
spans: &[LineMatchSpan],
|
|
1732
|
+
result: &FileResult,
|
|
1733
|
+
) -> io::Result<Arc<str>> {
|
|
1734
|
+
let mut span_key = spans
|
|
1735
|
+
.iter()
|
|
1736
|
+
.map(|span| (span.match_char_start, span.match_char_len))
|
|
1737
|
+
.collect::<Vec<_>>();
|
|
1738
|
+
span_key.sort_unstable();
|
|
1739
|
+
let key = MatchRenderKey {
|
|
1740
|
+
file_index,
|
|
1741
|
+
line_number,
|
|
1742
|
+
line_numbers,
|
|
1743
|
+
match_window,
|
|
1744
|
+
spans: span_key,
|
|
1745
|
+
};
|
|
1746
|
+
if let Some(line) = self.matches.get(&key) {
|
|
1747
|
+
return Ok(Arc::clone(line));
|
|
1748
|
+
}
|
|
1749
|
+
let source = result_line(result, line_number).ok_or_else(|| {
|
|
1750
|
+
io::Error::new(
|
|
1751
|
+
io::ErrorKind::InvalidData,
|
|
1752
|
+
"captured matching line is missing",
|
|
1753
|
+
)
|
|
1754
|
+
})?;
|
|
1755
|
+
let rendered = format_match_line(
|
|
1756
|
+
match_prefix(line_number, line_numbers),
|
|
1757
|
+
source,
|
|
1758
|
+
spans,
|
|
1759
|
+
match_window,
|
|
1760
|
+
)?;
|
|
1761
|
+
let line = Arc::<str>::from(rendered);
|
|
1762
|
+
self.matches.insert(key, Arc::clone(&line));
|
|
1763
|
+
Ok(line)
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
fn only_match(&mut self, key: OnlyMatchRenderKey, result: &FileResult) -> io::Result<Arc<str>> {
|
|
1767
|
+
if let Some(line) = self.only_matches.get(&key) {
|
|
1768
|
+
return Ok(Arc::clone(line));
|
|
1769
|
+
}
|
|
1770
|
+
let occurrence = result
|
|
1771
|
+
.content()
|
|
1772
|
+
.occurrences
|
|
1773
|
+
.get(&key.start_line)
|
|
1774
|
+
.and_then(|occurrences| occurrences.get(key.occurrence_index))
|
|
1775
|
+
.ok_or_else(|| {
|
|
1776
|
+
io::Error::new(io::ErrorKind::InvalidData, "captured occurrence is missing")
|
|
1777
|
+
})?;
|
|
1778
|
+
let rendered = format_only_match(
|
|
1779
|
+
match_prefix(key.output_line, key.line_numbers),
|
|
1780
|
+
occurrence.matched_text()?,
|
|
1781
|
+
key.match_window,
|
|
1782
|
+
);
|
|
1783
|
+
let line = Arc::<str>::from(rendered);
|
|
1784
|
+
self.only_matches.insert(key, Arc::clone(&line));
|
|
1785
|
+
Ok(line)
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
fn render_content_lines(
|
|
1790
|
+
results: &[FileResult],
|
|
1791
|
+
selected: &[(usize, ContentEntry)],
|
|
1792
|
+
request: &GrepRequest,
|
|
1793
|
+
context_depth: usize,
|
|
1794
|
+
match_window: usize,
|
|
1795
|
+
single_file_target: bool,
|
|
1796
|
+
cache: &mut ContentRenderCache,
|
|
1797
|
+
) -> Result<Vec<Arc<str>>, String> {
|
|
1798
|
+
let line_numbers = request.line_numbers.unwrap_or(true);
|
|
1799
|
+
let only_matching = request.only_matching.unwrap_or(false);
|
|
1800
|
+
let (requested_before, requested_after) = requested_context(request);
|
|
1801
|
+
let before = requested_before.min(context_depth);
|
|
1802
|
+
let after = requested_after.min(context_depth);
|
|
1803
|
+
let key = ContentPlanKey {
|
|
1804
|
+
shown: selected.len(),
|
|
1805
|
+
before,
|
|
1806
|
+
after,
|
|
1807
|
+
only_matching,
|
|
1808
|
+
single_file_target,
|
|
1809
|
+
};
|
|
1810
|
+
let plan = if let Some(plan) = cache.plans.get(&key) {
|
|
1811
|
+
Arc::clone(plan)
|
|
1812
|
+
} else {
|
|
1813
|
+
let plan = Arc::<[PlannedContentLine]>::from(build_content_plan(
|
|
1814
|
+
results,
|
|
1815
|
+
selected,
|
|
1816
|
+
before,
|
|
1817
|
+
after,
|
|
1818
|
+
only_matching,
|
|
1819
|
+
single_file_target,
|
|
1820
|
+
));
|
|
1821
|
+
cache.plans.insert(key, Arc::clone(&plan));
|
|
1822
|
+
plan
|
|
1823
|
+
};
|
|
1824
|
+
|
|
1825
|
+
let mut lines = Vec::with_capacity(plan.len());
|
|
1826
|
+
for planned in plan.iter() {
|
|
1827
|
+
let rendered = match planned {
|
|
1828
|
+
PlannedContentLine::Empty => cache.literal(""),
|
|
1829
|
+
PlannedContentLine::Header(file_index) => {
|
|
1830
|
+
cache.header(*file_index, &results[*file_index])
|
|
1831
|
+
}
|
|
1832
|
+
PlannedContentLine::Separator => cache.literal("--"),
|
|
1833
|
+
PlannedContentLine::Context {
|
|
1834
|
+
file_index,
|
|
1835
|
+
line_number,
|
|
1836
|
+
} => cache
|
|
1837
|
+
.context(
|
|
1838
|
+
*file_index,
|
|
1839
|
+
*line_number,
|
|
1840
|
+
line_numbers,
|
|
1841
|
+
&results[*file_index],
|
|
1842
|
+
)
|
|
1843
|
+
.map_err(|error| stable_snapshot_error(results[*file_index].path(), &error))?,
|
|
1844
|
+
PlannedContentLine::Match {
|
|
1845
|
+
file_index,
|
|
1846
|
+
line_number,
|
|
1847
|
+
spans,
|
|
1848
|
+
} => cache
|
|
1849
|
+
.matching(
|
|
1850
|
+
*file_index,
|
|
1851
|
+
*line_number,
|
|
1852
|
+
line_numbers,
|
|
1853
|
+
match_window,
|
|
1854
|
+
spans,
|
|
1855
|
+
&results[*file_index],
|
|
1856
|
+
)
|
|
1857
|
+
.map_err(|error| stable_snapshot_error(results[*file_index].path(), &error))?,
|
|
1858
|
+
PlannedContentLine::OnlyMatch {
|
|
1859
|
+
file_index,
|
|
1860
|
+
start_line,
|
|
1861
|
+
occurrence_index,
|
|
1862
|
+
output_line,
|
|
1863
|
+
} => cache
|
|
1864
|
+
.only_match(
|
|
1865
|
+
OnlyMatchRenderKey {
|
|
1866
|
+
file_index: *file_index,
|
|
1867
|
+
start_line: *start_line,
|
|
1868
|
+
occurrence_index: *occurrence_index,
|
|
1869
|
+
output_line: *output_line,
|
|
1870
|
+
line_numbers,
|
|
1871
|
+
match_window,
|
|
1872
|
+
},
|
|
1873
|
+
&results[*file_index],
|
|
1874
|
+
)
|
|
1875
|
+
.map_err(|error| stable_snapshot_error(results[*file_index].path(), &error))?,
|
|
1876
|
+
};
|
|
1877
|
+
lines.push(rendered);
|
|
1878
|
+
}
|
|
1879
|
+
Ok(lines)
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
fn build_content_plan(
|
|
1883
|
+
results: &[FileResult],
|
|
1884
|
+
selected: &[(usize, ContentEntry)],
|
|
1885
|
+
before: usize,
|
|
1886
|
+
after: usize,
|
|
1887
|
+
only_matching: bool,
|
|
1888
|
+
single_file_target: bool,
|
|
1889
|
+
) -> Vec<PlannedContentLine> {
|
|
1890
|
+
let mut by_file = Vec::<(usize, Vec<ContentEntry>)>::new();
|
|
1891
|
+
for (file_index, entry) in selected {
|
|
1892
|
+
if let Some((last_file, entries)) = by_file.last_mut()
|
|
1893
|
+
&& *last_file == *file_index
|
|
1894
|
+
{
|
|
1895
|
+
entries.push(*entry);
|
|
1896
|
+
} else {
|
|
1897
|
+
by_file.push((*file_index, vec![*entry]));
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
let mut lines = Vec::new();
|
|
1901
|
+
for (group_index, (file_index, entries)) in by_file.into_iter().enumerate() {
|
|
1902
|
+
let result = &results[file_index];
|
|
1903
|
+
if !single_file_target {
|
|
1904
|
+
if group_index > 0 {
|
|
1905
|
+
lines.push(PlannedContentLine::Empty);
|
|
1906
|
+
}
|
|
1907
|
+
lines.push(PlannedContentLine::Header(file_index));
|
|
1908
|
+
}
|
|
1909
|
+
if only_matching {
|
|
1910
|
+
plan_only_matching_group(file_index, result, &entries, before, after, &mut lines);
|
|
1911
|
+
} else {
|
|
1912
|
+
plan_matching_line_group(file_index, result, &entries, before, after, &mut lines);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
lines
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
fn requested_context(request: &GrepRequest) -> (usize, usize) {
|
|
1919
|
+
if let Some(context) = request.context {
|
|
1920
|
+
(context, context)
|
|
1921
|
+
} else {
|
|
1922
|
+
(
|
|
1923
|
+
request.before_context.unwrap_or(0),
|
|
1924
|
+
request.after_context.unwrap_or(0),
|
|
1925
|
+
)
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
fn plan_only_matching_group(
|
|
1930
|
+
file_index: usize,
|
|
1931
|
+
result: &FileResult,
|
|
1932
|
+
entries: &[ContentEntry],
|
|
1933
|
+
before: usize,
|
|
1934
|
+
after: usize,
|
|
1935
|
+
lines: &mut Vec<PlannedContentLine>,
|
|
1936
|
+
) {
|
|
1937
|
+
let content = result.content();
|
|
1938
|
+
let mut occurrence_starts = BTreeMap::<usize, Vec<(usize, usize)>>::new();
|
|
1939
|
+
let mut match_ranges = Vec::new();
|
|
1940
|
+
let mut ranges = Vec::new();
|
|
1941
|
+
for entry in entries {
|
|
1942
|
+
for (start_line, occurrence_index) in occurrence_keys(result, *entry) {
|
|
1943
|
+
let occurrence = &content.occurrences[&start_line][occurrence_index];
|
|
1944
|
+
occurrence_starts
|
|
1945
|
+
.entry(occurrence.start_line)
|
|
1946
|
+
.or_default()
|
|
1947
|
+
.push((start_line, occurrence_index));
|
|
1948
|
+
match_ranges.push((occurrence.start_line, occurrence.end_line));
|
|
1949
|
+
ranges.push(context_range(
|
|
1950
|
+
occurrence.start_line,
|
|
1951
|
+
occurrence.end_line,
|
|
1952
|
+
before,
|
|
1953
|
+
after,
|
|
1954
|
+
content.total_lines,
|
|
1955
|
+
));
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
let ranges = merge_ranges(ranges);
|
|
1959
|
+
let match_ranges = merge_ranges(match_ranges);
|
|
1960
|
+
for (block_index, (start, end)) in ranges.into_iter().enumerate() {
|
|
1961
|
+
if block_index > 0 {
|
|
1962
|
+
lines.push(PlannedContentLine::Separator);
|
|
1963
|
+
}
|
|
1964
|
+
for line_number in start..=end {
|
|
1965
|
+
if let Some(keys) = occurrence_starts.get(&line_number) {
|
|
1966
|
+
for (start_line, occurrence_index) in keys {
|
|
1967
|
+
lines.push(PlannedContentLine::OnlyMatch {
|
|
1968
|
+
file_index,
|
|
1969
|
+
start_line: *start_line,
|
|
1970
|
+
occurrence_index: *occurrence_index,
|
|
1971
|
+
output_line: line_number,
|
|
1972
|
+
});
|
|
1973
|
+
}
|
|
1974
|
+
continue;
|
|
1975
|
+
}
|
|
1976
|
+
if ranges_contain(&match_ranges, line_number) {
|
|
1977
|
+
continue;
|
|
1978
|
+
}
|
|
1979
|
+
if result_line(result, line_number).is_some() {
|
|
1980
|
+
lines.push(PlannedContentLine::Context {
|
|
1981
|
+
file_index,
|
|
1982
|
+
line_number,
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
fn plan_matching_line_group(
|
|
1990
|
+
file_index: usize,
|
|
1991
|
+
result: &FileResult,
|
|
1992
|
+
entries: &[ContentEntry],
|
|
1993
|
+
before: usize,
|
|
1994
|
+
after: usize,
|
|
1995
|
+
lines: &mut Vec<PlannedContentLine>,
|
|
1996
|
+
) {
|
|
1997
|
+
let content = result.content();
|
|
1998
|
+
let mut match_ranges = Vec::new();
|
|
1999
|
+
let mut spans = BTreeMap::<usize, Vec<LineMatchSpan>>::new();
|
|
2000
|
+
let mut ranges = Vec::new();
|
|
2001
|
+
for entry in entries {
|
|
2002
|
+
match *entry {
|
|
2003
|
+
ContentEntry::MatchingLine(line_number) => {
|
|
2004
|
+
match_ranges.push((line_number, line_number));
|
|
2005
|
+
if let Some(occurrences) = content.occurrences.get(&line_number) {
|
|
2006
|
+
for occurrence in occurrences {
|
|
2007
|
+
for span in &occurrence.line_spans {
|
|
2008
|
+
spans.entry(span.line_number).or_default().push(*span);
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
ranges.push(context_range(
|
|
2013
|
+
line_number,
|
|
2014
|
+
line_number,
|
|
2015
|
+
before,
|
|
2016
|
+
after,
|
|
2017
|
+
content.total_lines,
|
|
2018
|
+
));
|
|
2019
|
+
}
|
|
2020
|
+
ContentEntry::Occurrence {
|
|
2021
|
+
start_line,
|
|
2022
|
+
occurrence_index,
|
|
2023
|
+
} => {
|
|
2024
|
+
let occurrence = &content.occurrences[&start_line][occurrence_index];
|
|
2025
|
+
match_ranges.push((occurrence.start_line, occurrence.end_line));
|
|
2026
|
+
for span in &occurrence.line_spans {
|
|
2027
|
+
spans.entry(span.line_number).or_default().push(*span);
|
|
2028
|
+
}
|
|
2029
|
+
ranges.push(context_range(
|
|
2030
|
+
occurrence.start_line,
|
|
2031
|
+
occurrence.end_line,
|
|
2032
|
+
before,
|
|
2033
|
+
after,
|
|
2034
|
+
content.total_lines,
|
|
2035
|
+
));
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
let ranges = merge_ranges(ranges);
|
|
2040
|
+
let match_ranges = merge_ranges(match_ranges);
|
|
2041
|
+
for (block_index, (start, end)) in ranges.into_iter().enumerate() {
|
|
2042
|
+
if block_index > 0 {
|
|
2043
|
+
lines.push(PlannedContentLine::Separator);
|
|
2044
|
+
}
|
|
2045
|
+
for line_number in start..=end {
|
|
2046
|
+
if result_line(result, line_number).is_none() {
|
|
2047
|
+
continue;
|
|
2048
|
+
}
|
|
2049
|
+
if ranges_contain(&match_ranges, line_number) {
|
|
2050
|
+
lines.push(PlannedContentLine::Match {
|
|
2051
|
+
file_index,
|
|
2052
|
+
line_number,
|
|
2053
|
+
spans: Arc::from(
|
|
2054
|
+
spans
|
|
2055
|
+
.get(&line_number)
|
|
2056
|
+
.map(Vec::as_slice)
|
|
2057
|
+
.unwrap_or(&[])
|
|
2058
|
+
.to_vec(),
|
|
2059
|
+
),
|
|
2060
|
+
});
|
|
2061
|
+
} else {
|
|
2062
|
+
lines.push(PlannedContentLine::Context {
|
|
2063
|
+
file_index,
|
|
2064
|
+
line_number,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
fn occurrence_keys(result: &FileResult, entry: ContentEntry) -> Vec<(usize, usize)> {
|
|
2072
|
+
match entry {
|
|
2073
|
+
ContentEntry::MatchingLine(line_number) => result
|
|
2074
|
+
.content()
|
|
2075
|
+
.occurrences
|
|
2076
|
+
.get(&line_number)
|
|
2077
|
+
.map(|occurrences| {
|
|
2078
|
+
(0..occurrences.len())
|
|
2079
|
+
.map(|index| (line_number, index))
|
|
2080
|
+
.collect()
|
|
2081
|
+
})
|
|
2082
|
+
.unwrap_or_default(),
|
|
2083
|
+
ContentEntry::Occurrence {
|
|
2084
|
+
start_line,
|
|
2085
|
+
occurrence_index,
|
|
2086
|
+
} => vec![(start_line, occurrence_index)],
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
fn context_range(
|
|
2091
|
+
start_line: usize,
|
|
2092
|
+
end_line: usize,
|
|
2093
|
+
before: usize,
|
|
2094
|
+
after: usize,
|
|
2095
|
+
total_lines: usize,
|
|
2096
|
+
) -> (usize, usize) {
|
|
2097
|
+
(
|
|
2098
|
+
start_line.saturating_sub(before).max(1),
|
|
2099
|
+
end_line.saturating_add(after).min(total_lines),
|
|
2100
|
+
)
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
fn merge_ranges(mut ranges: Vec<(usize, usize)>) -> Vec<(usize, usize)> {
|
|
2104
|
+
ranges.sort_unstable();
|
|
2105
|
+
let mut merged = Vec::<(usize, usize)>::new();
|
|
2106
|
+
for (start, end) in ranges {
|
|
2107
|
+
if let Some(last) = merged.last_mut()
|
|
2108
|
+
&& start <= last.1.saturating_add(1)
|
|
2109
|
+
{
|
|
2110
|
+
last.1 = last.1.max(end);
|
|
2111
|
+
} else {
|
|
2112
|
+
merged.push((start, end));
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
merged
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
fn ranges_contain(ranges: &[(usize, usize)], line_number: usize) -> bool {
|
|
2119
|
+
let index = ranges.partition_point(|(_, end)| *end < line_number);
|
|
2120
|
+
ranges
|
|
2121
|
+
.get(index)
|
|
2122
|
+
.is_some_and(|(start, end)| *start <= line_number && line_number <= *end)
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
#[derive(Clone, Copy)]
|
|
2126
|
+
enum ResultLine<'a> {
|
|
2127
|
+
Captured(&'a CapturedLine),
|
|
2128
|
+
Empty,
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
impl<'a> ResultLine<'a> {
|
|
2132
|
+
fn as_str(self) -> io::Result<&'a str> {
|
|
2133
|
+
match self {
|
|
2134
|
+
Self::Captured(line) => line.as_str(),
|
|
2135
|
+
Self::Empty => Ok(""),
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
fn byte_len(self) -> usize {
|
|
2140
|
+
match self {
|
|
2141
|
+
Self::Captured(line) => line.byte_len(),
|
|
2142
|
+
Self::Empty => 0,
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
fn chars(self) -> io::Result<&'a [char]> {
|
|
2147
|
+
match self {
|
|
2148
|
+
Self::Captured(line) => line.chars(),
|
|
2149
|
+
Self::Empty => Ok(&[]),
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
fn char_count(self) -> io::Result<usize> {
|
|
2154
|
+
match self {
|
|
2155
|
+
Self::Captured(line) => line.char_count(),
|
|
2156
|
+
Self::Empty => Ok(0),
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
fn result_line(result: &FileResult, line_number: usize) -> Option<ResultLine<'_>> {
|
|
2162
|
+
let content = result.content();
|
|
2163
|
+
content
|
|
2164
|
+
.lines
|
|
2165
|
+
.get(&line_number)
|
|
2166
|
+
.map(ResultLine::Captured)
|
|
2167
|
+
.or_else(|| {
|
|
2168
|
+
(line_number == content.total_lines && content.has_trailing_newline)
|
|
2169
|
+
.then_some(ResultLine::Empty)
|
|
2170
|
+
})
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
fn match_prefix(line_number: usize, line_numbers: bool) -> String {
|
|
2174
|
+
if line_numbers {
|
|
2175
|
+
format!("{line_number}:")
|
|
2176
|
+
} else {
|
|
2177
|
+
String::new()
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
fn context_prefix(line_number: usize, line_numbers: bool) -> String {
|
|
2182
|
+
if line_numbers {
|
|
2183
|
+
format!("{line_number}-")
|
|
2184
|
+
} else {
|
|
2185
|
+
String::new()
|
|
2186
|
+
}
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
fn format_only_match(prefix: String, matched_text: &str, match_window: usize) -> String {
|
|
2190
|
+
let match_chars = matched_text.chars().count();
|
|
2191
|
+
let match_window = match_window.max(1);
|
|
2192
|
+
let shown = matched_text.chars().take(match_window).collect::<String>();
|
|
2193
|
+
let shown = shown
|
|
2194
|
+
.replace("\r\n", "\\n")
|
|
2195
|
+
.replace('\n', "\\n")
|
|
2196
|
+
.replace('\r', "\\r");
|
|
2197
|
+
if match_chars <= match_window {
|
|
2198
|
+
format!("{prefix}{shown}")
|
|
2199
|
+
} else {
|
|
2200
|
+
format!("{prefix}{shown}... [match truncated: {match_chars} chars total]")
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
fn format_match_line(
|
|
2205
|
+
prefix: String,
|
|
2206
|
+
line: ResultLine<'_>,
|
|
2207
|
+
match_spans: &[LineMatchSpan],
|
|
2208
|
+
match_window: usize,
|
|
2209
|
+
) -> io::Result<String> {
|
|
2210
|
+
if line.byte_len() <= LONG_LINE_BYTES {
|
|
2211
|
+
return Ok(format!("{prefix}{}", line.as_str()?));
|
|
2212
|
+
}
|
|
2213
|
+
let chars = line.chars()?;
|
|
2214
|
+
let mut spans = match_spans
|
|
2215
|
+
.iter()
|
|
2216
|
+
.map(|span| {
|
|
2217
|
+
let start = span.match_char_start.min(chars.len());
|
|
2218
|
+
let end = start.saturating_add(span.match_char_len).min(chars.len());
|
|
2219
|
+
(start, end)
|
|
2220
|
+
})
|
|
2221
|
+
.collect::<Vec<_>>();
|
|
2222
|
+
if spans.is_empty() {
|
|
2223
|
+
spans.push((0, 0));
|
|
2224
|
+
}
|
|
2225
|
+
spans.sort_unstable();
|
|
2226
|
+
let first_start = spans[0].0;
|
|
2227
|
+
let first_end = spans[0].1;
|
|
2228
|
+
let last_end = spans.iter().map(|(_, end)| *end).max().unwrap_or(first_end);
|
|
2229
|
+
let desired_start = first_start.saturating_sub(MATCH_WINDOW_SIDE_CHARS);
|
|
2230
|
+
let desired_end = last_end
|
|
2231
|
+
.saturating_add(MATCH_WINDOW_SIDE_CHARS)
|
|
2232
|
+
.min(chars.len());
|
|
2233
|
+
let match_window = match_window.max(1);
|
|
2234
|
+
let (window_start, window_end) = if desired_end.saturating_sub(desired_start) <= match_window {
|
|
2235
|
+
(desired_start, desired_end)
|
|
2236
|
+
} else {
|
|
2237
|
+
let before = MATCH_WINDOW_SIDE_CHARS.min(match_window / 4);
|
|
2238
|
+
let mut start = first_start.saturating_sub(before);
|
|
2239
|
+
let mut end = start.saturating_add(match_window).min(chars.len());
|
|
2240
|
+
if end == chars.len() {
|
|
2241
|
+
start = end.saturating_sub(match_window).min(first_start);
|
|
2242
|
+
end = start.saturating_add(match_window).min(chars.len());
|
|
2243
|
+
}
|
|
2244
|
+
(start, end)
|
|
2245
|
+
};
|
|
2246
|
+
let first_match_truncated = first_end > window_end || first_start < window_start;
|
|
2247
|
+
let matches_outside = spans
|
|
2248
|
+
.iter()
|
|
2249
|
+
.any(|(start, end)| *start < window_start || *end > window_end);
|
|
2250
|
+
let mut output = prefix;
|
|
2251
|
+
if window_start > 0 {
|
|
2252
|
+
output.push('…');
|
|
2253
|
+
}
|
|
2254
|
+
output.extend(chars[window_start..window_end].iter());
|
|
2255
|
+
if first_match_truncated {
|
|
2256
|
+
output.push_str(&format!(
|
|
2257
|
+
"... [match truncated: {} chars total]",
|
|
2258
|
+
first_end.saturating_sub(first_start)
|
|
2259
|
+
));
|
|
2260
|
+
}
|
|
2261
|
+
if window_end < chars.len() {
|
|
2262
|
+
output.push('…');
|
|
2263
|
+
}
|
|
2264
|
+
let outside_note = if spans.len() > 1 && matches_outside {
|
|
2265
|
+
"; additional matches fall outside this window"
|
|
2266
|
+
} else {
|
|
2267
|
+
""
|
|
2268
|
+
};
|
|
2269
|
+
output.push_str(&format!(
|
|
2270
|
+
" [line is {} chars; showing window around match(es){outside_note}]",
|
|
2271
|
+
chars.len(),
|
|
2272
|
+
));
|
|
2273
|
+
Ok(output)
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
fn format_context_line(prefix: String, line: ResultLine<'_>) -> io::Result<String> {
|
|
2277
|
+
if line.byte_len() <= LONG_LINE_BYTES {
|
|
2278
|
+
Ok(format!("{prefix}{}", line.as_str()?))
|
|
2279
|
+
} else {
|
|
2280
|
+
Ok(format!(
|
|
2281
|
+
"{prefix}[long line omitted: {} chars]",
|
|
2282
|
+
line.char_count()?
|
|
2283
|
+
))
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
fn terminal_with_skips(
|
|
2288
|
+
terminal: &str,
|
|
2289
|
+
tally: &SkipTally,
|
|
2290
|
+
shown: usize,
|
|
2291
|
+
) -> Result<String, RenderPlanError> {
|
|
2292
|
+
crate::skip_report::terminal_with_skips(terminal, tally, shown)
|
|
2293
|
+
.ok_or(RenderPlanError::InvalidTerminal)
|
|
2294
|
+
}
|
|
2295
|
+
|
|
2296
|
+
fn format_summary(occurrences: usize, files: usize, page: &PageFormat<'_>) -> ToolResponse {
|
|
2297
|
+
let terminal = format!(
|
|
2298
|
+
"(Complete: {} across {}.)",
|
|
2299
|
+
counted(occurrences, "occurrence", "occurrences"),
|
|
2300
|
+
counted(files, "file", "files")
|
|
2301
|
+
);
|
|
2302
|
+
terminal_only_response(terminal, page)
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
fn zero_result(mode: OutputMode, page: &PageFormat<'_>) -> ToolResponse {
|
|
2306
|
+
let terminal = match mode {
|
|
2307
|
+
OutputMode::FilesWithMatches => "(Complete: no files matched.)",
|
|
2308
|
+
OutputMode::Content | OutputMode::Count => "(Complete: no matches found.)",
|
|
2309
|
+
OutputMode::Summary => unreachable!("summary has its own zero-count response"),
|
|
2310
|
+
};
|
|
2311
|
+
terminal_only_response(terminal.to_string(), page)
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
fn offset_exhausted(mode: OutputMode, page: &PageFormat<'_>) -> ToolResponse {
|
|
2315
|
+
let (singular, plural) = match mode {
|
|
2316
|
+
OutputMode::Content => ("result", "results"),
|
|
2317
|
+
OutputMode::FilesWithMatches | OutputMode::Count => ("file", "files"),
|
|
2318
|
+
OutputMode::Summary => unreachable!("summary ignores offset"),
|
|
2319
|
+
};
|
|
2320
|
+
let offset = page.offset;
|
|
2321
|
+
let total = page.total_entries_seen;
|
|
2322
|
+
let verb = if total == 1 { "exists" } else { "exist" };
|
|
2323
|
+
terminal_only_response(
|
|
2324
|
+
format!(
|
|
2325
|
+
"(Complete: no {plural} at offset={offset}; only {} {verb}.)",
|
|
2326
|
+
counted(total, singular, plural)
|
|
2327
|
+
),
|
|
2328
|
+
page,
|
|
2329
|
+
)
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
fn terminal_only_response(terminal: String, page: &PageFormat<'_>) -> ToolResponse {
|
|
2333
|
+
let mut graph = match LineRenderGraph::new(Vec::new(), page.work()) {
|
|
2334
|
+
Ok(graph) => graph,
|
|
2335
|
+
Err(error) => return grep_render_failure(error),
|
|
2336
|
+
};
|
|
2337
|
+
let notes = GrepNoteUnits::new(page);
|
|
2338
|
+
match finish_grep_graph(&mut graph, 0, page, ¬es, &terminal) {
|
|
2339
|
+
Ok(Some(text)) => ToolResponse::text(text),
|
|
2340
|
+
Ok(None) => budget_too_small(page.budget, page.budget_variable),
|
|
2341
|
+
Err(error) => grep_render_failure(error),
|
|
2342
|
+
}
|
|
2343
|
+
}
|
|
2344
|
+
|
|
2345
|
+
fn paged_terminal(
|
|
2346
|
+
singular: &str,
|
|
2347
|
+
plural: &str,
|
|
2348
|
+
offset: usize,
|
|
2349
|
+
shown: usize,
|
|
2350
|
+
has_more: bool,
|
|
2351
|
+
total: usize,
|
|
2352
|
+
) -> String {
|
|
2353
|
+
let range = entry_range(singular, plural, offset + 1, shown);
|
|
2354
|
+
if has_more {
|
|
2355
|
+
format!(
|
|
2356
|
+
"(Partial: {range} shown; more exist. Continue with offset={}.)",
|
|
2357
|
+
offset + shown
|
|
2358
|
+
)
|
|
2359
|
+
} else if offset == 0 {
|
|
2360
|
+
format!(
|
|
2361
|
+
"(Complete: all {} shown.)",
|
|
2362
|
+
counted(total, singular, plural)
|
|
2363
|
+
)
|
|
2364
|
+
} else {
|
|
2365
|
+
format!("(Complete: {range} shown; end of results.)")
|
|
2366
|
+
}
|
|
2367
|
+
}
|
|
2368
|
+
|
|
2369
|
+
fn count_terminal(
|
|
2370
|
+
offset: usize,
|
|
2371
|
+
shown_files: usize,
|
|
2372
|
+
occurrences: usize,
|
|
2373
|
+
has_more: bool,
|
|
2374
|
+
total_files: usize,
|
|
2375
|
+
) -> String {
|
|
2376
|
+
if has_more {
|
|
2377
|
+
format!(
|
|
2378
|
+
"(Partial: {} shown, page subtotal {}; more exist. Continue with offset={}.)",
|
|
2379
|
+
counted(shown_files, "file", "files"),
|
|
2380
|
+
counted(occurrences, "occurrence", "occurrences"),
|
|
2381
|
+
offset + shown_files
|
|
2382
|
+
)
|
|
2383
|
+
} else if offset == 0 {
|
|
2384
|
+
format!(
|
|
2385
|
+
"(Complete: {} across {}.)",
|
|
2386
|
+
counted(occurrences, "occurrence", "occurrences"),
|
|
2387
|
+
counted(total_files, "file", "files")
|
|
2388
|
+
)
|
|
2389
|
+
} else {
|
|
2390
|
+
format!(
|
|
2391
|
+
"(Complete: {} shown, page subtotal {}; end of results.)",
|
|
2392
|
+
entry_range("file", "files", offset + 1, shown_files),
|
|
2393
|
+
counted(occurrences, "occurrence", "occurrences")
|
|
2394
|
+
)
|
|
2395
|
+
}
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
fn entry_range(singular: &str, plural: &str, first: usize, shown: usize) -> String {
|
|
2399
|
+
if shown == 1 {
|
|
2400
|
+
format!("{singular} {first}")
|
|
2401
|
+
} else {
|
|
2402
|
+
format!("{plural} {first}-{}", first + shown - 1)
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
|
|
2406
|
+
fn counted(count: usize, singular: &str, plural: &str) -> String {
|
|
2407
|
+
let noun = if count == 1 { singular } else { plural };
|
|
2408
|
+
format!("{count} {noun}")
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
fn budget_too_small(budget: usize, budget_variable: &str) -> ToolResponse {
|
|
2412
|
+
ErrorBudgetAdapter::new(budget, budget_variable).error(
|
|
2413
|
+
ErrorClass::Budget,
|
|
2414
|
+
format!(
|
|
2415
|
+
"{budget_variable}={budget} is too small to return the required grep continuation note. Increase it and retry."
|
|
2416
|
+
),
|
|
2417
|
+
)
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2420
|
+
fn normalize_multiline_pattern(pattern: &str) -> String {
|
|
2421
|
+
let mut output = String::with_capacity(pattern.len());
|
|
2422
|
+
let chars = pattern.chars().collect::<Vec<_>>();
|
|
2423
|
+
let mut index = 0;
|
|
2424
|
+
while index < chars.len() {
|
|
2425
|
+
if chars[index] == '\n' {
|
|
2426
|
+
output.push_str("\\r?\\n");
|
|
2427
|
+
index += 1;
|
|
2428
|
+
continue;
|
|
2429
|
+
}
|
|
2430
|
+
if chars[index] != '\\' {
|
|
2431
|
+
output.push(chars[index]);
|
|
2432
|
+
index += 1;
|
|
2433
|
+
continue;
|
|
2434
|
+
}
|
|
2435
|
+
let start = index;
|
|
2436
|
+
while index < chars.len() && chars[index] == '\\' {
|
|
2437
|
+
index += 1;
|
|
2438
|
+
}
|
|
2439
|
+
let slash_count = index - start;
|
|
2440
|
+
if index < chars.len() && chars[index] == 'n' && slash_count % 2 == 1 {
|
|
2441
|
+
output.extend(std::iter::repeat_n('\\', slash_count - 1));
|
|
2442
|
+
output.push_str("\\r?\\n");
|
|
2443
|
+
index += 1;
|
|
2444
|
+
} else {
|
|
2445
|
+
output.extend(std::iter::repeat_n('\\', slash_count));
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2448
|
+
output
|
|
2449
|
+
}
|