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.
Files changed (140) hide show
  1. package/CHANGELOG.md +202 -0
  2. package/LICENSE +30 -0
  3. package/NOTICE +106 -0
  4. package/PROVENANCE.md +435 -0
  5. package/README.en.md +126 -0
  6. package/README.md +115 -2
  7. package/README.zh.md +116 -0
  8. package/bin/dsh-ops.mjs +1216 -0
  9. package/cordis.patch.yml +160 -0
  10. package/docs/manual-validation.md +53 -0
  11. package/docs/release-0.2.1.md +72 -0
  12. package/docs/schema-baseline.json +64 -0
  13. package/docs/schema-current.json +84 -0
  14. package/docs/schema-measurement.md +17 -0
  15. package/dsh-plugin.json +88 -0
  16. package/icon.svg +12 -0
  17. package/lib/binary.js +409 -0
  18. package/lib/config.js +198 -0
  19. package/lib/handshake.js +252 -0
  20. package/lib/index.js +108 -0
  21. package/lib/jobs.js +42 -0
  22. package/lib/policy.js +64 -0
  23. package/lib/presentation.js +63 -0
  24. package/lib/profile-install.js +61 -0
  25. package/lib/rust.js +194 -0
  26. package/lib/session-shells.js +78 -0
  27. package/lib/shells.js +998 -0
  28. package/lib/tools.js +657 -0
  29. package/locale/en.json +6 -0
  30. package/locale/zh.json +6 -0
  31. package/package.json +114 -4
  32. package/vendor/fastctx/Cargo.lock +3210 -0
  33. package/vendor/fastctx/Cargo.toml +94 -0
  34. package/vendor/fastctx/FORK.md +119 -0
  35. package/vendor/fastctx/LICENSE-APACHE +201 -0
  36. package/vendor/fastctx/NOTICE +40 -0
  37. package/vendor/fastctx/README.md +439 -0
  38. package/vendor/fastctx/THIRD_PARTY_LICENSES.md +17 -0
  39. package/vendor/fastctx/THIRD_PARTY_LICENSES_RUST.md +7914 -0
  40. package/vendor/fastctx/UPSTREAM.md +49 -0
  41. package/vendor/fastctx/build.rs +413 -0
  42. package/vendor/fastctx/src/background_status.rs +403 -0
  43. package/vendor/fastctx/src/binary.rs +75 -0
  44. package/vendor/fastctx/src/bounded_sort.rs +500 -0
  45. package/vendor/fastctx/src/budget.rs +781 -0
  46. package/vendor/fastctx/src/cli/mod.rs +110 -0
  47. package/vendor/fastctx/src/context_guard.rs +289 -0
  48. package/vendor/fastctx/src/control/mod.rs +6 -0
  49. package/vendor/fastctx/src/control/paths.rs +49 -0
  50. package/vendor/fastctx/src/control/settings.rs +753 -0
  51. package/vendor/fastctx/src/control/transaction.rs +531 -0
  52. package/vendor/fastctx/src/edit/document.rs +535 -0
  53. package/vendor/fastctx/src/edit/locks.rs +371 -0
  54. package/vendor/fastctx/src/edit/mod.rs +213 -0
  55. package/vendor/fastctx/src/edit/private_storage/unix.rs +315 -0
  56. package/vendor/fastctx/src/edit/private_storage/windows.rs +793 -0
  57. package/vendor/fastctx/src/edit/private_storage.rs +234 -0
  58. package/vendor/fastctx/src/edit/replace.rs +1030 -0
  59. package/vendor/fastctx/src/edit_server.rs +53 -0
  60. package/vendor/fastctx/src/encoding/reference_v011.rs +587 -0
  61. package/vendor/fastctx/src/encoding/snapshot_pipeline.rs +1678 -0
  62. package/vendor/fastctx/src/encoding.rs +1118 -0
  63. package/vendor/fastctx/src/file_executor.rs +1151 -0
  64. package/vendor/fastctx/src/file_snapshot.rs +1491 -0
  65. package/vendor/fastctx/src/glob_filter.rs +98 -0
  66. package/vendor/fastctx/src/glob_tool.rs +653 -0
  67. package/vendor/fastctx/src/grep_sink.rs +1162 -0
  68. package/vendor/fastctx/src/grep_tool.rs +2449 -0
  69. package/vendor/fastctx/src/lib.rs +45 -0
  70. package/vendor/fastctx/src/main.rs +15 -0
  71. package/vendor/fastctx/src/model.rs +51 -0
  72. package/vendor/fastctx/src/model_guidance.rs +62 -0
  73. package/vendor/fastctx/src/operation.rs +356 -0
  74. package/vendor/fastctx/src/ordered_window.rs +1235 -0
  75. package/vendor/fastctx/src/os_environment.rs +414 -0
  76. package/vendor/fastctx/src/path_codec.rs +850 -0
  77. package/vendor/fastctx/src/paths.rs +244 -0
  78. package/vendor/fastctx/src/process_identity.rs +763 -0
  79. package/vendor/fastctx/src/process_policy.rs +74 -0
  80. package/vendor/fastctx/src/read_tool/batch.rs +496 -0
  81. package/vendor/fastctx/src/read_tool/hex_file.rs +141 -0
  82. package/vendor/fastctx/src/read_tool/image_file.rs +88 -0
  83. package/vendor/fastctx/src/read_tool/mod.rs +245 -0
  84. package/vendor/fastctx/src/read_tool/pdf.rs +470 -0
  85. package/vendor/fastctx/src/read_tool/pdf_disabled.rs +47 -0
  86. package/vendor/fastctx/src/read_tool/pdf_engine.rs +664 -0
  87. package/vendor/fastctx/src/read_tool/text_file.rs +351 -0
  88. package/vendor/fastctx/src/render_plan.rs +468 -0
  89. package/vendor/fastctx/src/runtime/activity.rs +159 -0
  90. package/vendor/fastctx/src/runtime/hosts.rs +99 -0
  91. package/vendor/fastctx/src/runtime/journal.rs +556 -0
  92. package/vendor/fastctx/src/runtime/local_ipc.rs +186 -0
  93. package/vendor/fastctx/src/runtime/mod.rs +746 -0
  94. package/vendor/fastctx/src/runtime/protocol.rs +296 -0
  95. package/vendor/fastctx/src/runtime/session.rs +536 -0
  96. package/vendor/fastctx/src/runtime/windows_process.rs +66 -0
  97. package/vendor/fastctx/src/search_parallelism.rs +106 -0
  98. package/vendor/fastctx/src/search_text.rs +227 -0
  99. package/vendor/fastctx/src/server.rs +359 -0
  100. package/vendor/fastctx/src/server_manifest.rs +468 -0
  101. package/vendor/fastctx/src/server_support.rs +826 -0
  102. package/vendor/fastctx/src/session.rs +629 -0
  103. package/vendor/fastctx/src/shell/apply_patch_hint.rs +41 -0
  104. package/vendor/fastctx/src/shell/bash.rs +263 -0
  105. package/vendor/fastctx/src/shell/buffer.rs +108 -0
  106. package/vendor/fastctx/src/shell/encoding.rs +403 -0
  107. package/vendor/fastctx/src/shell/foreground.rs +115 -0
  108. package/vendor/fastctx/src/shell/jobs/admission.rs +91 -0
  109. package/vendor/fastctx/src/shell/jobs/background.rs +146 -0
  110. package/vendor/fastctx/src/shell/jobs/host.rs +830 -0
  111. package/vendor/fastctx/src/shell/jobs/identity.rs +29 -0
  112. package/vendor/fastctx/src/shell/jobs/mod.rs +1513 -0
  113. package/vendor/fastctx/src/shell/jobs/model.rs +244 -0
  114. package/vendor/fastctx/src/shell/jobs/output_log.rs +1148 -0
  115. package/vendor/fastctx/src/shell/jobs/store.rs +1300 -0
  116. package/vendor/fastctx/src/shell/mod.rs +345 -0
  117. package/vendor/fastctx/src/shell/normalize.rs +389 -0
  118. package/vendor/fastctx/src/shell/output.rs +406 -0
  119. package/vendor/fastctx/src/shell/process.rs +493 -0
  120. package/vendor/fastctx/src/shell_server.rs +156 -0
  121. package/vendor/fastctx/src/skip_report.rs +83 -0
  122. package/vendor/fastctx/src/stdio_transport.rs +177 -0
  123. package/vendor/fastctx/src/tool_schema.rs +204 -0
  124. package/vendor/fastctx/src/traversal.rs +846 -0
  125. package/vendor/fastctx/third-party/pdfium-7763/LICENSE +9 -0
  126. package/vendor/fastctx/third-party/pdfium-7763/licenses/abseil.txt +202 -0
  127. package/vendor/fastctx/third-party/pdfium-7763/licenses/agg23.txt +14 -0
  128. package/vendor/fastctx/third-party/pdfium-7763/licenses/fast_float.txt +27 -0
  129. package/vendor/fastctx/third-party/pdfium-7763/licenses/freetype.txt +169 -0
  130. package/vendor/fastctx/third-party/pdfium-7763/licenses/icu.txt +542 -0
  131. package/vendor/fastctx/third-party/pdfium-7763/licenses/lcms.txt +27 -0
  132. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.ijg +260 -0
  133. package/vendor/fastctx/third-party/pdfium-7763/licenses/libjpeg_turbo.md +135 -0
  134. package/vendor/fastctx/third-party/pdfium-7763/licenses/libopenjpeg.txt +32 -0
  135. package/vendor/fastctx/third-party/pdfium-7763/licenses/libpng.txt +134 -0
  136. package/vendor/fastctx/third-party/pdfium-7763/licenses/libtiff.txt +21 -0
  137. package/vendor/fastctx/third-party/pdfium-7763/licenses/llvm-libc.txt +278 -0
  138. package/vendor/fastctx/third-party/pdfium-7763/licenses/pdfium.txt +230 -0
  139. package/vendor/fastctx/third-party/pdfium-7763/licenses/simdutf.txt +18 -0
  140. package/vendor/fastctx/third-party/pdfium-7763/licenses/zlib.txt +29 -0
@@ -0,0 +1,846 @@
1
+ //! Shared project traversal with lossless search paths, partial results, and a
2
+ //! deterministic report of whatever the walk could not reach.
3
+
4
+ use crate::bounded_sort::sort_cancelable;
5
+ use crate::file_executor::{BurstUse, GrepGlobExecutor};
6
+ use crate::glob_filter::PathGlobFilter;
7
+ use crate::operation::OperationCtx;
8
+ #[cfg(test)]
9
+ use crate::operation::TestStage;
10
+ use crate::path_codec::{
11
+ PathRecord, ResolvedRoot, RootKind, display_path as search_display_path,
12
+ io_error_message as search_io_error_message,
13
+ };
14
+ use ignore::types::TypesBuilder;
15
+ use ignore::{DirEntry, WalkBuilder, WalkState};
16
+ use parking_lot::Mutex;
17
+ use std::collections::BTreeMap;
18
+ use std::fs;
19
+ use std::io;
20
+ use std::panic::{AssertUnwindSafe, catch_unwind};
21
+ use std::path::Path;
22
+ use std::sync::Arc;
23
+ use std::sync::atomic::{AtomicBool, Ordering};
24
+
25
+ pub(crate) const TRAVERSAL_BATCH_ITEMS: usize = 256;
26
+
27
+ /// Upper bound on skipped paths kept with full detail. A damaged tree can raise
28
+ /// far more failures than any response could carry, so detail is bounded while
29
+ /// the tally stays exact.
30
+ const SKIPPED_DETAIL_CAP: usize = 256;
31
+
32
+ /// Legacy replace candidate retained while search uses `PathRecord` directly.
33
+ #[derive(Debug)]
34
+ pub(crate) struct ProjectCandidate {
35
+ pub(crate) display: String,
36
+ }
37
+
38
+ /// The schedule-independent ordering key for one traversal failure.
39
+ #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
40
+ pub(crate) struct TraversalErrorKey {
41
+ /// Doubles as the reported path: `String` orders by the same bytes the key
42
+ /// needs, so the failure never carries a second copy of it.
43
+ pub(crate) display: String,
44
+ pub(crate) kind_rank: u8,
45
+ pub(crate) raw_os_error: Option<i32>,
46
+ pub(crate) normalized_message: String,
47
+ }
48
+
49
+ #[derive(Clone, Debug, Eq, PartialEq)]
50
+ pub(crate) struct TraversalFailure {
51
+ pub(crate) key: TraversalErrorKey,
52
+ /// Standalone sentence used when the failure ends the whole request.
53
+ pub(crate) message: String,
54
+ /// Short cause phrase for skip reporting, where the path is already shown.
55
+ reason: String,
56
+ }
57
+
58
+ /// Existing collection limit enforced at the first item beyond `maximum`.
59
+ #[derive(Clone, Copy)]
60
+ pub(crate) struct TraversalLimit {
61
+ pub(crate) maximum: usize,
62
+ pub(crate) message: &'static str,
63
+ }
64
+
65
+ /// One path a walk could not enter or evaluate.
66
+ #[derive(Clone, Copy, Debug, Eq, PartialEq)]
67
+ pub(crate) struct SkippedPath<'a> {
68
+ pub(crate) display: &'a str,
69
+ pub(crate) reason: &'a str,
70
+ }
71
+
72
+ /// Paths a walk had to skip, deduplicated by failure identity and ordered by the
73
+ /// same schedule-independent key the walk uses everywhere else, so the report is
74
+ /// identical across serial and parallel runs.
75
+ #[derive(Debug, Default)]
76
+ pub(crate) struct SkippedPaths {
77
+ /// Keyed by failure identity; the value is that failure's cause phrase.
78
+ entries: BTreeMap<TraversalErrorKey, String>,
79
+ beyond_cap: usize,
80
+ /// Lowest-ranked failure, kept verbatim for the paths that still fail whole.
81
+ minimum: Option<(TraversalErrorKey, String)>,
82
+ }
83
+
84
+ /// Batched traversal output: what the walk collected, plus what it could not reach.
85
+ pub(crate) struct TraversalCollection<T> {
86
+ pub(crate) items: Vec<T>,
87
+ pub(crate) skipped: SkippedPaths,
88
+ }
89
+
90
+ impl SkippedPaths {
91
+ fn record(&mut self, failure: TraversalFailure) {
92
+ let TraversalFailure {
93
+ key,
94
+ message,
95
+ reason,
96
+ } = failure;
97
+ if self
98
+ .minimum
99
+ .as_ref()
100
+ .is_none_or(|(existing, _)| key < *existing)
101
+ {
102
+ self.minimum = Some((key.clone(), message));
103
+ }
104
+ self.insert(key, reason);
105
+ }
106
+
107
+ /// Past the cap the key is dropped, so repeats of an already-overflowed
108
+ /// failure add to the tally again. Holding every key just to keep the count
109
+ /// exact would defeat the bound the cap exists to provide, and a walk that
110
+ /// raised more than `SKIPPED_DETAIL_CAP` distinct failures is already being
111
+ /// told "large parts of this tree were not searched".
112
+ fn insert(&mut self, key: TraversalErrorKey, reason: String) {
113
+ if self.entries.contains_key(&key) {
114
+ return;
115
+ }
116
+ if self.entries.len() < SKIPPED_DETAIL_CAP {
117
+ self.entries.insert(key, reason);
118
+ } else {
119
+ self.beyond_cap = self.beyond_cap.saturating_add(1);
120
+ }
121
+ }
122
+
123
+ fn merge(&mut self, other: Self) {
124
+ if let Some((key, message)) = other.minimum
125
+ && self
126
+ .minimum
127
+ .as_ref()
128
+ .is_none_or(|(existing, _)| key < *existing)
129
+ {
130
+ self.minimum = Some((key, message));
131
+ }
132
+ for (key, reason) in other.entries {
133
+ self.insert(key, reason);
134
+ }
135
+ self.beyond_cap = self.beyond_cap.saturating_add(other.beyond_cap);
136
+ }
137
+
138
+ /// The message a walk reports when the skip cannot be survived.
139
+ fn root_failure_message(&self) -> Option<String> {
140
+ self.minimum.as_ref().map(|(_, message)| message.clone())
141
+ }
142
+
143
+ /// Also answers "has anything been recorded at all": `record` always both
144
+ /// sets `minimum` and lands in one of the two counters, so an empty report
145
+ /// can never be hiding a failure the parallel merge would drop.
146
+ pub(crate) fn is_empty(&self) -> bool {
147
+ self.entries.is_empty() && self.beyond_cap == 0
148
+ }
149
+
150
+ /// Every skipped path, listed and unlisted alike; exact up to the cap.
151
+ pub(crate) fn total(&self) -> usize {
152
+ self.entries.len().saturating_add(self.beyond_cap)
153
+ }
154
+
155
+ /// Paths kept with full detail, in deterministic order.
156
+ pub(crate) fn listed(&self) -> impl ExactSizeIterator<Item = SkippedPath<'_>> {
157
+ self.entries.iter().map(|(key, reason)| SkippedPath {
158
+ display: &key.display,
159
+ reason,
160
+ })
161
+ }
162
+
163
+ /// Paths counted but dropped from the detail list at the cap.
164
+ pub(crate) fn unlisted(&self) -> usize {
165
+ self.beyond_cap
166
+ }
167
+ }
168
+
169
+ impl TraversalFailure {
170
+ pub(crate) fn from_io(path: &Path, error: &io::Error) -> Self {
171
+ Self {
172
+ key: TraversalErrorKey {
173
+ display: search_display_path(path),
174
+ kind_rank: io_kind_rank(error.kind()),
175
+ raw_os_error: error.raw_os_error(),
176
+ normalized_message: normalize_error_message(&error.to_string()),
177
+ },
178
+ message: search_io_error_message(path, error),
179
+ reason: io_reason(error),
180
+ }
181
+ }
182
+
183
+ pub(crate) fn from_other(path: &Path, message: String, reason: String) -> Self {
184
+ Self {
185
+ key: TraversalErrorKey {
186
+ display: search_display_path(path),
187
+ kind_rank: u8::MAX,
188
+ raw_os_error: None,
189
+ normalized_message: normalize_error_message(&message),
190
+ },
191
+ message,
192
+ reason,
193
+ }
194
+ }
195
+ }
196
+
197
+ /// Mirrors `path_codec::io_error_message`'s cases without repeating the path,
198
+ /// which skip reports already carry in their own column.
199
+ fn io_reason(error: &io::Error) -> String {
200
+ #[cfg(windows)]
201
+ if matches!(error.raw_os_error(), Some(32 | 33)) {
202
+ return "locked by another process".to_string();
203
+ }
204
+ if error.kind() == io::ErrorKind::PermissionDenied {
205
+ return "permission denied".to_string();
206
+ }
207
+ error.to_string()
208
+ }
209
+
210
+ /// Collects lossless grep candidates while reusing the root's sole metadata result.
211
+ pub(crate) fn collect_search_candidates(
212
+ root: &ResolvedRoot,
213
+ glob: Option<&PathGlobFilter>,
214
+ file_type: Option<&str>,
215
+ operation: Option<&OperationCtx>,
216
+ executor: Option<&Arc<GrepGlobExecutor>>,
217
+ ) -> Result<TraversalCollection<PathRecord>, String> {
218
+ if operation_cancelled(operation) {
219
+ return Err("Request cancelled.".to_string());
220
+ }
221
+ let type_filter = build_type_filter(file_type)?;
222
+ let mut candidates = Vec::new();
223
+ let mut skipped = SkippedPaths::default();
224
+ if root.kind == RootKind::File {
225
+ let candidate =
226
+ PathRecord::from_metadata(&root.native, root.match_root(), &root.metadata, true)
227
+ .map_err(|error| search_io_error_message(&root.native, &error))?;
228
+ if matches_record(&candidate, glob, type_filter.as_ref()) {
229
+ candidates.push(candidate);
230
+ }
231
+ } else {
232
+ let collected = collect_directory_candidates(root, glob, type_filter, operation, executor)?;
233
+ candidates = collected.items;
234
+ skipped = collected.skipped;
235
+ }
236
+ let items = sort_cancelable(candidates, compare_search_candidates, operation, executor)
237
+ .map(|sorted| sorted.items)
238
+ .map_err(|error| error.to_string())?;
239
+ Ok(TraversalCollection { items, skipped })
240
+ }
241
+
242
+ /// Collects files for replace while preserving its pre-codec display contract.
243
+ pub(crate) fn collect_project_candidates(
244
+ root: &Path,
245
+ glob: Option<&PathGlobFilter>,
246
+ file_type: Option<&str>,
247
+ ) -> Result<TraversalCollection<ProjectCandidate>, String> {
248
+ let metadata =
249
+ fs::metadata(root).map_err(|error| crate::paths::io_error_message(root, &error))?;
250
+ let resolved = ResolvedRoot::from_metadata(root.to_path_buf(), metadata)?;
251
+ collect_search_candidates(&resolved, glob, file_type, None, None).map(|collected| {
252
+ TraversalCollection {
253
+ items: collected
254
+ .items
255
+ .into_iter()
256
+ .map(|candidate| ProjectCandidate {
257
+ display: crate::paths::display_path(&candidate.native),
258
+ })
259
+ .collect(),
260
+ skipped: collected.skipped,
261
+ }
262
+ })
263
+ }
264
+
265
+ fn build_type_filter(file_type: Option<&str>) -> Result<Option<ignore::types::Types>, String> {
266
+ let Some(file_type) = file_type else {
267
+ return Ok(None);
268
+ };
269
+ let mut builder = TypesBuilder::new();
270
+ builder.add_defaults();
271
+ builder.select(file_type);
272
+ builder.build().map(Some).map_err(|_| {
273
+ format!(
274
+ "Unknown file type: \"{file_type}\". Run with a glob filter instead, or use a standard type like js, py, rust, go, java."
275
+ )
276
+ })
277
+ }
278
+
279
+ fn collect_directory_candidates(
280
+ root: &ResolvedRoot,
281
+ glob: Option<&PathGlobFilter>,
282
+ type_filter: Option<ignore::types::Types>,
283
+ operation: Option<&OperationCtx>,
284
+ executor: Option<&Arc<GrepGlobExecutor>>,
285
+ ) -> Result<TraversalCollection<PathRecord>, String> {
286
+ let mut builder = WalkBuilder::new(&root.native);
287
+ builder
288
+ .hidden(false)
289
+ .ignore(true)
290
+ .git_ignore(true)
291
+ .git_global(true)
292
+ .git_exclude(true)
293
+ .follow_links(false)
294
+ .filter_entry(|entry| entry.depth() == 0 || entry.file_name() != ".git");
295
+ if let Some(types) = type_filter {
296
+ builder.types(types);
297
+ }
298
+
299
+ collect_walk_batched(builder, &root.native, operation, executor, None, |entry| {
300
+ if !entry
301
+ .file_type()
302
+ .is_some_and(|file_type| file_type.is_file() || file_type.is_symlink())
303
+ {
304
+ return Ok(None);
305
+ }
306
+ let preliminary = PathRecord::without_metadata(entry.path(), &root.native);
307
+ if !matches_record(&preliminary, glob, None) {
308
+ return Ok(None);
309
+ }
310
+ candidate_from_entry(entry, &root.native)
311
+ })
312
+ }
313
+
314
+ /// Runs a true serial walker when no traversal credit is immediately available;
315
+ /// parallel walkers merge only fixed-size thread-local batches.
316
+ pub(crate) fn collect_walk_batched<T, F>(
317
+ mut builder: WalkBuilder,
318
+ root: &Path,
319
+ operation: Option<&OperationCtx>,
320
+ executor: Option<&Arc<GrepGlobExecutor>>,
321
+ limit: Option<TraversalLimit>,
322
+ evaluate: F,
323
+ ) -> Result<TraversalCollection<T>, String>
324
+ where
325
+ T: Send,
326
+ F: Fn(&DirEntry) -> Result<Option<T>, TraversalFailure> + Send + Sync,
327
+ {
328
+ if operation_cancelled(operation) {
329
+ return Err("Request cancelled.".to_string());
330
+ }
331
+ let permits = executor
332
+ .map(|executor| executor.try_bursts(executor.extra_capacity(), BurstUse::TraversalExtra))
333
+ .unwrap_or_default();
334
+ if permits.is_empty() {
335
+ return collect_walk_serial(builder, root, operation, limit, &evaluate);
336
+ }
337
+
338
+ let thread_count = permits.len().saturating_add(1);
339
+ builder.threads(thread_count);
340
+ let shared = Mutex::new(ParallelCollectionState::<T>::default());
341
+ let stop = AtomicBool::new(false);
342
+ let cancelled = AtomicBool::new(false);
343
+ let evaluate = &evaluate;
344
+ let run = catch_unwind(AssertUnwindSafe(|| {
345
+ builder.build_parallel().run(|| {
346
+ let mut local = ParallelLocalBatch::new(&shared, &stop, &cancelled, operation, limit);
347
+ Box::new(move |entry| {
348
+ process_parallel_entry(entry, root, operation, evaluate, &mut local)
349
+ })
350
+ });
351
+ }));
352
+ drop(permits);
353
+ if cancelled.load(Ordering::Acquire) || operation_cancelled(operation) {
354
+ return Err("Request cancelled.".to_string());
355
+ }
356
+ if run.is_err() {
357
+ return Err("Internal traversal worker failure.".to_string());
358
+ }
359
+ finish_parallel_collection(shared.into_inner(), limit)
360
+ }
361
+
362
+ fn collect_walk_serial<T, F>(
363
+ builder: WalkBuilder,
364
+ root: &Path,
365
+ operation: Option<&OperationCtx>,
366
+ limit: Option<TraversalLimit>,
367
+ evaluate: &F,
368
+ ) -> Result<TraversalCollection<T>, String>
369
+ where
370
+ F: Fn(&DirEntry) -> Result<Option<T>, TraversalFailure>,
371
+ {
372
+ let mut items = Vec::new();
373
+ let mut skipped = SkippedPaths::default();
374
+ let mut entries_seen = 0_usize;
375
+ let mut too_many = false;
376
+ for entry in builder.build() {
377
+ stage_traversal_entry(operation);
378
+ if operation_cancelled(operation) {
379
+ return Err("Request cancelled.".to_string());
380
+ }
381
+ let entry = match entry {
382
+ Ok(entry) => entry,
383
+ Err(error) => {
384
+ for failure in traversal_errors_from_ignore(&error, root) {
385
+ skipped.record(failure);
386
+ }
387
+ continue;
388
+ }
389
+ };
390
+ entries_seen = entries_seen.saturating_add(1);
391
+ let evaluated = catch_unwind(AssertUnwindSafe(|| evaluate(&entry)));
392
+ match evaluated {
393
+ Ok(Ok(Some(item))) => {
394
+ if limit.is_some_and(|limit| items.len() >= limit.maximum) {
395
+ too_many = true;
396
+ break;
397
+ }
398
+ items.push(item);
399
+ }
400
+ Ok(Ok(None)) => {}
401
+ Ok(Err(failure)) => skipped.record(failure),
402
+ Err(_) => skipped.record(evaluation_panic(entry.path())),
403
+ }
404
+ }
405
+ if operation_cancelled(operation) {
406
+ return Err("Request cancelled.".to_string());
407
+ }
408
+ if too_many {
409
+ return match limit {
410
+ Some(limit) => Err(limit.message.to_string()),
411
+ None => Err("Internal traversal limit state was inconsistent.".to_string()),
412
+ };
413
+ }
414
+ finish_collection(items, skipped, entries_seen)
415
+ }
416
+
417
+ /// A walk that never yielded a single entry never happened: the root itself was
418
+ /// unreadable, so reporting it as "no results, some paths skipped" would read as
419
+ /// "searched, found nothing". Those still fail whole, with the message the walk
420
+ /// would have produced before partial results existed.
421
+ fn finish_collection<T>(
422
+ items: Vec<T>,
423
+ skipped: SkippedPaths,
424
+ entries_seen: usize,
425
+ ) -> Result<TraversalCollection<T>, String> {
426
+ if entries_seen == 0
427
+ && let Some(message) = skipped.root_failure_message()
428
+ {
429
+ return Err(message);
430
+ }
431
+ Ok(TraversalCollection { items, skipped })
432
+ }
433
+
434
+ fn evaluation_panic(path: &Path) -> TraversalFailure {
435
+ TraversalFailure::from_other(
436
+ path,
437
+ "Internal traversal failure while evaluating a file candidate.".to_string(),
438
+ "internal failure while evaluating this path".to_string(),
439
+ )
440
+ }
441
+
442
+ struct ParallelCollectionState<T> {
443
+ items: Vec<T>,
444
+ skipped: SkippedPaths,
445
+ entries_seen: usize,
446
+ too_many: bool,
447
+ }
448
+
449
+ impl<T> Default for ParallelCollectionState<T> {
450
+ fn default() -> Self {
451
+ Self {
452
+ items: Vec::new(),
453
+ skipped: SkippedPaths::default(),
454
+ entries_seen: 0,
455
+ too_many: false,
456
+ }
457
+ }
458
+ }
459
+
460
+ struct ParallelLocalBatch<'a, T> {
461
+ shared: &'a Mutex<ParallelCollectionState<T>>,
462
+ stop: &'a AtomicBool,
463
+ cancelled: &'a AtomicBool,
464
+ operation: Option<&'a OperationCtx>,
465
+ limit: Option<TraversalLimit>,
466
+ items: Vec<T>,
467
+ skipped: SkippedPaths,
468
+ entries_seen: usize,
469
+ }
470
+
471
+ impl<'a, T> ParallelLocalBatch<'a, T> {
472
+ fn new(
473
+ shared: &'a Mutex<ParallelCollectionState<T>>,
474
+ stop: &'a AtomicBool,
475
+ cancelled: &'a AtomicBool,
476
+ operation: Option<&'a OperationCtx>,
477
+ limit: Option<TraversalLimit>,
478
+ ) -> Self {
479
+ Self {
480
+ shared,
481
+ stop,
482
+ cancelled,
483
+ operation,
484
+ limit,
485
+ items: Vec::with_capacity(TRAVERSAL_BATCH_ITEMS),
486
+ skipped: SkippedPaths::default(),
487
+ entries_seen: 0,
488
+ }
489
+ }
490
+
491
+ fn push(&mut self, item: T) {
492
+ self.items.push(item);
493
+ if self.items.len() == TRAVERSAL_BATCH_ITEMS {
494
+ self.flush();
495
+ }
496
+ }
497
+
498
+ fn record_failure(&mut self, failure: TraversalFailure) {
499
+ self.skipped.record(failure);
500
+ }
501
+
502
+ fn flush(&mut self) {
503
+ if self.items.is_empty() && self.skipped.is_empty() && self.entries_seen == 0 {
504
+ return;
505
+ }
506
+ stage_traversal_batch_flush(self.operation);
507
+ if operation_cancelled(self.operation) {
508
+ self.cancelled.store(true, Ordering::Release);
509
+ self.stop.store(true, Ordering::Release);
510
+ self.items.clear();
511
+ self.skipped = SkippedPaths::default();
512
+ self.entries_seen = 0;
513
+ return;
514
+ }
515
+
516
+ let mut shared = self.shared.lock();
517
+ shared.skipped.merge(std::mem::take(&mut self.skipped));
518
+ shared.entries_seen = shared
519
+ .entries_seen
520
+ .saturating_add(std::mem::take(&mut self.entries_seen));
521
+ for item in self.items.drain(..) {
522
+ if self
523
+ .limit
524
+ .is_some_and(|limit| shared.items.len() >= limit.maximum)
525
+ {
526
+ shared.too_many = true;
527
+ self.stop.store(true, Ordering::Release);
528
+ break;
529
+ }
530
+ shared.items.push(item);
531
+ }
532
+ drop(shared);
533
+ stage_traversal_batch_flush(self.operation);
534
+ if operation_cancelled(self.operation) {
535
+ self.cancelled.store(true, Ordering::Release);
536
+ self.stop.store(true, Ordering::Release);
537
+ }
538
+ }
539
+ }
540
+
541
+ impl<T> Drop for ParallelLocalBatch<'_, T> {
542
+ fn drop(&mut self) {
543
+ self.flush();
544
+ }
545
+ }
546
+
547
+ fn process_parallel_entry<'a, T, F>(
548
+ entry: Result<DirEntry, ignore::Error>,
549
+ root: &Path,
550
+ operation: Option<&OperationCtx>,
551
+ evaluate: &F,
552
+ local: &mut ParallelLocalBatch<'a, T>,
553
+ ) -> WalkState
554
+ where
555
+ F: Fn(&DirEntry) -> Result<Option<T>, TraversalFailure>,
556
+ {
557
+ if local.stop.load(Ordering::Acquire) {
558
+ return WalkState::Quit;
559
+ }
560
+ stage_traversal_entry(operation);
561
+ if operation_cancelled(operation) {
562
+ local.cancelled.store(true, Ordering::Release);
563
+ local.stop.store(true, Ordering::Release);
564
+ return WalkState::Quit;
565
+ }
566
+ let entry = match entry {
567
+ Ok(entry) => entry,
568
+ Err(error) => {
569
+ for failure in traversal_errors_from_ignore(&error, root) {
570
+ local.record_failure(failure);
571
+ }
572
+ return WalkState::Continue;
573
+ }
574
+ };
575
+ local.entries_seen = local.entries_seen.saturating_add(1);
576
+ let evaluated = catch_unwind(AssertUnwindSafe(|| evaluate(&entry)));
577
+ match evaluated {
578
+ Ok(Ok(Some(item))) => local.push(item),
579
+ Ok(Ok(None)) => {}
580
+ Ok(Err(failure)) => local.record_failure(failure),
581
+ Err(_) => local.record_failure(evaluation_panic(entry.path())),
582
+ }
583
+ if local.stop.load(Ordering::Acquire) {
584
+ WalkState::Quit
585
+ } else {
586
+ WalkState::Continue
587
+ }
588
+ }
589
+
590
+ fn finish_parallel_collection<T>(
591
+ state: ParallelCollectionState<T>,
592
+ limit: Option<TraversalLimit>,
593
+ ) -> Result<TraversalCollection<T>, String> {
594
+ if state.too_many {
595
+ return match limit {
596
+ Some(limit) => Err(limit.message.to_string()),
597
+ None => Err("Internal traversal limit state was inconsistent.".to_string()),
598
+ };
599
+ }
600
+ finish_collection(state.items, state.skipped, state.entries_seen)
601
+ }
602
+
603
+ fn compare_search_candidates(left: &PathRecord, right: &PathRecord) -> std::cmp::Ordering {
604
+ right
605
+ .modified
606
+ .cmp(&left.modified)
607
+ .then_with(|| left.display.as_bytes().cmp(right.display.as_bytes()))
608
+ .then_with(|| left.native_key.cmp(&right.native_key))
609
+ }
610
+
611
+ fn matches_record(
612
+ candidate: &PathRecord,
613
+ glob: Option<&PathGlobFilter>,
614
+ types: Option<&ignore::types::Types>,
615
+ ) -> bool {
616
+ if let Some(types) = types
617
+ && !types.matched(&candidate.native, false).is_whitelist()
618
+ {
619
+ return false;
620
+ }
621
+ glob.is_none_or(|glob| glob.is_match(candidate.relative_match.as_ref()))
622
+ }
623
+
624
+ fn candidate_from_path(
625
+ path: &Path,
626
+ match_root: &Path,
627
+ ) -> Result<Option<PathRecord>, TraversalFailure> {
628
+ let metadata = match fs::metadata(path) {
629
+ Ok(metadata) if metadata.is_file() => metadata,
630
+ Ok(_) => return Ok(None),
631
+ Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
632
+ Err(error) => return Err(TraversalFailure::from_io(path, &error)),
633
+ };
634
+ candidate_from_metadata(path, match_root, &metadata).map(Some)
635
+ }
636
+
637
+ /// Symlinks follow their target for the regular-file check and ordering metadata.
638
+ fn candidate_from_entry(
639
+ entry: &ignore::DirEntry,
640
+ match_root: &Path,
641
+ ) -> Result<Option<PathRecord>, TraversalFailure> {
642
+ if entry
643
+ .file_type()
644
+ .is_some_and(|file_type| file_type.is_symlink())
645
+ {
646
+ return candidate_from_path(entry.path(), match_root);
647
+ }
648
+ match entry.metadata() {
649
+ Ok(metadata) if metadata.is_file() => {
650
+ candidate_from_metadata(entry.path(), match_root, &metadata).map(Some)
651
+ }
652
+ Ok(_) => Ok(None),
653
+ Err(_) => candidate_from_path(entry.path(), match_root),
654
+ }
655
+ }
656
+
657
+ fn candidate_from_metadata(
658
+ path: &Path,
659
+ match_root: &Path,
660
+ metadata: &fs::Metadata,
661
+ ) -> Result<PathRecord, TraversalFailure> {
662
+ PathRecord::from_metadata(path, match_root, metadata, true)
663
+ .map_err(|error| TraversalFailure::from_io(path, &error))
664
+ }
665
+
666
+ fn operation_cancelled(operation: Option<&OperationCtx>) -> bool {
667
+ operation.is_some_and(|operation| operation.check().is_err())
668
+ }
669
+
670
+ fn stage_traversal_entry(operation: Option<&OperationCtx>) {
671
+ #[cfg(test)]
672
+ if let Some(operation) = operation {
673
+ operation.stage(TestStage::TraversalEntry);
674
+ }
675
+ #[cfg(not(test))]
676
+ let _ = operation;
677
+ }
678
+
679
+ fn stage_traversal_batch_flush(operation: Option<&OperationCtx>) {
680
+ #[cfg(test)]
681
+ if let Some(operation) = operation {
682
+ operation.stage(TestStage::TraversalBatchFlush);
683
+ }
684
+ #[cfg(not(test))]
685
+ let _ = operation;
686
+ }
687
+
688
+ pub(crate) fn traversal_errors_from_ignore(
689
+ error: &ignore::Error,
690
+ root: &Path,
691
+ ) -> Vec<TraversalFailure> {
692
+ let mut failures = Vec::new();
693
+ collect_ignore_error(error, None, root, &mut failures);
694
+ failures
695
+ }
696
+
697
+ fn collect_ignore_error(
698
+ error: &ignore::Error,
699
+ inherited_path: Option<&Path>,
700
+ root: &Path,
701
+ failures: &mut Vec<TraversalFailure>,
702
+ ) {
703
+ match error {
704
+ ignore::Error::Partial(errors) => {
705
+ for error in errors {
706
+ collect_ignore_error(error, inherited_path, root, failures);
707
+ }
708
+ }
709
+ ignore::Error::WithLineNumber { err, .. } | ignore::Error::WithDepth { err, .. } => {
710
+ collect_ignore_error(err, inherited_path, root, failures);
711
+ }
712
+ ignore::Error::WithPath { path, err } => {
713
+ collect_ignore_error(err, Some(path), root, failures);
714
+ }
715
+ ignore::Error::Loop { child, .. } => failures.push(TraversalFailure::from_other(
716
+ child,
717
+ format!("Cannot traverse path: {error}"),
718
+ error.to_string(),
719
+ )),
720
+ ignore::Error::Io(error) => failures.push(TraversalFailure::from_io(
721
+ inherited_path.unwrap_or(root),
722
+ error,
723
+ )),
724
+ ignore::Error::Glob { .. }
725
+ | ignore::Error::UnrecognizedFileType(_)
726
+ | ignore::Error::InvalidDefinition => failures.push(TraversalFailure::from_other(
727
+ inherited_path.unwrap_or(root),
728
+ format!("Cannot traverse path: {error}"),
729
+ error.to_string(),
730
+ )),
731
+ }
732
+ }
733
+
734
+ fn normalize_error_message(message: &str) -> String {
735
+ message.replace("\r\n", "\n").replace('\r', "\n")
736
+ }
737
+
738
+ fn io_kind_rank(kind: io::ErrorKind) -> u8 {
739
+ match kind {
740
+ io::ErrorKind::NotFound => 0,
741
+ io::ErrorKind::PermissionDenied => 1,
742
+ io::ErrorKind::WouldBlock => 2,
743
+ io::ErrorKind::TimedOut => 3,
744
+ io::ErrorKind::Interrupted => 4,
745
+ io::ErrorKind::InvalidInput | io::ErrorKind::InvalidData => 5,
746
+ io::ErrorKind::UnexpectedEof => 6,
747
+ _ => 254,
748
+ }
749
+ }
750
+
751
+ #[cfg(test)]
752
+ mod tests {
753
+ use super::*;
754
+ use std::path::PathBuf;
755
+
756
+ fn failure(path: &str) -> TraversalFailure {
757
+ TraversalFailure::from_other(
758
+ Path::new(path),
759
+ format!("Cannot traverse path: {path}"),
760
+ format!("reason for {path}"),
761
+ )
762
+ }
763
+
764
+ fn listing(skipped: &SkippedPaths) -> Vec<(String, String)> {
765
+ skipped
766
+ .listed()
767
+ .map(|path| (path.display.to_string(), path.reason.to_string()))
768
+ .collect()
769
+ }
770
+
771
+ /// The defect this whole module exists to prevent: one unreadable corner of a
772
+ /// tree used to discard every result collected around it.
773
+ #[test]
774
+ fn a_failure_inside_the_tree_keeps_the_items_around_it() {
775
+ let dir = tempfile::TempDir::new().expect("temp dir");
776
+ for name in ["a.txt", "b.txt", "c.txt"] {
777
+ fs::write(dir.path().join(name), "x").expect("seed");
778
+ }
779
+ let collected = collect_walk_serial(
780
+ WalkBuilder::new(dir.path()),
781
+ dir.path(),
782
+ None,
783
+ None,
784
+ &|entry: &DirEntry| -> Result<Option<PathBuf>, TraversalFailure> {
785
+ if !entry.file_type().is_some_and(|kind| kind.is_file()) {
786
+ return Ok(None);
787
+ }
788
+ if entry.file_name() == "b.txt" {
789
+ return Err(failure("b.txt"));
790
+ }
791
+ Ok(Some(entry.path().to_path_buf()))
792
+ },
793
+ )
794
+ .expect("a failure inside the tree must not fail the walk");
795
+ assert_eq!(collected.items.len(), 2);
796
+ assert_eq!(collected.skipped.total(), 1);
797
+ }
798
+
799
+ /// A walk with nothing to report on cannot claim it searched: an unreadable
800
+ /// root must stay an error rather than become "found nothing".
801
+ #[test]
802
+ fn a_walk_that_never_reached_an_entry_still_fails_whole() {
803
+ let dir = tempfile::TempDir::new().expect("temp dir");
804
+ let missing = dir.path().join("missing");
805
+ let error = collect_walk_serial(
806
+ WalkBuilder::new(&missing),
807
+ &missing,
808
+ None,
809
+ None,
810
+ &|_: &DirEntry| -> Result<Option<PathBuf>, TraversalFailure> { Ok(None) },
811
+ )
812
+ .map(|collected| collected.items)
813
+ .expect_err("an unreachable root has no partial result to offer");
814
+ assert!(!error.is_empty());
815
+ }
816
+
817
+ /// Parallel walkers merge thread-local batches in scheduling order, so the
818
+ /// report has to be a function of the failures alone.
819
+ #[test]
820
+ fn skip_reports_stay_identical_regardless_of_arrival_order() {
821
+ let record = |paths: &[&str]| {
822
+ let mut skipped = SkippedPaths::default();
823
+ for path in paths {
824
+ skipped.record(failure(path));
825
+ }
826
+ skipped
827
+ };
828
+ let forward = record(&["/a", "/b", "/c"]);
829
+ let mut merged = record(&["/c"]);
830
+ merged.merge(record(&["/b", "/a"]));
831
+ assert_eq!(listing(&forward), listing(&merged));
832
+ assert_eq!(forward.total(), merged.total());
833
+
834
+ let mut duplicated = record(&["/a", "/a", "/a"]);
835
+ duplicated.merge(record(&["/a"]));
836
+ assert_eq!(duplicated.total(), 1);
837
+
838
+ let flood = (0..SKIPPED_DETAIL_CAP + 40)
839
+ .map(|index| format!("/flood/{index:05}"))
840
+ .collect::<Vec<_>>();
841
+ let overflowed = record(&flood.iter().map(String::as_str).collect::<Vec<_>>());
842
+ assert_eq!(overflowed.total(), flood.len());
843
+ assert_eq!(overflowed.listed().len(), SKIPPED_DETAIL_CAP);
844
+ assert_eq!(overflowed.unlisted(), 40);
845
+ }
846
+ }