返回 CodeWhale
search.rs
根目录 / crates / tui / src / tools / search.rs
1 //! Search tools: `grep_files` for code search
2 //!
3 //! These tools provide powerful code search capabilities within the workspace,
4 //! similar to ripgrep/grep functionality.
5
6 use super::file::{
7 PATH_ALIASES, SEARCH_CONTENT_ALIASES, SEARCH_CONTENT_PARAMS, apply_param_aliases,
8 };
9 use super::spec::{
10 ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_bool, optional_str,
11 optional_u64, required_str,
12 };
13 use async_trait::async_trait;
14 use regex::Regex;
15 use serde::{Deserialize, Serialize};
16 use serde_json::{Value, json};
17 use std::collections::{HashSet, VecDeque};
18 use std::fs;
19 use std::io::BufRead;
20 use std::path::{Path, PathBuf};
21 use std::time::Duration;
22 use tokio_util::sync::CancellationToken;
23
24 /// Maximum number of results to return to avoid overwhelming output
25 const MAX_RESULTS: usize = 100;
26
27 /// Maximum file size to search (skip large binaries)
28 const MAX_FILE_SIZE: u64 = 10 * 1024 * 1024; // 10MB
29
30 /// Hard cap on a single grep_files run. The directory walk plus per-file regex
31 /// is synchronous blocking work; without this it can run for minutes on a large
32 /// tree. Mirrors the file_search tool so both blocking searches behave the same.
33 const GREP_FILES_TIMEOUT: Duration = Duration::from_secs(30);
34
35 /// Result of a grep match
36 #[derive(Debug, Clone, Serialize, Deserialize)]
37 pub struct GrepMatch {
38 pub file: String,
39 pub line_number: usize,
40 pub line: String,
41 pub context_before: Vec<String>,
42 pub context_after: Vec<String>,
43 }
44
45 /// Tool for searching files using regex patterns
46 pub struct GrepFilesTool;
47
48 #[async_trait]
49 impl ToolSpec for GrepFilesTool {
50 fn name(&self) -> &'static str {
51 "grep_files"
52 }
53
54 fn model_visible(&self) -> bool {
55 true
56 }
57
58 fn description(&self) -> &'static str {
59 "Search for a regex pattern in workspace files. The pure-Rust search skips common non-code directories by default and returns matching lines with context."
60 }
61
62 fn input_schema(&self) -> Value {
63 json!({
64 "type": "object",
65 "properties": {
66 "pattern": {
67 "type": "string",
68 "description": "Regular expression pattern to search for"
69 },
70 "path": {
71 "type": "string",
72 "description": "Directory or file to search (relative to workspace, default: .)"
73 },
74 "include": {
75 "type": "array",
76 "items": {"type": "string"},
77 "description": "Glob patterns for files to include (e.g., ['*.rs', '*.ts'])"
78 },
79 "exclude": {
80 "type": "array",
81 "items": {"type": "string"},
82 "description": "Glob patterns for files to exclude (e.g., ['*.min.js', 'node_modules/*'])"
83 },
84 "context_lines": {
85 "type": "integer",
86 "description": "Number of context lines before and after each match (default: 2)"
87 },
88 "case_insensitive": {
89 "type": "boolean",
90 "description": "Whether to perform case-insensitive matching (default: false)"
91 },
92 "max_results": {
93 "type": "integer",
94 "description": "Maximum number of results to return (default: 100)"
95 }
96 },
97 "required": ["pattern"]
98 })
99 }
100
101 fn capabilities(&self) -> Vec<ToolCapability> {
102 vec![ToolCapability::ReadOnly, ToolCapability::Sandboxable]
103 }
104
105 fn supports_parallel(&self) -> bool {
106 true
107 }
108
109 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
110 let mut input = input;
111 apply_param_aliases(&mut input, PATH_ALIASES, "File search_content")?;
112 apply_param_aliases(&mut input, SEARCH_CONTENT_ALIASES, "File search_content")?;
113 SEARCH_CONTENT_PARAMS.reject_unknown(&input)?;
114
115 let pattern_str = required_str(&input, "pattern")?;
116 let path_str = optional_str(&input, "path")?.unwrap_or(".");
117 let context_lines = usize::try_from(optional_u64(&input, "context_lines", 2)?)
118 .unwrap_or(usize::MAX)
119 .min(1000);
120 let case_insensitive = optional_bool(&input, "case_insensitive", false)?;
121 let max_results = usize::try_from(optional_u64(&input, "max_results", MAX_RESULTS as u64)?)
122 .unwrap_or(MAX_RESULTS);
123
124 // Parse include patterns
125 let include_patterns: Vec<String> = input
126 .get("include")
127 .and_then(|v| v.as_array())
128 .map(|arr| {
129 arr.iter()
130 .filter_map(|v| v.as_str().map(String::from))
131 .collect()
132 })
133 .unwrap_or_default();
134
135 // Parse exclude patterns
136 let exclude_patterns: Vec<String> =
137 input.get("exclude").and_then(|v| v.as_array()).map_or_else(
138 || {
139 // Default exclusions for common non-code directories.
140 // Bare directory names skip the directory traversal entirely;
141 // `dir/*` filters files inside if the directory is already
142 // being walked (belt-and-suspenders — see #2200).
143 vec![
144 "node_modules".to_string(),
145 "node_modules/*".to_string(),
146 ".git".to_string(),
147 ".git/*".to_string(),
148 "target".to_string(),
149 "target/*".to_string(),
150 "*.min.js".to_string(),
151 "*.min.css".to_string(),
152 "dist".to_string(),
153 "dist/*".to_string(),
154 "build".to_string(),
155 "build/*".to_string(),
156 "__pycache__".to_string(),
157 "__pycache__/*".to_string(),
158 ".venv".to_string(),
159 ".venv/*".to_string(),
160 "venv".to_string(),
161 "venv/*".to_string(),
162 ]
163 },
164 |arr| {
165 arr.iter()
166 .filter_map(|v| v.as_str().map(String::from))
167 .collect()
168 },
169 );
170
171 // Build regex
172 let regex_pattern = if case_insensitive {
173 format!("(?i){pattern_str}")
174 } else {
175 pattern_str.to_string()
176 };
177
178 // Model-supplied: compile through the bounded cache (length cap,
179 // program/DFA size limits, invalid patterns remembered).
180 let regex = crate::regex_cache::compile_user_regex(&regex_pattern)
181 .map_err(|e| ToolError::invalid_input(format!("Invalid regex pattern: {e}")))?;
182
183 // Resolve search path
184 let search_path = context.resolve_path(path_str)?;
185
186 let workspace = context.workspace.clone();
187 let cancel_token = context.cancel_token.clone();
188 let follow_symlinks = context.follow_symlinks;
189
190 // The directory walk and per-file regex are synchronous blocking work.
191 // Run them on a blocking worker bounded by a hard timeout so a huge tree
192 // can't pin the async runtime and leave the stop button unresponsive.
193 let result = run_blocking_grep(GREP_FILES_TIMEOUT, cancel_token.clone(), move || {
194 let cancel_token = cancel_token.as_ref();
195
196 // Stream the walk: each file is searched as it is discovered and
197 // the traversal stops as soon as the match budget is exhausted.
198 // Files are never materialized in a big Vec and file contents are
199 // read line-by-line, so memory stays bounded by the result set.
200 let mut results: Vec<GrepMatch> = Vec::new();
201 let mut files_searched = 0;
202 let mut total_matches = 0;
203 // Set when the cap stopped the search before it covered the whole
204 // tree: a match was seen beyond `max_results` in the file that
205 // filled the budget, or another candidate file was left unread.
206 // The walk never scans past the file that fills the budget.
207 let mut truncated = false;
208 let mut unreadable_files = 0usize;
209
210 let skipped_dirs = visit_files(
211 &search_path,
212 &include_patterns,
213 &exclude_patterns,
214 cancel_token,
215 follow_symlinks,
216 &mut |file_path| {
217 if truncated || results.len() >= max_results {
218 truncated = true;
219 return Ok(WalkControl::Stop);
220 }
221 check_cancelled(cancel_token)?;
222
223 // Skip files that are too large
224 if let Ok(metadata) = fs::metadata(file_path)
225 && metadata.len() > MAX_FILE_SIZE
226 {
227 return Ok(WalkControl::Continue);
228 }
229
230 // Get relative path from workspace
231 let relative_path = file_path
232 .strip_prefix(&workspace)
233 .unwrap_or(file_path)
234 .to_string_lossy()
235 .to_string();
236
237 let budget = max_results.saturating_sub(results.len());
238 let (file_matches, overflowed) = match search_file_streaming(
239 file_path,
240 &relative_path,
241 &regex,
242 context_lines,
243 budget,
244 cancel_token,
245 )? {
246 FileScan::Scanned {
247 matches,
248 overflowed,
249 } => (matches, overflowed),
250 FileScan::NotText => return Ok(WalkControl::Continue),
251 FileScan::Unreadable => {
252 unreadable_files += 1;
253 return Ok(WalkControl::Continue);
254 }
255 };
256
257 files_searched += 1;
258 total_matches += file_matches.len();
259 results.extend(file_matches);
260 if overflowed {
261 truncated = true;
262 return Ok(WalkControl::Stop);
263 }
264 Ok(WalkControl::Continue)
265 },
266 )?;
267
268 let matches_json: Vec<Value> = results
269 .iter()
270 .map(|item| grep_match_to_json(item, context_lines))
271 .collect();
272
273 // Build result. When context_lines == 1, return the single context
274 // line as a string instead of a one-item array. That keeps the common
275 // "show just the adjacent line" case easy for model callers to read.
276 let mut output = json!({
277 "matches": matches_json,
278 "total_matches": total_matches,
279 "files_searched": files_searched,
280 "truncated": truncated,
281 });
282 if truncated {
283 output["max_results"] = json!(max_results);
284 }
285 // Anything the walk could not read (a directory, a directory
286 // entry, or a file) is counted so the caller knows coverage was
287 // incomplete rather than reading silence as "no matches there".
288 let unreadable = skipped_dirs + unreadable_files;
289 if unreadable > 0 {
290 output["unreadable_paths_skipped"] = json!(unreadable);
291 }
292 Ok(output)
293 })
294 .await?;
295
296 ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
297 }
298 }
299
300 /// Run the synchronous grep walk on a blocking worker, cancellable via the
301 /// token and bounded by `timeout`. Mirrors `run_blocking_file_search`.
302 async fn run_blocking_grep<F>(
303 timeout: Duration,
304 cancel_token: Option<CancellationToken>,
305 search: F,
306 ) -> Result<Value, ToolError>
307 where
308 F: FnOnce() -> Result<Value, ToolError> + Send + 'static,
309 {
310 if cancel_token
311 .as_ref()
312 .is_some_and(CancellationToken::is_cancelled)
313 {
314 return Err(grep_cancelled());
315 }
316
317 let task = tokio::task::spawn_blocking(search);
318 let result = match cancel_token {
319 Some(token) => {
320 tokio::select! {
321 biased;
322 () = token.cancelled() => return Err(grep_cancelled()),
323 result = tokio::time::timeout(timeout, task) => result,
324 }
325 }
326 None => tokio::time::timeout(timeout, task).await,
327 };
328
329 let joined = result.map_err(|_| grep_timeout(timeout))?;
330 joined.map_err(|err| {
331 ToolError::execution_failed(format!("grep_files worker failed before completion: {err}"))
332 })?
333 }
334
335 fn grep_cancelled() -> ToolError {
336 ToolError::cancelled("grep_files cancelled before completion")
337 }
338
339 fn grep_timeout(timeout: Duration) -> ToolError {
340 ToolError::Timeout {
341 seconds: timeout.as_secs().max(1),
342 }
343 }
344
345 fn grep_match_to_json(item: &GrepMatch, context_lines: usize) -> Value {
346 if context_lines == 1 {
347 json!({
348 "file": item.file,
349 "line_number": item.line_number,
350 "line": item.line,
351 "context_before": item.context_before.first().cloned().unwrap_or_default(),
352 "context_after": item.context_after.first().cloned().unwrap_or_default(),
353 })
354 } else {
355 json!(item)
356 }
357 }
358
359 /// Search a single file line-by-line with a small ring buffer for
360 /// before-context, so file contents are never fully materialized.
361 ///
362 /// Returns [`FileScan::NotText`] when the file contains invalid UTF-8
363 /// anywhere (the whole file must be valid before contributing any match) and
364 /// [`FileScan::Unreadable`] when it cannot be opened or read. At most `budget` matches are
365 /// recorded; the scan still runs to EOF so late invalid bytes disqualify the
366 /// file and pending after-context is completed. `overflowed` is true when
367 /// the file held at least one match beyond `budget`.
368 fn search_file_streaming(
369 path: &Path,
370 relative_path: &str,
371 regex: &Regex,
372 context_lines: usize,
373 budget: usize,
374 cancel_token: Option<&CancellationToken>,
375 ) -> Result<FileScan, ToolError> {
376 let Ok(file) = fs::File::open(path) else {
377 return Ok(FileScan::Unreadable);
378 };
379 let mut reader = std::io::BufReader::new(file);
380 let mut raw: Vec<u8> = Vec::new();
381 let mut before: VecDeque<String> = VecDeque::new();
382 let mut matches: Vec<GrepMatch> = Vec::new();
383 // Matches still waiting for after-context lines: (index into `matches`,
384 // lines still needed). Entries complete in FIFO order.
385 let mut pending: VecDeque<(usize, usize)> = VecDeque::new();
386 let mut line_idx = 0usize;
387 let mut overflowed = false;
388
389 loop {
390 raw.clear();
391 let n = match reader.read_until(b'\n', &mut raw) {
392 Ok(n) => n,
393 Err(_) => return Ok(FileScan::Unreadable),
394 };
395 if n == 0 {
396 break;
397 }
398 check_cancelled(cancel_token)?;
399
400 // Mirror `str::lines`: strip the trailing '\n', and a '\r' only when
401 // it directly precedes that '\n'.
402 let mut end = raw.len();
403 if raw[..end].ends_with(b"\n") {
404 end -= 1;
405 if raw[..end].ends_with(b"\r") {
406 end -= 1;
407 }
408 }
409 let Ok(line) = std::str::from_utf8(&raw[..end]) else {
410 return Ok(FileScan::NotText);
411 };
412
413 for (idx, remaining) in &mut pending {
414 matches[*idx].context_after.push(line.to_string());
415 *remaining -= 1;
416 }
417 while pending
418 .front()
419 .is_some_and(|(_, remaining)| *remaining == 0)
420 {
421 pending.pop_front();
422 }
423
424 if matches.len() >= budget {
425 overflowed = overflowed || regex.is_match(line);
426 } else if regex.is_match(line) {
427 matches.push(GrepMatch {
428 file: relative_path.to_string(),
429 line_number: line_idx + 1,
430 line: line.to_string(),
431 context_before: before.iter().cloned().collect(),
432 context_after: Vec::new(),
433 });
434 if context_lines > 0 {
435 pending.push_back((matches.len() - 1, context_lines));
436 }
437 }
438
439 if context_lines > 0 {
440 if before.len() == context_lines {
441 before.pop_front();
442 }
443 before.push_back(line.to_string());
444 }
445 line_idx += 1;
446 }
447
448 Ok(FileScan::Scanned {
449 matches,
450 overflowed,
451 })
452 }
453
454 /// Outcome of scanning one file.
455 enum FileScan {
456 Scanned {
457 matches: Vec<GrepMatch>,
458 overflowed: bool,
459 },
460 /// Binary or not valid UTF-8: skipped on purpose.
461 NotText,
462 /// Could not be opened or read: skipped, and counted as incomplete coverage.
463 Unreadable,
464 }
465
466 /// Flow control for the streaming file walk.
467 enum WalkControl {
468 Continue,
469 Stop,
470 }
471
472 /// Walk files matching the include/exclude patterns, invoking `visit` for
473 /// each one in traversal order. The walk stops early when `visit` returns
474 /// [`WalkControl::Stop`]. Returns how many subdirectories (or entries) could
475 /// not be read and were skipped; only an unreadable `root` fails the walk.
476 fn visit_files(
477 root: &Path,
478 include_patterns: &[String],
479 exclude_patterns: &[String],
480 cancel_token: Option<&CancellationToken>,
481 follow_symlinks: bool,
482 visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
483 ) -> Result<usize, ToolError> {
484 let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
485 let mut skipped = 0usize;
486 check_cancelled(cancel_token)?;
487
488 if root.is_file() {
489 visit(root)?;
490 return Ok(0);
491 }
492
493 if follow_symlinks && let Ok(canonical_root) = root.canonicalize() {
494 visited_dirs.insert(canonical_root);
495 }
496
497 visit_files_recursive(
498 root,
499 root,
500 include_patterns,
501 exclude_patterns,
502 cancel_token,
503 &mut visited_dirs,
504 follow_symlinks,
505 &mut skipped,
506 visit,
507 )?;
508 Ok(skipped)
509 }
510
511 #[allow(clippy::too_many_arguments)]
512 fn visit_files_recursive(
513 root: &Path,
514 current: &Path,
515 include_patterns: &[String],
516 exclude_patterns: &[String],
517 cancel_token: Option<&CancellationToken>,
518 visited_dirs: &mut HashSet<PathBuf>,
519 follow_symlinks: bool,
520 skipped: &mut usize,
521 visit: &mut dyn FnMut(&Path) -> Result<WalkControl, ToolError>,
522 ) -> Result<WalkControl, ToolError> {
523 check_cancelled(cancel_token)?;
524
525 // An unreadable subdirectory (permissions, or removed mid-walk) is
526 // skipped and counted rather than discarding every match found so far.
527 // The search root itself still fails loudly.
528 let entries = match fs::read_dir(current) {
529 Ok(entries) => entries,
530 Err(e) if current != root => {
531 tracing::debug!(dir = %current.display(), error = %e, "grep_files skipped an unreadable directory");
532 *skipped += 1;
533 return Ok(WalkControl::Continue);
534 }
535 Err(e) => {
536 return Err(ToolError::execution_failed(format!(
537 "Failed to read directory {}: {}",
538 current.display(),
539 e
540 )));
541 }
542 };
543
544 for entry in entries {
545 check_cancelled(cancel_token)?;
546
547 let Ok(entry) = entry else {
548 *skipped += 1;
549 continue;
550 };
551 let path = entry.path();
552 let Ok(file_type) = entry.file_type() else {
553 *skipped += 1;
554 continue;
555 };
556 if file_type.is_symlink() && !follow_symlinks {
557 continue;
558 }
559
560 // Get relative path for pattern matching. Globs use `/`, so a native
561 // `\` separator is normalized as file_search does; otherwise
562 // `src/**` could never match `src\a.rs` on Windows.
563 let relative = path.strip_prefix(root).unwrap_or(&path);
564 let relative_str = relative.to_string_lossy();
565 let relative_str = if std::path::MAIN_SEPARATOR == '/' {
566 relative_str
567 } else {
568 std::borrow::Cow::Owned(relative_str.replace(std::path::MAIN_SEPARATOR, "/"))
569 };
570
571 // Check exclusions
572 if should_exclude(&relative_str, exclude_patterns) {
573 continue;
574 }
575
576 // When following symlinks, resolve the target type for directories
577 // and files so symlinked dirs are traversed and symlinked files are
578 // included.
579 let effective_type = if file_type.is_symlink() && follow_symlinks {
580 match fs::metadata(&path) {
581 Ok(meta) => meta.file_type(),
582 Err(_) => continue,
583 }
584 } else {
585 file_type
586 };
587
588 if effective_type.is_dir() {
589 if follow_symlinks {
590 let canonical_dir = match path.canonicalize() {
591 Ok(canonical) => canonical,
592 Err(_) => continue,
593 };
594 if !visited_dirs.insert(canonical_dir) {
595 continue;
596 }
597 }
598 if let WalkControl::Stop = visit_files_recursive(
599 root,
600 &path,
601 include_patterns,
602 exclude_patterns,
603 cancel_token,
604 visited_dirs,
605 follow_symlinks,
606 skipped,
607 visit,
608 )? {
609 return Ok(WalkControl::Stop);
610 }
611 } else if effective_type.is_file() {
612 // Sandbox read deny-list (S1). A recursive search is not a directed
613 // read request, so a denied file is skipped rather than failing the
614 // whole search — otherwise one `.env` anywhere in the tree would
615 // make `search` useless. The skip is logged; a directed
616 // `read_file`/`read`/`read_media` on the same path still returns an
617 // explicit refusal rather than an empty result.
618 if let Err(denial) = crate::sandbox::read_guard::active().check(&path) {
619 tracing::debug!(
620 target: "codewhale::sandbox::read_guard",
621 requested = %denial.requested.display(),
622 "sandbox read deny-list skipped a file during search"
623 );
624 continue;
625 }
626 // Check inclusions (if any specified)
627 if (include_patterns.is_empty() || should_include(&relative_str, include_patterns))
628 && let WalkControl::Stop = visit(&path)?
629 {
630 return Ok(WalkControl::Stop);
631 }
632 }
633 }
634
635 Ok(WalkControl::Continue)
636 }
637
638 fn check_cancelled(cancel_token: Option<&CancellationToken>) -> Result<(), ToolError> {
639 if cancel_token.is_some_and(CancellationToken::is_cancelled) {
640 return Err(ToolError::cancelled("search cancelled before completion"));
641 }
642 Ok(())
643 }
644
645 /// Check if a path matches any of the exclude patterns
646 fn should_exclude(path: &str, patterns: &[String]) -> bool {
647 for pattern in patterns {
648 if matches_glob(path, pattern) {
649 return true;
650 }
651 }
652 false
653 }
654
655 /// Check if a path matches any of the include patterns
656 fn should_include(path: &str, patterns: &[String]) -> bool {
657 for pattern in patterns {
658 if matches_glob(path, pattern) {
659 return true;
660 }
661 }
662 false
663 }
664
665 /// Simple glob pattern matching
666 /// Supports: * (any chars), ** (any path), ? (single char)
667 pub(crate) fn matches_glob(path: &str, pattern: &str) -> bool {
668 // Handle ** for any path
669 if pattern.contains("**") {
670 let parts: Vec<&str> = pattern.split("**").collect();
671 if parts.len() == 2 {
672 let prefix = parts[0].trim_end_matches('/');
673 let suffix = parts[1].trim_start_matches('/');
674
675 // A prefix ending in `/` names a directory: `build/**` covers
676 // `build` and `build/...`, never `build.rs` or `builders/`. A
677 // prefix without one (`src/test_**`) is a plain string prefix.
678 let under_prefix = if parts[0].ends_with('/') {
679 path == prefix
680 || path
681 .strip_prefix(prefix)
682 .is_some_and(|rest| rest.starts_with('/'))
683 } else {
684 path.starts_with(prefix)
685 };
686 if !prefix.is_empty() && !under_prefix {
687 return false;
688 }
689 if !suffix.is_empty() {
690 return path.ends_with(suffix)
691 || path
692 .split('/')
693 .any(|part| matches_simple_glob(part, suffix));
694 }
695 return true;
696 }
697 }
698
699 // Handle patterns like "*.rs" - match against filename only
700 if pattern.starts_with('*') && !pattern.contains('/') {
701 let filename = path.rsplit('/').next().unwrap_or(path);
702 return matches_simple_glob(filename, pattern);
703 }
704
705 // Handle patterns with path components
706 if pattern.contains('/') {
707 return matches_simple_glob(path, pattern);
708 }
709
710 // Match against filename
711 let filename = path.rsplit('/').next().unwrap_or(path);
712 matches_simple_glob(filename, pattern)
713 }
714
715 /// Simple glob matching for single path component
716 fn matches_simple_glob(text: &str, pattern: &str) -> bool {
717 let mut text_chars = text.chars().peekable();
718 let mut pattern_chars = pattern.chars().peekable();
719
720 while let Some(p) = pattern_chars.next() {
721 match p {
722 '*' => {
723 // Match zero or more characters
724 let next_pattern: String = pattern_chars.collect();
725 if next_pattern.is_empty() {
726 return true;
727 }
728
729 // Try matching at each position (use char-indices to stay on
730 // UTF-8 boundaries — byte-index slicing panics on multi-byte
731 // characters like 冰糖, see #249).
732 let remaining: String = text_chars.collect();
733 for (i, _) in remaining.char_indices() {
734 if matches_simple_glob(&remaining[i..], &next_pattern) {
735 return true;
736 }
737 }
738 // Also try the empty suffix at end of string
739 if matches_simple_glob("", &next_pattern) {
740 return true;
741 }
742 return false;
743 }
744 '?' => {
745 // Match exactly one character
746 if text_chars.next().is_none() {
747 return false;
748 }
749 }
750 c => {
751 // Match literal character
752 if text_chars.next() != Some(c) {
753 return false;
754 }
755 }
756 }
757 }
758
759 text_chars.next().is_none()
760 }
761
762 // === Unit Tests ===
763
764 #[cfg(test)]
765 mod tests;
766
766 lines RUST