1use std::collections::{HashMap, VecDeque};
2use std::io::Read as _;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5use std::time::UNIX_EPOCH;
6
7use serde::Serialize;
8
9use crate::commands::read::{handle_github_outline, is_github_read_target};
10use crate::context::AppContext;
11use crate::edit;
12use crate::error::AftError;
13use crate::inspect::job::is_test_file;
14use crate::parser::{detect_language, LangId};
15use crate::protocol::{RawRequest, Response};
16use crate::symbols::{Range, Symbol};
17use crate::url_fetch::{fetch_url_to_cache, is_http_url, UrlFetchOptions};
18
19const MAX_OUTLINE_FILE_BYTES: u64 = 50 * 1024 * 1024;
20const BINARY_SAMPLE_BYTES: usize = 4 * 1024;
21const OUTLINE_FILE_WALK_CAP: usize = 200;
22const OUTLINE_FILE_COLLECTION_CAP: usize = 10_000;
23
24#[derive(Debug, Clone, Serialize)]
29pub struct OutlineEntry {
30 pub name: String,
31 pub kind: String,
32 pub range: Range,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub signature: Option<String>,
35 pub exported: bool,
36 pub members: Vec<OutlineEntry>,
37}
38
39pub fn handle_outline(req: &RawRequest, ctx: &AppContext) -> Response {
50 const MAX_OUTPUT_BYTES: usize = 30 * 1024;
51
52 if req
53 .params
54 .get("files")
55 .and_then(|value| value.as_bool())
56 .unwrap_or(false)
57 {
58 return handle_outline_files_mode(req, ctx, MAX_OUTPUT_BYTES);
59 }
60
61 if let Some(directory) = req.params.get("directory").and_then(|v| v.as_str()) {
62 let dir_path = match ctx.validate_path(&req.id, Path::new(directory)) {
63 Ok(path) => path,
64 Err(resp) => return resp,
65 };
66 if !dir_path.is_dir() {
67 return Response::error(
68 &req.id,
69 "file_not_found",
70 format!("directory not found: {}", directory),
71 );
72 }
73
74 let discovery = discover_outline_files(&dir_path);
75 let project_root = ctx.config().project_root.clone();
76 let include_tests = include_tests_param(req);
77 let files = if include_tests {
78 discovery.files.clone()
79 } else {
80 discovery
81 .files
82 .iter()
83 .filter(|file| {
84 let path = Path::new(file);
85 let relative = project_root
86 .as_deref()
87 .and_then(|root| relative_path_from_root(path, root))
88 .unwrap_or_else(|| path_to_slash(path));
89 !is_test_file(&relative)
90 })
91 .cloned()
92 .collect::<Vec<_>>()
93 };
94 let (file_outlines, skipped_files) =
95 match outline_many_files(&files, ctx, &req.id, project_root.as_deref()) {
96 Ok(result) => result,
97 Err(resp) => return resp,
98 };
99
100 let text = format_multi_file_tree(&file_outlines, MAX_OUTPUT_BYTES, files.len());
101 return Response::success(
102 &req.id,
103 serde_json::json!({
104 "text": text,
105 "complete": !discovery.walk_truncated
106 && !discovery.collection_truncated
107 && discovery.skipped_foreign_mounts == 0,
108 "walk_truncated": discovery.walk_truncated,
109 "collection_truncated": discovery.collection_truncated,
110 "skipped_foreign_mounts": discovery.skipped_foreign_mounts,
111 "skipped_files": skipped_files,
112 }),
113 );
114 }
115
116 if let Some(files_arr) = req.params.get("files").and_then(|v| v.as_array()) {
118 let project_root = ctx.config().project_root.clone();
119 let files: Vec<String> = files_arr
120 .iter()
121 .filter_map(|file_val| file_val.as_str().map(String::from))
122 .collect();
123 let total_files_requested = files_arr.len();
124 let (file_outlines, skipped_files) =
125 match outline_many_files(&files, ctx, &req.id, project_root.as_deref()) {
126 Ok(result) => result,
127 Err(resp) => return resp,
128 };
129
130 let text = format_multi_file_tree(&file_outlines, MAX_OUTPUT_BYTES, total_files_requested);
131 return Response::success(
134 &req.id,
135 serde_json::json!({
136 "text": text,
137 "complete": skipped_files.is_empty(),
138 "skipped_files": skipped_files,
139 }),
140 );
141 }
142
143 let file = match req
145 .params
146 .get("file")
147 .or_else(|| req.params.get("target"))
148 .and_then(|v| v.as_str())
149 {
150 Some(f) => f,
151 None => {
152 return Response::error(
153 &req.id,
154 "invalid_request",
155 "outline: missing required param 'file', 'files', or 'directory'",
156 );
157 }
158 };
159
160 if is_github_read_target(file) {
161 return handle_github_outline(req, ctx, file);
162 }
163
164 let path = match resolve_file_or_url(req, ctx, file) {
165 Ok(path) => path,
166 Err(resp) => return resp,
167 };
168 if !path.exists() {
169 return Response::error(
170 &req.id,
171 "file_not_found",
172 format!("file not found: {}", file),
173 );
174 }
175
176 let symbols = match ctx.provider().list_symbols(&path) {
177 Ok(s) => s,
178 Err(e) => {
179 return Response::error(&req.id, e.code(), e.to_string());
180 }
181 };
182
183 let entries = build_outline_tree(&symbols);
184 let filename = path
185 .file_name()
186 .map(|f| f.to_string_lossy().to_string())
187 .unwrap_or_else(|| file.to_string());
188 let text = format_single_file_tree(&filename, &entries);
189
190 Response::success(
191 &req.id,
192 serde_json::json!({ "text": text, "complete": true }),
193 )
194}
195
196fn include_tests_param(req: &RawRequest) -> bool {
197 req.params
198 .get("includeTests")
199 .or_else(|| req.params.get("include_tests"))
200 .and_then(|value| value.as_bool())
201 .unwrap_or(false)
202}
203
204fn resolve_file_or_url(
205 req: &RawRequest,
206 ctx: &AppContext,
207 file: &str,
208) -> Result<PathBuf, Response> {
209 if is_http_url(file) {
210 let storage_dir = crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
211 let allow_private = ctx.config().url_fetch_allow_private
212 || req
213 .params
214 .get("allow_private")
215 .and_then(|value| value.as_bool())
216 .unwrap_or(false);
217 return fetch_url_to_cache(
218 file,
219 &storage_dir,
220 UrlFetchOptions {
221 allow_private,
222 ..UrlFetchOptions::default()
223 },
224 )
225 .map_err(|error| Response::error(&req.id, "url_fetch_failed", error.to_string()));
226 }
227
228 ctx.validate_path(&req.id, Path::new(file))
229}
230
231pub(crate) fn build_outline_tree(symbols: &[Symbol]) -> Vec<OutlineEntry> {
242 let mut top_level = Vec::new();
243 let mut scope_index = OutlineScopeIndex::default();
244 let mut children = Vec::new();
245
246 for sym in symbols {
247 if sym.parent.is_none() {
248 push_indexed_entry(&mut top_level, &mut scope_index, symbol_to_entry(sym));
249 } else {
250 children.push(sym);
251 }
252 }
253
254 for child in children {
255 let entry = symbol_to_entry(child);
256 let scope = &child.scope_chain;
257
258 if scope.is_empty() {
259 push_indexed_entry(&mut top_level, &mut scope_index, entry);
260 continue;
261 }
262
263 let entry = match insert_at_scope_indexed(&mut top_level, &mut scope_index, scope, entry) {
266 Ok(()) => continue,
267 Err(entry) => entry,
268 };
269 let entry = match child.parent.as_ref() {
270 Some(parent) => match insert_at_scope_indexed(
271 &mut top_level,
272 &mut scope_index,
273 std::slice::from_ref(parent),
274 entry,
275 ) {
276 Ok(()) => continue,
277 Err(entry) => entry,
278 },
279 None => entry,
280 };
281 push_indexed_entry(&mut top_level, &mut scope_index, entry);
282 }
283
284 top_level
285}
286
287const OUTLINE_SCOPE_INDEX_THRESHOLD: usize = 8;
291
292#[derive(Default)]
293struct OutlineScopeIndex {
294 first_by_name: Option<HashMap<String, usize>>,
295 children: Vec<OutlineScopeIndex>,
296}
297
298impl OutlineScopeIndex {
299 fn first_match(&self, entries: &[OutlineEntry], name: &str) -> Option<usize> {
300 if let Some(first_by_name) = &self.first_by_name {
301 return first_by_name.get(name).copied();
302 }
303 entries.iter().position(|entry| entry.name == name)
304 }
305
306 fn note_pushed(&mut self, entries: &[OutlineEntry]) {
307 debug_assert_eq!(self.children.len() + 1, entries.len());
308 self.children.push(Self::default());
309
310 if let Some(first_by_name) = &mut self.first_by_name {
311 let index = entries.len() - 1;
312 let name = &entries[index].name;
313 if !first_by_name.contains_key(name) {
314 first_by_name.insert(name.clone(), index);
315 }
316 } else if entries.len() == OUTLINE_SCOPE_INDEX_THRESHOLD {
317 let mut first_by_name = HashMap::with_capacity(entries.len());
318 for (index, entry) in entries.iter().enumerate() {
319 first_by_name.entry(entry.name.clone()).or_insert(index);
320 }
321 self.first_by_name = Some(first_by_name);
322 }
323 }
324}
325
326fn push_indexed_entry(
327 entries: &mut Vec<OutlineEntry>,
328 scope_index: &mut OutlineScopeIndex,
329 entry: OutlineEntry,
330) {
331 entries.push(entry);
332 scope_index.note_pushed(entries);
333}
334
335fn insert_at_scope_indexed(
336 entries: &mut Vec<OutlineEntry>,
337 scope_index: &mut OutlineScopeIndex,
338 scope_chain: &[String],
339 entry: OutlineEntry,
340) -> Result<(), OutlineEntry> {
341 let Some(target_name) = scope_chain.first() else {
342 return Err(entry);
343 };
344 let Some(target_index) = scope_index.first_match(entries, target_name) else {
345 return Err(entry);
346 };
347
348 let existing = &mut entries[target_index];
349 let child_index = &mut scope_index.children[target_index];
350 if scope_chain.len() == 1 {
351 push_indexed_entry(&mut existing.members, child_index, entry);
352 Ok(())
353 } else {
354 insert_at_scope_indexed(&mut existing.members, child_index, &scope_chain[1..], entry)
355 }
356}
357
358struct FileOutline {
362 path: String, entries: Vec<OutlineEntry>,
364}
365
366#[derive(Debug, Clone, Serialize)]
367struct SkippedFile {
368 file: String,
369 reason: String,
370}
371
372impl SkippedFile {
373 fn new(file: impl Into<String>, reason: impl Into<String>) -> Self {
374 Self {
375 file: file.into(),
376 reason: reason.into(),
377 }
378 }
379}
380
381#[derive(Debug, Clone, Serialize)]
382struct OutlineFileEntry {
383 path: String,
384 language: String,
385 #[serde(skip_serializing_if = "Option::is_none")]
386 symbols: Option<usize>,
387 lines: Option<usize>,
388 #[serde(skip)]
389 absolute_path: PathBuf,
390 #[serde(skip)]
391 data_doc: bool,
392}
393
394#[derive(Debug, Clone, Default)]
395struct OutlineDirectoryStats {
396 dirs: usize,
397 files: usize,
398 lines: usize,
399 data_doc_files: usize,
400 code_files: usize,
401 code_lines: usize,
402}
403
404#[derive(Debug, Clone)]
405struct OutlineDirectoryNode {
406 path: String,
407 depth: usize,
408 direct_files: Vec<usize>,
409 children: Vec<usize>,
410 stats: OutlineDirectoryStats,
411}
412
413#[derive(Debug, Clone, Copy, PartialEq, Eq)]
414enum OutlineTableRow {
415 File(usize),
416 Rollup(usize),
417}
418
419#[derive(Debug, Clone, PartialEq, Eq)]
421pub struct OutlineTable {
422 table: String,
423 rendered_len: usize,
424}
425
426impl std::ops::Deref for OutlineTable {
427 type Target = str;
428 fn deref(&self) -> &str {
429 &self.table
430 }
431}
432
433impl AsRef<str> for OutlineTable {
434 fn as_ref(&self) -> &str {
435 &self.table
436 }
437}
438
439impl std::fmt::Display for OutlineTable {
440 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
441 write!(f, "{}", self.table)
442 }
443}
444
445impl serde::Serialize for OutlineTable {
446 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
447 where
448 S: serde::Serializer,
449 {
450 serializer.serialize_str(&self.table)
451 }
452}
453
454impl OutlineTable {
455 pub fn len(&self) -> usize {
456 self.rendered_len
457 }
458
459 pub fn is_empty(&self) -> bool {
460 self.rendered_len == 0
461 }
462
463 pub fn as_str(&self) -> &str {
464 &self.table
465 }
466
467 pub fn into_string(self) -> String {
468 self.table
469 }
470}
471
472impl From<OutlineTable> for String {
473 fn from(table: OutlineTable) -> Self {
474 table.table
475 }
476}
477
478#[derive(Debug, Clone, Copy)]
479struct OutlineFileContentStats {
480 binary: bool,
481 lines: Option<usize>,
482}
483
484#[derive(Debug, Clone)]
485struct OutlineWalkOptions {
486 gitignore: Option<Arc<ignore::gitignore::Gitignore>>,
487 gitignore_root: Option<PathBuf>,
488}
489
490#[derive(Debug, Clone)]
491struct OutlineFileDiscovery {
492 files: Vec<String>,
493 directories: Vec<String>,
494 walk_truncated: bool,
495 collection_truncated: bool,
496 skipped_foreign_mounts: usize,
497}
498
499fn handle_outline_files_mode(
500 req: &RawRequest,
501 ctx: &AppContext,
502 max_output_bytes: usize,
503) -> Response {
504 let targets = match outline_files_mode_targets(req) {
505 Ok(targets) => targets,
506 Err(response) => return response,
507 };
508
509 let multiple_targets = targets.len() >= 2;
510 let project_root = ctx.config().project_root.clone();
511 let include_tests = include_tests_param(req);
512
513 let mut file_entries = Vec::new();
514 let mut directory_nodes = Vec::new();
515 let mut tree_roots = Vec::new();
516 let mut walk_truncated = false;
517 let mut collection_truncated = false;
518 let mut skipped_foreign_mounts = 0usize;
519
520 for target in targets {
521 let dir_path = match ctx.validate_path(&req.id, Path::new(&target)) {
522 Ok(path) => path,
523 Err(response) => return response,
524 };
525
526 if !dir_path.exists() {
527 return Response::error(
528 &req.id,
529 "file_not_found",
530 format!("directory not found: {}", target),
531 );
532 }
533 if !dir_path.is_dir() {
534 return Response::error(
535 &req.id,
536 "invalid_request",
537 "files mode requires a directory target",
538 );
539 }
540
541 let display_root = if multiple_targets {
542 project_root.as_deref().unwrap_or(&dir_path)
543 } else {
544 &dir_path
545 };
546 let discovery = discover_outline_files_for_files_mode(&dir_path, ctx);
547 walk_truncated |= discovery.walk_truncated;
548 collection_truncated |= discovery.collection_truncated;
549 skipped_foreign_mounts += discovery.skipped_foreign_mounts;
550
551 let root = append_outline_directory_tree(
552 &dir_path,
553 display_root,
554 discovery,
555 ctx,
556 include_tests,
557 &mut file_entries,
558 &mut directory_nodes,
559 );
560 tree_roots.push(root);
561 }
562
563 for root in &tree_roots {
564 aggregate_outline_directory(*root, &mut directory_nodes, &file_entries);
565 }
566 let rows = plan_outline_file_rows(
567 &tree_roots,
568 &directory_nodes,
569 &file_entries,
570 max_output_bytes,
571 );
572 populate_rendered_file_symbols(&rows, &mut file_entries, ctx);
573 let table = format_files_table(&rows, &directory_nodes, &file_entries, max_output_bytes);
574 let text = table.into_string();
575 let rollup_count = rows
576 .iter()
577 .filter(|row| matches!(row, OutlineTableRow::Rollup(_)))
578 .count();
579
580 let shown = rows
581 .iter()
582 .filter(|row| matches!(row, OutlineTableRow::File(_)))
583 .count();
584 let mut budget_rollup_files = 0;
585 let mut budget_rollups_present = false;
586 for row in &rows {
587 if let OutlineTableRow::Rollup(node_id) = row {
588 if !directory_is_data_heavy(&directory_nodes[*node_id]) {
589 budget_rollups_present = true;
590 budget_rollup_files += directory_nodes[*node_id].stats.files;
591 }
592 }
593 }
594
595 let envelope = crate::list_surfaces::outline::build_outline_files_envelope(
596 shown,
597 budget_rollup_files,
598 budget_rollups_present,
599 collection_truncated,
600 walk_truncated,
601 skipped_foreign_mounts,
602 );
603
604 let mut unchecked_files = Vec::new();
605 if walk_truncated {
606 unchecked_files
607 .push("<additional files not counted: 10000-file walk limit reached>".to_string());
608 }
609 if collection_truncated {
610 unchecked_files.push("<additional files not counted: directory walk failed>".to_string());
611 }
612 if skipped_foreign_mounts > 0 {
613 unchecked_files.push(format!(
614 "<{skipped_foreign_mounts} foreign filesystem mount(s) not traversed>"
615 ));
616 }
617
618 file_entries.sort_by(|a, b| a.path.cmp(&b.path));
619 let mut response_data = serde_json::json!({
620 "text": text,
621 "files": file_entries,
622 "complete": !walk_truncated
623 && !collection_truncated
624 && skipped_foreign_mounts == 0,
625 "walk_truncated": walk_truncated,
626 "walk_limit": OUTLINE_FILE_COLLECTION_CAP,
627 "collection_truncated": collection_truncated,
628 "skipped_foreign_mounts": skipped_foreign_mounts,
629 "unchecked_files": unchecked_files,
630 "rollup_count": rollup_count,
631 });
632 if let Some(env) = envelope {
633 response_data["files_list_envelope"] = serde_json::to_value(&env).unwrap();
634 }
635 Response::success(&req.id, response_data)
636}
637
638fn outline_files_mode_targets(req: &RawRequest) -> Result<Vec<String>, Response> {
639 if let Some(directory) = req.params.get("directory").and_then(|value| value.as_str()) {
640 return Ok(vec![directory.to_string()]);
641 }
642
643 if let Some(directories) = req
644 .params
645 .get("directories")
646 .and_then(|value| value.as_array())
647 {
648 let targets = directories
649 .iter()
650 .filter_map(|value| value.as_str().map(ToOwned::to_owned))
651 .collect::<Vec<_>>();
652 if !targets.is_empty() {
653 return Ok(targets);
654 }
655 }
656
657 if let Some(targets) = req.params.get("targets") {
658 if let Some(target) = targets.as_str() {
659 return Ok(vec![target.to_string()]);
660 }
661 if let Some(targets) = targets.as_array() {
662 let targets = targets
663 .iter()
664 .filter_map(|value| value.as_str().map(ToOwned::to_owned))
665 .collect::<Vec<_>>();
666 if !targets.is_empty() {
667 return Ok(targets);
668 }
669 }
670 }
671
672 if let Some(target) = req.params.get("target") {
673 if let Some(target) = target.as_str() {
674 return Ok(vec![target.to_string()]);
675 }
676 if let Some(targets) = target.as_array() {
677 let targets = targets
678 .iter()
679 .filter_map(|value| value.as_str().map(ToOwned::to_owned))
680 .collect::<Vec<_>>();
681 if !targets.is_empty() {
682 return Ok(targets);
683 }
684 }
685 }
686
687 if let Some(file) = req.params.get("file").and_then(|value| value.as_str()) {
688 return Ok(vec![file.to_string()]);
689 }
690
691 Err(Response::error(
692 &req.id,
693 "invalid_request",
694 "files mode requires a directory target",
695 ))
696}
697
698fn discover_outline_files_for_files_mode(
699 directory: &Path,
700 ctx: &AppContext,
701) -> OutlineFileDiscovery {
702 let gitignore = ctx.gitignore();
703 let gitignore_root = ctx
704 .config()
705 .project_root
706 .as_ref()
707 .and_then(|root| std::fs::canonicalize(root).ok());
708 let options = OutlineWalkOptions {
709 gitignore,
710 gitignore_root,
711 };
712 discover_outline_files_with_options(directory, Some(&options), true)
713}
714
715fn append_outline_directory_tree(
716 target_root: &Path,
717 display_root: &Path,
718 mut discovery: OutlineFileDiscovery,
719 ctx: &AppContext,
720 include_tests: bool,
721 file_entries: &mut Vec<OutlineFileEntry>,
722 directory_nodes: &mut Vec<OutlineDirectoryNode>,
723) -> usize {
724 let root_path = relative_path_from_root(target_root, display_root).unwrap_or_default();
725 let root_id = directory_nodes.len();
726 directory_nodes.push(OutlineDirectoryNode {
727 path: root_path,
728 depth: 0,
729 direct_files: Vec::new(),
730 children: Vec::new(),
731 stats: OutlineDirectoryStats::default(),
732 });
733
734 discovery.directories.sort_by(|a, b| {
735 Path::new(a)
736 .components()
737 .count()
738 .cmp(&Path::new(b).components().count())
739 .then_with(|| a.cmp(b))
740 });
741 let mut directory_ids = HashMap::new();
742 directory_ids.insert(target_root.to_path_buf(), root_id);
743
744 for directory in discovery.directories {
745 let path = PathBuf::from(directory);
746 let Some(parent_id) = path
747 .parent()
748 .and_then(|parent| directory_ids.get(parent).copied())
749 else {
750 continue;
751 };
752 let node_id = directory_nodes.len();
753 let node_path =
754 relative_path_from_root(&path, display_root).unwrap_or_else(|| path_to_slash(&path));
755 let depth = directory_nodes[parent_id].depth + 1;
756 directory_nodes.push(OutlineDirectoryNode {
757 path: node_path,
758 depth,
759 direct_files: Vec::new(),
760 children: Vec::new(),
761 stats: OutlineDirectoryStats::default(),
762 });
763 directory_nodes[parent_id].children.push(node_id);
764 directory_ids.insert(path, node_id);
765 }
766
767 for file in discovery.files {
768 let path = PathBuf::from(file);
769 let test_path = ctx
770 .config()
771 .project_root
772 .as_deref()
773 .and_then(|root| relative_path_from_root(&path, root))
774 .unwrap_or_else(|| path_to_slash(&path));
775 if !include_tests && is_test_file(&test_path) {
776 continue;
777 }
778 let Some(entry) = outline_file_entry(&path, display_root) else {
779 continue;
780 };
781 let Some(parent_id) = path
782 .parent()
783 .and_then(|parent| directory_ids.get(parent).copied())
784 else {
785 continue;
786 };
787 let file_id = file_entries.len();
788 file_entries.push(entry);
789 directory_nodes[parent_id].direct_files.push(file_id);
790 }
791
792 root_id
793}
794
795fn aggregate_outline_directory(
796 node_id: usize,
797 directory_nodes: &mut [OutlineDirectoryNode],
798 file_entries: &[OutlineFileEntry],
799) -> OutlineDirectoryStats {
800 let direct_files = directory_nodes[node_id].direct_files.clone();
801 let children = directory_nodes[node_id].children.clone();
802 let mut stats = OutlineDirectoryStats::default();
803
804 for file_id in direct_files {
805 let entry = &file_entries[file_id];
806 stats.files += 1;
807 stats.lines += entry.lines.unwrap_or(0);
808 if entry.data_doc {
809 stats.data_doc_files += 1;
810 } else {
811 stats.code_files += 1;
812 stats.code_lines += entry.lines.unwrap_or(0);
813 }
814 }
815 for child in children {
816 let child_stats = aggregate_outline_directory(child, directory_nodes, file_entries);
817 stats.dirs += child_stats.dirs + 1;
818 stats.files += child_stats.files;
819 stats.lines += child_stats.lines;
820 stats.data_doc_files += child_stats.data_doc_files;
821 stats.code_files += child_stats.code_files;
822 stats.code_lines += child_stats.code_lines;
823 }
824
825 directory_nodes[node_id].stats = stats.clone();
826 stats
827}
828
829fn outline_rows_for_directory(
830 node_id: usize,
831 directory_nodes: &[OutlineDirectoryNode],
832 file_entries: &[OutlineFileEntry],
833) -> Vec<OutlineTableRow> {
834 let node = &directory_nodes[node_id];
835 let mut code_files = node
836 .direct_files
837 .iter()
838 .copied()
839 .filter(|file_id| !file_entries[*file_id].data_doc)
840 .collect::<Vec<_>>();
841 let mut data_files = node
842 .direct_files
843 .iter()
844 .copied()
845 .filter(|file_id| file_entries[*file_id].data_doc)
846 .collect::<Vec<_>>();
847 let mut directories = node.children.clone();
848 code_files.sort_by(|a, b| file_entries[*a].path.cmp(&file_entries[*b].path));
849 data_files.sort_by(|a, b| file_entries[*a].path.cmp(&file_entries[*b].path));
850 directories.sort_by(|a, b| directory_nodes[*a].path.cmp(&directory_nodes[*b].path));
851
852 code_files
853 .into_iter()
854 .map(OutlineTableRow::File)
855 .chain(directories.into_iter().map(OutlineTableRow::Rollup))
856 .chain(data_files.into_iter().map(OutlineTableRow::File))
857 .collect()
858}
859
860fn directory_is_data_heavy(node: &OutlineDirectoryNode) -> bool {
861 !node.direct_files.is_empty()
862 && node.stats.files > 0
863 && node.stats.data_doc_files * 10 >= node.stats.files * 9
864}
865
866fn outline_expansion_added_rows(node: &OutlineDirectoryNode) -> usize {
867 node.direct_files
868 .len()
869 .saturating_add(node.children.len())
870 .saturating_sub(1)
871}
872
873fn compare_directory_code_share(
874 a: &OutlineDirectoryNode,
875 b: &OutlineDirectoryNode,
876) -> std::cmp::Ordering {
877 let a_total = if a.stats.lines == 0 {
878 a.stats.files.max(1)
879 } else {
880 a.stats.lines
881 };
882 let b_total = if b.stats.lines == 0 {
883 b.stats.files.max(1)
884 } else {
885 b.stats.lines
886 };
887 let a_code = if a.stats.lines == 0 {
888 a.stats.code_files
889 } else {
890 a.stats.code_lines
891 };
892 let b_code = if b.stats.lines == 0 {
893 b.stats.code_files
894 } else {
895 b.stats.code_lines
896 };
897 (a_code as u128 * b_total as u128).cmp(&(b_code as u128 * a_total as u128))
898}
899
900fn plan_outline_file_rows(
901 roots: &[usize],
902 directory_nodes: &[OutlineDirectoryNode],
903 file_entries: &[OutlineFileEntry],
904 max_bytes: usize,
905) -> Vec<OutlineTableRow> {
906 let mut rows = roots
912 .iter()
913 .flat_map(|root| outline_rows_for_directory(*root, directory_nodes, file_entries))
914 .collect::<Vec<_>>();
915 let mut considered = std::collections::HashSet::new();
916
917 loop {
918 let Some(level) = rows
919 .iter()
920 .filter_map(|row| match row {
921 OutlineTableRow::Rollup(node_id)
922 if !considered.contains(node_id)
923 && !directory_is_data_heavy(&directory_nodes[*node_id]) =>
924 {
925 Some(directory_nodes[*node_id].depth)
926 }
927 _ => None,
928 })
929 .min()
930 else {
931 break;
932 };
933
934 let mut candidates = rows
935 .iter()
936 .filter_map(|row| match row {
937 OutlineTableRow::Rollup(node_id)
938 if directory_nodes[*node_id].depth == level
939 && !considered.contains(node_id)
940 && !directory_is_data_heavy(&directory_nodes[*node_id]) =>
941 {
942 Some(*node_id)
943 }
944 _ => None,
945 })
946 .collect::<Vec<_>>();
947 candidates.sort_by(|a, b| {
948 let a_node = &directory_nodes[*a];
949 let b_node = &directory_nodes[*b];
950 outline_expansion_added_rows(a_node)
951 .cmp(&outline_expansion_added_rows(b_node))
952 .then_with(|| compare_directory_code_share(a_node, b_node).reverse())
953 .then_with(|| a_node.path.cmp(&b_node.path))
954 });
955
956 let mut level_fully_expanded = true;
957 for node_id in candidates {
958 considered.insert(node_id);
959 let replacement = outline_rows_for_directory(node_id, directory_nodes, file_entries);
960 if replacement.is_empty() {
961 continue;
962 }
963 let Some(position) = rows
964 .iter()
965 .position(|row| *row == OutlineTableRow::Rollup(node_id))
966 else {
967 continue;
968 };
969 let mut candidate_rows = rows.clone();
970 candidate_rows.splice(position..=position, replacement);
971 if format_files_table(&candidate_rows, directory_nodes, file_entries, max_bytes).len()
972 <= max_bytes
973 {
974 rows = candidate_rows;
975 } else {
976 level_fully_expanded = false;
977 }
978 }
979 if !level_fully_expanded {
980 break;
981 }
982 }
983
984 rows
985}
986
987fn outline_file_entry(path: &Path, display_root: &Path) -> Option<OutlineFileEntry> {
988 let rel_path =
989 relative_path_from_root(path, display_root).unwrap_or_else(|| path_to_slash(path));
990 let detected_language = detect_language(path);
991 let content = inspect_outline_file_content(path).unwrap_or(OutlineFileContentStats {
992 binary: false,
993 lines: None,
994 });
995 let language = if content.binary {
996 "binary"
997 } else {
998 outline_file_language(path, detected_language)
999 };
1000
1001 Some(OutlineFileEntry {
1002 path: rel_path,
1003 language: language.to_string(),
1004 symbols: None,
1005 lines: content.lines,
1006 absolute_path: path.to_path_buf(),
1007 data_doc: is_data_doc_outline_file(path, detected_language),
1008 })
1009}
1010
1011fn populate_rendered_file_symbols(
1012 rows: &[OutlineTableRow],
1013 file_entries: &mut [OutlineFileEntry],
1014 ctx: &AppContext,
1015) {
1016 for file_id in rows.iter().filter_map(|row| match row {
1017 OutlineTableRow::File(file_id) => Some(*file_id),
1018 OutlineTableRow::Rollup(_) => None,
1019 }) {
1020 let entry = &mut file_entries[file_id];
1021 if entry.symbols.is_some() {
1022 continue;
1023 }
1024 if entry.language == "binary" {
1025 entry.symbols = Some(0);
1026 continue;
1027 }
1028 let path = entry.absolute_path.clone();
1029 if detect_language(&path).is_none() {
1030 entry.symbols = Some(0);
1031 continue;
1032 }
1033 let Ok(metadata) = std::fs::metadata(&path) else {
1034 entry.symbols = Some(0);
1035 continue;
1036 };
1037 let symbols = cached_symbol_count(ctx, &path, &metadata).unwrap_or_else(|| {
1038 if metadata.len() > MAX_OUTLINE_FILE_BYTES {
1039 0
1040 } else {
1041 ctx.provider()
1042 .list_symbols(&path)
1043 .map(|symbols| symbols.len())
1044 .unwrap_or(0)
1045 }
1046 });
1047 entry.symbols = Some(symbols);
1048 }
1049}
1050
1051fn relative_path_from_root(path: &Path, root: &Path) -> Option<String> {
1052 if let Ok(relative) = path.strip_prefix(root) {
1053 return Some(path_to_slash(relative));
1054 }
1055
1056 let canonical_path = std::fs::canonicalize(path).ok()?;
1057 let canonical_root = std::fs::canonicalize(root).ok()?;
1058 canonical_path
1059 .strip_prefix(canonical_root)
1060 .ok()
1061 .map(path_to_slash)
1062}
1063
1064fn path_to_slash(path: &Path) -> String {
1065 path.to_string_lossy().replace('\\', "/")
1066}
1067
1068fn cached_symbol_count(
1069 ctx: &AppContext,
1070 path: &Path,
1071 metadata: &std::fs::Metadata,
1072) -> Option<usize> {
1073 let mtime = metadata.modified().unwrap_or(UNIX_EPOCH);
1074 let size = metadata.len();
1075 let symbol_cache = ctx.symbol_cache();
1076 let cache = symbol_cache.read().ok()?;
1077 cache
1078 .symbol_count_if_metadata_matches(path, mtime, size)
1079 .or_else(|| cache.get(path, mtime).map(|symbols| symbols.len()))
1080}
1081
1082fn inspect_outline_file_content(path: &Path) -> std::io::Result<OutlineFileContentStats> {
1083 let mut file = std::fs::File::open(path)?;
1084 let mut sample = [0u8; BINARY_SAMPLE_BYTES];
1085 let sample_len = file.read(&mut sample)?;
1086 if sample_len > 0 && content_inspector::inspect(&sample[..sample_len]).is_binary() {
1087 return Ok(OutlineFileContentStats {
1088 binary: true,
1089 lines: None,
1090 });
1091 }
1092
1093 let mut newline_count = sample[..sample_len]
1094 .iter()
1095 .filter(|byte| **byte == b'\n')
1096 .count();
1097 let mut total_bytes = sample_len;
1098 let mut last_byte = sample_len.checked_sub(1).map(|index| sample[index]);
1099 let mut buffer = [0u8; 16 * 1024];
1100 loop {
1101 let read = file.read(&mut buffer)?;
1102 if read == 0 {
1103 break;
1104 }
1105 newline_count += buffer[..read].iter().filter(|byte| **byte == b'\n').count();
1106 total_bytes += read;
1107 last_byte = Some(buffer[read - 1]);
1108 }
1109
1110 let lines = newline_count + usize::from(total_bytes > 0 && last_byte != Some(b'\n'));
1111 Ok(OutlineFileContentStats {
1112 binary: false,
1113 lines: Some(lines),
1114 })
1115}
1116
1117fn outline_file_language(path: &Path, detected_language: Option<LangId>) -> &'static str {
1118 if let Some(language) = detected_language {
1119 return language_id(language);
1120 }
1121 let filename = path
1122 .file_name()
1123 .and_then(|name| name.to_str())
1124 .unwrap_or_default();
1125 let extension = path
1126 .extension()
1127 .and_then(|extension| extension.to_str())
1128 .unwrap_or_default()
1129 .to_ascii_lowercase();
1130 if extension == "toml" {
1131 "toml"
1132 } else if extension == "lock" || filename.ends_with(".lock") {
1133 "lock"
1134 } else if extension == "txt" {
1135 "text"
1136 } else if extension == "bazel" || matches!(filename, "BUILD" | "WORKSPACE" | "MODULE.bazel") {
1137 "bazel"
1138 } else {
1139 "unknown"
1140 }
1141}
1142
1143fn is_data_doc_outline_file(path: &Path, detected_language: Option<LangId>) -> bool {
1144 if matches!(
1145 detected_language,
1146 Some(LangId::Json | LangId::Yaml | LangId::Markdown)
1147 ) {
1148 return true;
1149 }
1150 detected_language.is_none()
1151 || matches!(
1152 outline_file_language(path, detected_language),
1153 "toml" | "lock" | "text" | "bazel" | "unknown"
1154 )
1155}
1156
1157fn language_id(lang: LangId) -> &'static str {
1158 match lang {
1159 LangId::TypeScript => "typescript",
1160 LangId::Tsx => "tsx",
1161 LangId::JavaScript => "javascript",
1162 LangId::Python => "python",
1163 LangId::Rust => "rust",
1164 LangId::Go => "go",
1165 LangId::C => "c",
1166 LangId::Cpp => "cpp",
1167 LangId::Cuda => "cuda",
1168 LangId::Metal => "metal",
1169 LangId::Zig => "zig",
1170 LangId::CSharp => "csharp",
1171 LangId::Bash => "bash",
1172 LangId::Html => "html",
1173 LangId::Markdown => "markdown",
1174 LangId::Yaml => "yaml",
1175 LangId::Solidity => "solidity",
1176 LangId::Scss => "scss",
1177 LangId::Vue => "vue",
1178 LangId::Json => "json",
1179 LangId::Scala => "scala",
1180 LangId::Java => "java",
1181 LangId::Ruby => "ruby",
1182 LangId::Kotlin => "kotlin",
1183 LangId::Swift => "swift",
1184 LangId::Php => "php",
1185 LangId::Lua => "lua",
1186 LangId::Perl => "perl",
1187 LangId::Pascal => "pascal",
1188 LangId::R => "r",
1189 LangId::Groovy => "groovy",
1190 LangId::ObjC => "objc",
1191 LangId::Toml => "toml",
1192 }
1193}
1194
1195fn format_files_table(
1196 rows: &[OutlineTableRow],
1197 directory_nodes: &[OutlineDirectoryNode],
1198 file_entries: &[OutlineFileEntry],
1199 _max_bytes: usize,
1200) -> OutlineTable {
1201 let path_width = rows
1202 .iter()
1203 .map(|row| match row {
1204 OutlineTableRow::File(file_id) => file_entries[*file_id].path.len(),
1205 OutlineTableRow::Rollup(node_id) => directory_nodes[*node_id].path.len() + 1,
1206 })
1207 .max()
1208 .unwrap_or(0);
1209 let language_width = rows
1210 .iter()
1211 .filter_map(|row| match row {
1212 OutlineTableRow::File(file_id) => Some(file_entries[*file_id].language.len()),
1213 OutlineTableRow::Rollup(_) => None,
1214 })
1215 .max()
1216 .unwrap_or("language".len())
1217 .max(8);
1218 let file_middle_width = language_width + 11;
1219 let middle_width = rows
1220 .iter()
1221 .filter_map(|row| match row {
1222 OutlineTableRow::File(_) => None,
1223 OutlineTableRow::Rollup(node_id) => {
1224 Some(directory_rollup_summary(&directory_nodes[*node_id].stats).len())
1225 }
1226 })
1227 .max()
1228 .unwrap_or(0)
1229 .max(file_middle_width);
1230
1231 let mut output = String::new();
1232 for row in rows {
1233 let (path, middle, lines) = match row {
1234 OutlineTableRow::File(file_id) => {
1235 let entry = &file_entries[*file_id];
1236 (
1237 entry.path.clone(),
1238 format!(
1239 "{:<language_width$} {:>5} syms",
1240 entry.language,
1241 entry.symbols.unwrap_or(0)
1242 ),
1243 entry.lines.map(|lines| lines.to_string()),
1244 )
1245 }
1246 OutlineTableRow::Rollup(node_id) => {
1247 let node = &directory_nodes[*node_id];
1248 (
1249 format!("{}/", node.path.trim_end_matches('/')),
1250 directory_rollup_summary(&node.stats),
1251 Some(node.stats.lines.to_string()),
1252 )
1253 }
1254 };
1255 output.push_str(&format!(
1256 "{path:<path_width$} {middle:<middle_width$} {lines:>7} lines\n",
1257 lines = lines.as_deref().unwrap_or("-"),
1258 ));
1259 }
1260
1261 let shown = rows
1262 .iter()
1263 .filter(|row| matches!(row, OutlineTableRow::File(_)))
1264 .count();
1265 let mut budget_rollup_files = 0;
1266 let mut budget_rollups_present = false;
1267 for row in rows {
1268 if let OutlineTableRow::Rollup(node_id) = row {
1269 if !directory_is_data_heavy(&directory_nodes[*node_id]) {
1270 budget_rollups_present = true;
1271 budget_rollup_files += directory_nodes[*node_id].stats.files;
1272 }
1273 }
1274 }
1275
1276 let rendered_len = if budget_rollups_present {
1277 let envelope = crate::list_envelope::ListEnvelope::new(
1278 shown,
1279 crate::list_envelope::Total::Exact(shown + budget_rollup_files),
1280 crate::list_envelope::Unit::Files,
1281 vec![crate::list_envelope::Reason::Budget],
1282 &["path"],
1283 );
1284 let trailer_len = crate::list_surfaces::outline::outline_trailer_byte_len(&envelope);
1285 output.len() + 2 + trailer_len
1286 } else {
1287 output.len()
1288 };
1289
1290 OutlineTable {
1291 table: output,
1292 rendered_len,
1293 }
1294}
1295
1296fn directory_rollup_summary(stats: &OutlineDirectoryStats) -> String {
1297 let file_word = if stats.files == 1 { "file" } else { "files" };
1298 let dir_word = if stats.dirs == 1 { "dir" } else { "dirs" };
1299 if stats.dirs == 0 {
1300 format!("{} {file_word}", stats.files)
1301 } else {
1302 format!("{} {file_word}, {} {dir_word}", stats.files, stats.dirs)
1303 }
1304}
1305
1306fn outline_many_files(
1307 files: &[String],
1308 ctx: &AppContext,
1309 req_id: &str,
1310 project_root: Option<&Path>,
1311) -> Result<(Vec<FileOutline>, Vec<SkippedFile>), Response> {
1312 let mut file_outlines: Vec<FileOutline> = Vec::with_capacity(files.len());
1313 let mut skipped_files: Vec<SkippedFile> = Vec::new();
1314
1315 for file in files {
1316 let path = match ctx.validate_path(req_id, Path::new(file)) {
1317 Ok(path) => path,
1318 Err(resp) => return Err(resp),
1319 };
1320 if !path.exists() {
1321 skipped_files.push(SkippedFile::new(file, "file_not_found"));
1322 continue;
1323 }
1324
1325 let rel_path = display_path(&path, file, project_root);
1326 if let Some(reason) = outline_skip_reason(&path) {
1327 skipped_files.push(SkippedFile::new(rel_path, reason));
1328 continue;
1329 }
1330
1331 match ctx.provider().list_symbols(&path) {
1332 Ok(symbols) => {
1333 let entries = build_outline_tree(&symbols);
1334 file_outlines.push(FileOutline {
1335 path: rel_path,
1336 entries,
1337 });
1338 }
1339 Err(e) => skipped_files.push(SkippedFile::new(rel_path, outline_error_reason(&e))),
1340 }
1341 }
1342
1343 Ok((file_outlines, skipped_files))
1344}
1345
1346fn discover_outline_files(directory: &Path) -> OutlineFileDiscovery {
1347 let mut discovery = discover_outline_files_with_options(directory, None, false);
1348 if discovery.files.len() > OUTLINE_FILE_WALK_CAP {
1349 discovery.files.truncate(OUTLINE_FILE_WALK_CAP);
1350 discovery.walk_truncated = true;
1351 }
1352 discovery
1353}
1354
1355fn discover_outline_files_with_options(
1356 directory: &Path,
1357 options: Option<&OutlineWalkOptions>,
1358 breadth_first: bool,
1359) -> OutlineFileDiscovery {
1360 let mut files = Vec::new();
1361 let mut directories = Vec::new();
1362 let mut walk_truncated = false;
1363 let mut collection_truncated = false;
1364 let mut skipped_foreign_mounts = 0usize;
1365 let boundary = crate::walk_boundary::DeviceBoundary::for_root(directory);
1369 if let Ok(boundary) = boundary {
1370 let mut device_lookup = crate::walk_boundary::filesystem_device_id;
1371 if breadth_first {
1372 collect_outline_files_breadth_first_with_device_lookup(
1373 directory,
1374 &mut files,
1375 &mut directories,
1376 &mut walk_truncated,
1377 &mut collection_truncated,
1378 &mut skipped_foreign_mounts,
1379 options,
1380 &boundary,
1381 &mut device_lookup,
1382 );
1383 } else {
1384 collect_outline_files_with_device_lookup(
1385 directory,
1386 &mut files,
1387 &mut directories,
1388 &mut walk_truncated,
1389 &mut collection_truncated,
1390 &mut skipped_foreign_mounts,
1391 options,
1392 &boundary,
1393 &mut device_lookup,
1394 );
1395 }
1396 } else {
1397 collection_truncated = true;
1398 }
1399 files.sort();
1400 directories.sort();
1401
1402 OutlineFileDiscovery {
1403 files,
1404 directories,
1405 walk_truncated,
1406 collection_truncated,
1407 skipped_foreign_mounts,
1408 }
1409}
1410
1411fn collect_outline_files_with_device_lookup<F>(
1412 directory: &Path,
1413 files: &mut Vec<String>,
1414 directories: &mut Vec<String>,
1415 walk_truncated: &mut bool,
1416 collection_truncated: &mut bool,
1417 skipped_foreign_mounts: &mut usize,
1418 options: Option<&OutlineWalkOptions>,
1419 boundary: &crate::walk_boundary::DeviceBoundary,
1420 device_lookup: &mut F,
1421) where
1422 F: FnMut(&Path) -> std::io::Result<Option<u64>>,
1423{
1424 if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1425 *walk_truncated = true;
1426 return;
1427 }
1428 let Ok(entries) = std::fs::read_dir(directory) else {
1429 return;
1430 };
1431 let mut entries = entries.flatten().collect::<Vec<_>>();
1432 entries.sort_by_key(|entry| entry.path());
1433
1434 for entry in entries {
1435 if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1436 *walk_truncated = true;
1437 return;
1438 }
1439 let Ok(file_type) = entry.file_type() else {
1440 continue;
1441 };
1442 if file_type.is_symlink() {
1443 continue;
1444 }
1445 let path = entry.path();
1446 if file_type.is_dir() {
1447 if should_skip_directory(&path) || is_ignored_outline_path(&path, true, options) {
1448 continue;
1449 }
1450 match boundary.should_descend_with(&path, |child| device_lookup(child)) {
1451 Ok(true) => {}
1452 Ok(false) => {
1453 *skipped_foreign_mounts += 1;
1454 continue;
1455 }
1456 Err(_) => {
1457 *collection_truncated = true;
1458 return;
1459 }
1460 }
1461 directories.push(path.to_string_lossy().to_string());
1462 collect_outline_files_with_device_lookup(
1463 &path,
1464 files,
1465 directories,
1466 walk_truncated,
1467 collection_truncated,
1468 skipped_foreign_mounts,
1469 options,
1470 boundary,
1471 device_lookup,
1472 );
1473 if *walk_truncated || *collection_truncated {
1474 return;
1475 }
1476 } else if file_type.is_file() {
1477 if is_ignored_outline_path(&path, false, options) {
1478 continue;
1479 }
1480 files.push(path.to_string_lossy().to_string());
1481 }
1482 }
1483}
1484
1485fn collect_outline_files_breadth_first_with_device_lookup<F>(
1486 directory: &Path,
1487 files: &mut Vec<String>,
1488 directories: &mut Vec<String>,
1489 walk_truncated: &mut bool,
1490 collection_truncated: &mut bool,
1491 skipped_foreign_mounts: &mut usize,
1492 options: Option<&OutlineWalkOptions>,
1493 boundary: &crate::walk_boundary::DeviceBoundary,
1494 device_lookup: &mut F,
1495) where
1496 F: FnMut(&Path) -> std::io::Result<Option<u64>>,
1497{
1498 let mut pending = VecDeque::from([directory.to_path_buf()]);
1499
1500 while let Some(current) = pending.pop_front() {
1501 if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1502 *walk_truncated = true;
1503 return;
1504 }
1505 let Ok(entries) = std::fs::read_dir(¤t) else {
1506 continue;
1507 };
1508 let mut entries = entries.flatten().collect::<Vec<_>>();
1509 entries.sort_by_key(|entry| entry.path());
1510 let mut child_directories = Vec::new();
1511 let mut child_files = Vec::new();
1512 for entry in entries {
1513 let Ok(file_type) = entry.file_type() else {
1516 continue;
1517 };
1518 if file_type.is_symlink() {
1519 continue;
1520 }
1521 if file_type.is_dir() {
1522 child_directories.push(entry.path());
1523 } else if file_type.is_file() {
1524 child_files.push(entry.path());
1525 }
1526 }
1527
1528 for path in child_directories {
1532 if should_skip_directory(&path) || is_ignored_outline_path(&path, true, options) {
1533 continue;
1534 }
1535 match boundary.should_descend_with(&path, |child| device_lookup(child)) {
1536 Ok(true) => {
1537 directories.push(path.to_string_lossy().to_string());
1538 pending.push_back(path);
1539 }
1540 Ok(false) => *skipped_foreign_mounts += 1,
1541 Err(_) => {
1542 *collection_truncated = true;
1543 return;
1544 }
1545 }
1546 }
1547
1548 for path in child_files {
1549 if files.len() >= OUTLINE_FILE_COLLECTION_CAP {
1550 *walk_truncated = true;
1551 return;
1552 }
1553 if is_ignored_outline_path(&path, false, options) {
1554 continue;
1555 }
1556 files.push(path.to_string_lossy().to_string());
1557 }
1558 }
1559}
1560
1561fn is_ignored_outline_path(
1562 path: &Path,
1563 is_dir: bool,
1564 options: Option<&OutlineWalkOptions>,
1565) -> bool {
1566 let Some(options) = options else {
1567 return false;
1568 };
1569 let Some(gitignore) = options.gitignore.as_ref() else {
1570 return false;
1571 };
1572
1573 let candidate = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
1574 if let Some(root) = options.gitignore_root.as_ref() {
1575 if !candidate.starts_with(root) {
1576 return false;
1577 }
1578 }
1579
1580 gitignore
1581 .matched_path_or_any_parents(candidate, is_dir)
1582 .is_ignore()
1583}
1584
1585fn should_skip_directory(path: &Path) -> bool {
1586 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
1587 return false;
1588 };
1589 matches!(
1590 name,
1591 "node_modules"
1592 | ".git"
1593 | "dist"
1594 | "build"
1595 | "out"
1596 | ".next"
1597 | ".nuxt"
1598 | "target"
1599 | "__pycache__"
1600 | ".venv"
1601 | "venv"
1602 | "vendor"
1603 | ".turbo"
1604 | "coverage"
1605 | ".nyc_output"
1606 | ".cache"
1607 ) || name.starts_with('.')
1608}
1609
1610fn display_path(path: &Path, fallback: &str, project_root: Option<&Path>) -> String {
1611 project_root
1612 .and_then(|root| path.strip_prefix(root).ok())
1613 .map(|p| p.to_string_lossy().to_string())
1614 .unwrap_or_else(|| fallback.to_string())
1615}
1616
1617fn outline_skip_reason(path: &Path) -> Option<&'static str> {
1618 if !path.is_file() {
1619 return Some("file_not_found");
1620 }
1621
1622 let metadata = match std::fs::metadata(path) {
1623 Ok(metadata) => metadata,
1624 Err(_) => return Some("file_not_found"),
1625 };
1626 if metadata.len() > MAX_OUTLINE_FILE_BYTES {
1627 return Some("too_large");
1628 }
1629
1630 if detect_language(path).is_none() {
1631 return Some("unsupported_language");
1632 }
1633
1634 match edit::validate_syntax(path) {
1643 Ok(Some(false)) => Some("parse_error"),
1644 Ok(Some(true)) | Ok(None) => None,
1645 Err(e) => Some(outline_error_reason(&e)),
1646 }
1647}
1648
1649fn outline_error_reason(error: &AftError) -> &'static str {
1650 match error.code() {
1651 "invalid_request" => "unsupported_language",
1652 "parse_error" => "parse_error",
1653 "file_not_found" => "file_not_found",
1654 "project_too_large" => "too_large",
1655 _ => "error",
1656 }
1657}
1658
1659fn kind_abbrev(kind: &str) -> &str {
1661 match kind {
1662 "function" => "fn",
1663 "variable" => "var",
1664 "class" => "cls",
1665 "interface" => "ifc",
1666 "type_alias" => "type",
1667 "enum" => "enum",
1668 "method" => "mth",
1669 "property" => "prop",
1670 "struct" => "st",
1671 "heading" => "h",
1672 _ => &kind[..kind.len().min(4)],
1673 }
1674}
1675
1676fn format_entry_compact(entry: &OutlineEntry) -> String {
1678 let vis = if entry.exported { 'E' } else { '-' };
1679 let kind = kind_abbrev(&entry.kind);
1680 let sl = entry.range.start_line + 1;
1683 let el = entry.range.end_line + 1;
1684 format!("{} {:<4} {} {}:{}", vis, kind, entry.name, sl, el)
1685}
1686
1687const SIGNATURE_VISIBILITY_KEYWORDS: &[&str] = &[
1692 "pub",
1693 "public",
1694 "export",
1695 "open",
1696 "external",
1697 "internal",
1698 "private",
1699 "protected",
1700];
1701
1702fn signature_has_visibility(sig: &str) -> bool {
1705 sig.split_whitespace().any(|token| {
1706 let head = token.split('(').next().unwrap_or(token);
1709 SIGNATURE_VISIBILITY_KEYWORDS.contains(&head)
1710 })
1711}
1712
1713pub(crate) fn format_entry_with_sig(entry: &OutlineEntry) -> String {
1730 let sl = entry.range.start_line + 1;
1731 let el = entry.range.end_line + 1;
1732 if let Some(ref sig) = entry.signature {
1733 if entry.exported && !signature_has_visibility(sig) {
1734 format!("E {} {}:{}", sig, sl, el)
1735 } else {
1736 format!("{} {}:{}", sig, sl, el)
1737 }
1738 } else {
1739 let vis = if entry.exported { 'E' } else { '-' };
1740 let kind = kind_abbrev(&entry.kind);
1741 format!("{} {:<4} {} {}:{}", vis, kind, entry.name, sl, el)
1742 }
1743}
1744
1745fn render_entries(entries: &[OutlineEntry], indent: usize, output: &mut String, with_sig: bool) {
1747 let prefix = " ".repeat(indent);
1748 let member_prefix = " ".repeat(indent + 1);
1749 for entry in entries {
1750 if with_sig {
1751 output.push_str(&format!("{}{}\n", prefix, format_entry_with_sig(entry)));
1752 } else {
1753 output.push_str(&format!("{}{}\n", prefix, format_entry_compact(entry)));
1754 }
1755 if !entry.members.is_empty() {
1756 for member in &entry.members {
1757 if with_sig {
1758 output.push_str(&format!(
1759 "{}.{}\n",
1760 member_prefix,
1761 format_entry_with_sig(member)
1762 ));
1763 } else {
1764 output.push_str(&format!(
1765 "{}.{}\n",
1766 member_prefix,
1767 format_entry_compact(member)
1768 ));
1769 }
1770 if !member.members.is_empty() {
1772 render_entries(&member.members, indent + 2, output, with_sig);
1773 }
1774 }
1775 }
1776 }
1777}
1778
1779fn render_top_level_entries(
1782 entries: &[OutlineEntry],
1783 indent: usize,
1784 output: &mut String,
1785 with_sig: bool,
1786) {
1787 let prefix = " ".repeat(indent);
1788 for entry in entries {
1789 if with_sig {
1790 output.push_str(&format!("{}{}\n", prefix, format_entry_with_sig(entry)));
1791 } else {
1792 output.push_str(&format!("{}{}\n", prefix, format_entry_compact(entry)));
1793 }
1794 }
1795}
1796
1797fn format_single_file_tree(filename: &str, entries: &[OutlineEntry]) -> String {
1799 let mut output = format!("{}\n", filename);
1800 render_entries(entries, 1, &mut output, true);
1801 output
1802}
1803
1804fn format_multi_file_tree(
1809 file_outlines: &[FileOutline],
1810 max_bytes: usize,
1811 total_requested: usize,
1812) -> String {
1813 let mut output = String::new();
1816 let mut truncated = false;
1817 let mut files_shown = 0;
1818
1819 let mut sorted: Vec<&FileOutline> = file_outlines.iter().collect();
1821 sorted.sort_by(|a, b| a.path.cmp(&b.path));
1822
1823 let mut prev_parts: Vec<&str> = Vec::new();
1825
1826 for fo in &sorted {
1827 let parts: Vec<&str> = fo.path.split('/').collect();
1828 let file_name = parts.last().copied().unwrap_or(&fo.path);
1829 let dir_parts = &parts[..parts.len().saturating_sub(1)];
1830
1831 let common = prev_parts
1833 .iter()
1834 .zip(dir_parts.iter())
1835 .take_while(|(a, b)| a == b)
1836 .count();
1837
1838 for (i, part) in dir_parts.iter().enumerate().skip(common) {
1840 let indent = " ".repeat(i);
1841 output.push_str(&format!("{}{}/\n", indent, part));
1842 }
1843
1844 let file_indent = " ".repeat(dir_parts.len());
1846 output.push_str(&format!("{}{}\n", file_indent, file_name));
1847
1848 render_top_level_entries(&fo.entries, dir_parts.len() + 1, &mut output, false);
1851
1852 files_shown += 1;
1853 prev_parts = parts.iter().map(|s| *s).collect();
1854
1855 if output.len() > max_bytes {
1857 truncated = true;
1858 break;
1859 }
1860 }
1861
1862 if truncated {
1863 output.push_str(&format!(
1864 "\n... truncated ({}/{} files shown, {}KB limit)\n\
1865 Narrow scope with a more specific directory path, or pass a single file as target.\n",
1866 files_shown,
1867 total_requested,
1868 max_bytes / 1024,
1869 ));
1870 }
1871
1872 output
1873}
1874
1875pub(crate) fn symbol_to_entry(sym: &Symbol) -> OutlineEntry {
1876 OutlineEntry {
1877 name: sym.name.clone(),
1878 kind: serde_json::to_value(&sym.kind)
1879 .ok()
1880 .and_then(|v| v.as_str().map(String::from))
1881 .unwrap_or_else(|| format!("{:?}", sym.kind).to_lowercase()),
1882 range: sym.range.clone(),
1883 signature: sym.signature.clone(),
1884 exported: sym.exported,
1885 members: Vec::new(),
1886 }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891 use super::*;
1892 use crate::symbols::SymbolKind;
1893
1894 #[test]
1895 fn outline_walk_skips_and_reports_injected_foreign_mount() {
1896 let temp = tempfile::tempdir().expect("tempdir");
1897 let root = temp.path().join("root");
1898 let local = root.join("local");
1899 let foreign = root.join("foreign");
1900 std::fs::create_dir_all(&local).expect("create local directory");
1901 std::fs::create_dir_all(&foreign).expect("create foreign directory");
1902 std::fs::write(local.join("keep.rs"), "pub fn keep() {}\n").expect("write local file");
1903 std::fs::write(foreign.join("skip.rs"), "pub fn skip() {}\n").expect("write foreign file");
1904
1905 let boundary = crate::walk_boundary::DeviceBoundary::from_device_for_test(41);
1906 let mut files = Vec::new();
1907 let mut directories = Vec::new();
1908 let mut walk_truncated = false;
1909 let mut collection_truncated = false;
1910 let mut skipped_foreign_mounts = 0usize;
1911 let mut lookup = |path: &Path| {
1912 Ok(Some(
1913 if path.file_name().is_some_and(|name| name == "foreign") {
1914 99
1915 } else {
1916 41
1917 },
1918 ))
1919 };
1920
1921 collect_outline_files_with_device_lookup(
1922 &root,
1923 &mut files,
1924 &mut directories,
1925 &mut walk_truncated,
1926 &mut collection_truncated,
1927 &mut skipped_foreign_mounts,
1928 None,
1929 &boundary,
1930 &mut lookup,
1931 );
1932
1933 assert_eq!(skipped_foreign_mounts, 1, "foreign mount is disclosed");
1934 assert!(
1935 !walk_truncated,
1936 "a foreign mount is not the file-count fence"
1937 );
1938 assert!(
1939 !collection_truncated,
1940 "a known foreign mount is not an I/O failure"
1941 );
1942 let keep = Path::new("local").join("keep.rs");
1946 let skip = Path::new("foreign").join("skip.rs");
1947 assert!(files.iter().any(|path| Path::new(path).ends_with(&keep)));
1948 assert!(
1949 !files.iter().any(|path| Path::new(path).ends_with(&skip)),
1950 "foreign-mount contents must not be traversed"
1951 );
1952 }
1953
1954 fn make_symbol(
1955 name: &str,
1956 kind: SymbolKind,
1957 parent: Option<&str>,
1958 scope_chain: Vec<&str>,
1959 exported: bool,
1960 ) -> Symbol {
1961 Symbol {
1962 name: name.to_string(),
1963 kind,
1964 range: Range {
1965 start_line: 0,
1966 start_col: 0,
1967 end_line: 0,
1968 end_col: 0,
1969 },
1970 signature: None,
1971 scope_chain: scope_chain.into_iter().map(String::from).collect(),
1972 exported,
1973 parent: parent.map(String::from),
1974 }
1975 }
1976
1977 fn build_outline_tree_reference(symbols: &[Symbol]) -> Vec<OutlineEntry> {
1978 let mut top_level = Vec::new();
1979 let mut children = Vec::new();
1980
1981 for sym in symbols {
1982 if sym.parent.is_none() {
1983 top_level.push(symbol_to_entry(sym));
1984 } else {
1985 children.push(sym);
1986 }
1987 }
1988
1989 for child in children {
1990 let entry = symbol_to_entry(child);
1991 let scope = &child.scope_chain;
1992 if scope.is_empty() {
1993 top_level.push(entry);
1994 continue;
1995 }
1996 if !insert_at_scope_reference(&mut top_level, scope, entry.clone()) {
1997 let parent_scope = child.parent.as_ref().map(std::slice::from_ref);
1998 if !parent_scope.is_some_and(|scope| {
1999 insert_at_scope_reference(&mut top_level, scope, entry.clone())
2000 }) {
2001 top_level.push(entry);
2002 }
2003 }
2004 }
2005
2006 top_level
2007 }
2008
2009 fn insert_at_scope_reference(
2011 entries: &mut Vec<OutlineEntry>,
2012 scope_chain: &[String],
2013 entry: OutlineEntry,
2014 ) -> bool {
2015 if scope_chain.is_empty() {
2016 return false;
2017 }
2018
2019 let target_name = &scope_chain[0];
2020 for existing in entries {
2021 if existing.name == *target_name {
2022 if scope_chain.len() == 1 {
2023 existing.members.push(entry);
2024 return true;
2025 }
2026 return insert_at_scope_reference(&mut existing.members, &scope_chain[1..], entry);
2027 }
2028 }
2029 false
2030 }
2031
2032 fn assert_indexed_matches_reference(case: &str, symbols: &[Symbol]) -> Vec<OutlineEntry> {
2033 let actual = build_outline_tree(symbols);
2034 let expected = build_outline_tree_reference(symbols);
2035 assert_eq!(
2036 serde_json::to_vec(&actual).expect("serialize indexed outline"),
2037 serde_json::to_vec(&expected).expect("serialize reference outline"),
2038 "indexed outline diverged for {case}"
2039 );
2040 actual
2041 }
2042
2043 fn parsed_symbols(extension: &str, source: &str) -> Vec<Symbol> {
2044 use crate::parser::FileParser;
2045
2046 let dir = tempfile::tempdir().expect("tempdir");
2047 let path = dir.path().join(format!("fixture.{extension}"));
2048 std::fs::write(&path, source).expect("write parser fixture");
2049 FileParser::new()
2050 .extract_symbols(&path)
2051 .expect("extract fixture symbols")
2052 }
2053
2054 #[test]
2055 fn indexed_outline_matches_reference_for_first_match_and_insertion_order() {
2056 let mut symbols = vec![make_symbol(
2057 "Duplicate",
2058 SymbolKind::Class,
2059 None,
2060 vec![],
2061 false,
2062 )];
2063 for index in 0..OUTLINE_SCOPE_INDEX_THRESHOLD {
2064 symbols.push(make_symbol(
2065 &format!("Filler{index}"),
2066 SymbolKind::Class,
2067 None,
2068 vec![],
2069 false,
2070 ));
2071 }
2072 symbols.extend([
2073 make_symbol("Duplicate", SymbolKind::Class, None, vec![], true),
2074 make_symbol(
2075 "firstChild",
2076 SymbolKind::Method,
2077 Some("Duplicate"),
2078 vec!["Duplicate"],
2079 false,
2080 ),
2081 make_symbol(
2082 "secondChild",
2083 SymbolKind::Method,
2084 Some("Duplicate"),
2085 vec!["Duplicate"],
2086 false,
2087 ),
2088 ]);
2089
2090 let tree = assert_indexed_matches_reference("duplicate siblings", &symbols);
2091 let duplicates = tree
2092 .iter()
2093 .filter(|entry| entry.name == "Duplicate")
2094 .collect::<Vec<_>>();
2095 assert_eq!(
2096 duplicates[0]
2097 .members
2098 .iter()
2099 .map(|entry| entry.name.as_str())
2100 .collect::<Vec<_>>(),
2101 ["firstChild", "secondChild"]
2102 );
2103 assert!(
2104 duplicates[1].members.is_empty(),
2105 "second duplicate stays unused"
2106 );
2107 }
2108
2109 #[test]
2110 fn indexed_outline_matches_reference_for_dynamic_deep_parents() {
2111 let symbols = vec![
2112 make_symbol("Outer", SymbolKind::Class, None, vec![], false),
2113 make_symbol(
2116 "earlyLeaf",
2117 SymbolKind::Method,
2118 Some("Inner"),
2119 vec!["Outer", "Inner"],
2120 false,
2121 ),
2122 make_symbol(
2123 "Inner",
2124 SymbolKind::Class,
2125 Some("Outer"),
2126 vec!["Outer"],
2127 false,
2128 ),
2129 make_symbol(
2130 "lateLeaf",
2131 SymbolKind::Method,
2132 Some("Inner"),
2133 vec!["Outer", "Inner"],
2134 false,
2135 ),
2136 make_symbol(
2137 "Deep",
2138 SymbolKind::Class,
2139 Some("Inner"),
2140 vec!["Outer", "Inner"],
2141 false,
2142 ),
2143 make_symbol(
2144 "deepLeaf",
2145 SymbolKind::Method,
2146 Some("Deep"),
2147 vec!["Outer", "Inner", "Deep"],
2148 false,
2149 ),
2150 ];
2151
2152 let tree = assert_indexed_matches_reference("deep dynamic parents", &symbols);
2153 assert_eq!(tree[1].name, "earlyLeaf");
2154 let inner = &tree[0].members[0];
2155 assert_eq!(inner.name, "Inner");
2156 assert_eq!(inner.members[0].name, "lateLeaf");
2157 assert_eq!(inner.members[1].members[0].name, "deepLeaf");
2158 }
2159
2160 #[test]
2161 fn indexed_outline_matches_reference_for_fallbacks_and_orphans() {
2162 let symbols = vec![
2163 make_symbol("Widget", SymbolKind::Struct, None, vec![], true),
2164 make_symbol(
2165 "fmt",
2166 SymbolKind::Method,
2167 Some("Widget"),
2168 vec!["Display for Widget"],
2169 true,
2170 ),
2171 make_symbol(
2172 "Orphan",
2173 SymbolKind::Class,
2174 Some("Missing"),
2175 vec!["Missing"],
2176 false,
2177 ),
2178 make_symbol(
2179 "adoptedLater",
2180 SymbolKind::Method,
2181 Some("Orphan"),
2182 vec!["Orphan"],
2183 false,
2184 ),
2185 ];
2186
2187 let tree = assert_indexed_matches_reference("fallback and orphan ladder", &symbols);
2188 assert_eq!(tree[0].members[0].name, "fmt");
2189 assert_eq!(tree[1].name, "Orphan");
2190 assert_eq!(tree[1].members[0].name, "adoptedLater");
2191 }
2192
2193 #[test]
2194 fn indexed_outline_matches_reference_at_scale() {
2195 const PARENTS: usize = 2_048;
2196 let mut symbols = Vec::with_capacity(PARENTS * 2);
2197 for index in 0..PARENTS {
2198 symbols.push(make_symbol(
2199 &format!("Container{index:04}"),
2200 SymbolKind::Class,
2201 None,
2202 vec![],
2203 true,
2204 ));
2205 }
2206 for index in 0..PARENTS {
2207 let parent = format!("Container{index:04}");
2208 symbols.push(make_symbol(
2209 &format!("method{index:04}"),
2210 SymbolKind::Method,
2211 Some(&parent),
2212 vec![&parent],
2213 false,
2214 ));
2215 }
2216
2217 assert_indexed_matches_reference("one child per parent at scale", &symbols);
2218 }
2219
2220 #[test]
2221 fn indexed_outline_matches_reference_for_typescript_and_python() {
2222 let typescript = parsed_symbols(
2223 "ts",
2224 "class Outer {\n method(): void {}\n classField = 1;\n}\n",
2225 );
2226 assert!(
2227 typescript.iter().any(|symbol| symbol.parent.is_some()),
2228 "TypeScript fixture must exercise child insertion"
2229 );
2230 assert_indexed_matches_reference("TypeScript parser output", &typescript);
2231
2232 let python = parsed_symbols(
2233 "py",
2234 "class Outer:\n class Inner:\n def leaf(self):\n pass\n\n def outer(self):\n pass\n",
2235 );
2236 assert!(
2237 python.iter().any(|symbol| symbol.parent.is_some()),
2238 "Python fixture must exercise child insertion"
2239 );
2240 assert_indexed_matches_reference("Python parser output", &python);
2241 }
2242
2243 #[test]
2244 fn flat_symbols_stay_flat() {
2245 let symbols = vec![
2246 make_symbol("greet", SymbolKind::Function, None, vec![], true),
2247 make_symbol("Config", SymbolKind::Interface, None, vec![], true),
2248 ];
2249 let tree = build_outline_tree(&symbols);
2250 assert_eq!(tree.len(), 2);
2251 assert!(tree[0].members.is_empty());
2252 assert!(tree[1].members.is_empty());
2253 }
2254
2255 #[test]
2256 fn methods_nest_under_class() {
2257 let symbols = vec![
2258 make_symbol("UserService", SymbolKind::Class, None, vec![], true),
2259 make_symbol(
2260 "getUser",
2261 SymbolKind::Method,
2262 Some("UserService"),
2263 vec!["UserService"],
2264 false,
2265 ),
2266 make_symbol(
2267 "addUser",
2268 SymbolKind::Method,
2269 Some("UserService"),
2270 vec!["UserService"],
2271 false,
2272 ),
2273 ];
2274 let tree = build_outline_tree(&symbols);
2275 assert_eq!(tree.len(), 1, "methods should not appear at top level");
2276 assert_eq!(tree[0].name, "UserService");
2277 assert_eq!(tree[0].members.len(), 2);
2278 assert_eq!(tree[0].members[0].name, "getUser");
2279 assert_eq!(tree[0].members[1].name, "addUser");
2280 }
2281
2282 #[test]
2283 fn parent_fallback_nests_trait_impl_methods_under_type() {
2284 let symbols = vec![
2285 make_symbol("Widget", SymbolKind::Struct, None, vec![], true),
2286 make_symbol(
2287 "fmt",
2288 SymbolKind::Method,
2289 Some("Widget"),
2290 vec!["Display for Widget"],
2291 true,
2292 ),
2293 ];
2294 let tree = build_outline_tree(&symbols);
2295 assert_eq!(
2296 tree.len(),
2297 1,
2298 "trait impl method should nest under parent type"
2299 );
2300 assert_eq!(tree[0].name, "Widget");
2301 assert_eq!(tree[0].members.len(), 1);
2302 assert_eq!(tree[0].members[0].name, "fmt");
2303 }
2304
2305 #[test]
2306 fn methods_not_duplicated_at_top_level() {
2307 let symbols = vec![
2308 make_symbol("Foo", SymbolKind::Class, None, vec![], false),
2309 make_symbol("bar", SymbolKind::Method, Some("Foo"), vec!["Foo"], false),
2310 ];
2311 let tree = build_outline_tree(&symbols);
2312 assert!(
2314 tree.iter().all(|e| e.name != "bar"),
2315 "method should not be at top level"
2316 );
2317 assert_eq!(tree[0].members.len(), 1);
2318 }
2319
2320 #[test]
2321 fn multi_level_nesting_python() {
2322 let symbols = vec![
2324 make_symbol("OuterClass", SymbolKind::Class, None, vec![], false),
2325 make_symbol(
2326 "InnerClass",
2327 SymbolKind::Class,
2328 Some("OuterClass"),
2329 vec!["OuterClass"],
2330 false,
2331 ),
2332 make_symbol(
2333 "inner_method",
2334 SymbolKind::Method,
2335 Some("InnerClass"),
2336 vec!["OuterClass", "InnerClass"],
2337 false,
2338 ),
2339 make_symbol(
2340 "outer_method",
2341 SymbolKind::Method,
2342 Some("OuterClass"),
2343 vec!["OuterClass"],
2344 false,
2345 ),
2346 ];
2347 let tree = build_outline_tree(&symbols);
2348 assert_eq!(tree.len(), 1, "only OuterClass at top level");
2349
2350 let outer = &tree[0];
2351 assert_eq!(outer.name, "OuterClass");
2352 assert_eq!(outer.members.len(), 2, "InnerClass + outer_method");
2353
2354 let inner = outer
2355 .members
2356 .iter()
2357 .find(|m| m.name == "InnerClass")
2358 .unwrap();
2359 assert_eq!(inner.members.len(), 1);
2360 assert_eq!(inner.members[0].name, "inner_method");
2361 }
2362
2363 #[test]
2364 fn all_symbol_kinds_handled() {
2365 let symbols = vec![
2366 make_symbol("f", SymbolKind::Function, None, vec![], false),
2367 make_symbol("C", SymbolKind::Class, None, vec![], false),
2368 make_symbol("m", SymbolKind::Method, Some("C"), vec!["C"], false),
2369 make_symbol("S", SymbolKind::Struct, None, vec![], false),
2370 make_symbol("I", SymbolKind::Interface, None, vec![], false),
2371 make_symbol("E", SymbolKind::Enum, None, vec![], false),
2372 make_symbol("T", SymbolKind::TypeAlias, None, vec![], false),
2373 ];
2374 let tree = build_outline_tree(&symbols);
2375
2376 assert_eq!(tree.len(), 6);
2378
2379 let kinds: Vec<&str> = tree.iter().map(|e| e.kind.as_str()).collect();
2380 assert!(kinds.contains(&"function"));
2381 assert!(kinds.contains(&"class"));
2382 assert!(kinds.contains(&"struct"));
2383 assert!(kinds.contains(&"interface"));
2384 assert!(kinds.contains(&"enum"));
2385 assert!(kinds.contains(&"type_alias"));
2386
2387 let class_entry = tree.iter().find(|e| e.name == "C").unwrap();
2389 assert_eq!(class_entry.members.len(), 1);
2390 assert_eq!(class_entry.members[0].kind, "method");
2391 }
2392
2393 #[test]
2394 fn exported_flag_preserved() {
2395 let symbols = vec![
2396 make_symbol("exported_fn", SymbolKind::Function, None, vec![], true),
2397 make_symbol("internal_fn", SymbolKind::Function, None, vec![], false),
2398 ];
2399 let tree = build_outline_tree(&symbols);
2400 let exported = tree.iter().find(|e| e.name == "exported_fn").unwrap();
2401 let internal = tree.iter().find(|e| e.name == "internal_fn").unwrap();
2402 assert!(exported.exported);
2403 assert!(!internal.exported);
2404 }
2405
2406 #[test]
2407 fn orphan_child_promoted_to_top_level() {
2408 let symbols = vec![make_symbol(
2410 "orphan",
2411 SymbolKind::Method,
2412 Some("MissingParent"),
2413 vec!["MissingParent"],
2414 false,
2415 )];
2416 let tree = build_outline_tree(&symbols);
2417 assert_eq!(tree.len(), 1, "orphan should be promoted to top level");
2418 assert_eq!(tree[0].name, "orphan");
2419 }
2420
2421 fn sig_entry(
2422 name: &str,
2423 kind: &str,
2424 signature: Option<&str>,
2425 exported: bool,
2426 start_line: u32,
2427 end_line: u32,
2428 ) -> OutlineEntry {
2429 OutlineEntry {
2430 name: name.to_string(),
2431 kind: kind.to_string(),
2432 range: Range {
2433 start_line,
2434 start_col: 0,
2435 end_line,
2436 end_col: 0,
2437 },
2438 signature: signature.map(String::from),
2439 exported,
2440 members: Vec::new(),
2441 }
2442 }
2443
2444 #[test]
2445 fn signature_lines_drop_the_redundant_vis_kind_prefix() {
2446 assert_eq!(
2448 format_entry_with_sig(&sig_entry(
2449 "resolve",
2450 "function",
2451 Some("pub fn resolve(id: u64) -> Result<()>"),
2452 true,
2453 9,
2454 20,
2455 )),
2456 "pub fn resolve(id: u64) -> Result<()> 10:21"
2457 );
2458 assert_eq!(
2460 format_entry_with_sig(&sig_entry(
2461 "helper",
2462 "function",
2463 Some("fn helper()"),
2464 false,
2465 0,
2466 2
2467 )),
2468 "fn helper() 1:3"
2469 );
2470 assert_eq!(
2472 format_entry_with_sig(&sig_entry(
2473 "compute",
2474 "function",
2475 Some("def compute(value):"),
2476 false,
2477 4,
2478 7,
2479 )),
2480 "def compute(value): 5:8"
2481 );
2482 }
2483
2484 #[test]
2485 fn exported_without_visibility_keyword_keeps_a_minimal_marker() {
2486 assert_eq!(
2490 format_entry_with_sig(&sig_entry(
2491 "greet",
2492 "function",
2493 Some("function greet(name: string): string"),
2494 true,
2495 0,
2496 2,
2497 )),
2498 "E function greet(name: string): string 1:3"
2499 );
2500 assert_eq!(
2502 format_entry_with_sig(&sig_entry(
2503 "Parse",
2504 "function",
2505 Some("func Parse(input string) (*Tree, error)"),
2506 true,
2507 0,
2508 5,
2509 )),
2510 "E func Parse(input string) (*Tree, error) 1:6"
2511 );
2512 assert_eq!(
2515 format_entry_with_sig(&sig_entry(
2516 "run",
2517 "method",
2518 Some("public void run()"),
2519 true,
2520 0,
2521 1
2522 )),
2523 "public void run() 1:2"
2524 );
2525 }
2526
2527 #[test]
2528 fn no_signature_fallback_keeps_the_vis_kind_prefix() {
2529 assert_eq!(
2532 format_entry_with_sig(&sig_entry("answer", "variable", None, true, 0, 0)),
2533 "E var answer 1:1"
2534 );
2535 assert_eq!(
2536 format_entry_with_sig(&sig_entry("local", "variable", None, false, 1, 1)),
2537 "- var local 2:2"
2538 );
2539 }
2540
2541 #[test]
2542 fn dropping_the_prefix_removes_exactly_the_prefix_bytes() {
2543 let entry = sig_entry(
2547 "resolve",
2548 "function",
2549 Some("pub fn resolve(id: u64)"),
2550 true,
2551 9,
2552 20,
2553 );
2554 let new = format_entry_with_sig(&entry);
2555 let old = format!("E {:<4} {} {}:{}", "fn", "pub fn resolve(id: u64)", 10, 21);
2556 assert_eq!(old, "E fn pub fn resolve(id: u64) 10:21");
2557 assert_eq!(new, "pub fn resolve(id: u64) 10:21");
2558 assert_eq!(old.len() - new.len(), "E fn ".len());
2561 assert!(
2562 old.ends_with(&new),
2563 "only the prefix may change: {old:?} -> {new:?}"
2564 );
2565 }
2566
2567 #[test]
2568 fn signature_visibility_detection() {
2569 assert!(signature_has_visibility("pub fn f()"));
2570 assert!(signature_has_visibility("pub(crate) fn f()"));
2571 assert!(signature_has_visibility("public void f()"));
2572 assert!(signature_has_visibility("export function f()"));
2573 assert!(signature_has_visibility("external function f()"));
2574 assert!(!signature_has_visibility("fn publish()"));
2576 assert!(!signature_has_visibility(
2577 "function greet(name: string): string"
2578 ));
2579 assert!(!signature_has_visibility("def compute(value):"));
2580 assert!(!signature_has_visibility("func Parse(input string)"));
2581 }
2582
2583 fn outline_file_entry_for_test(
2584 path: &str,
2585 language: &str,
2586 symbols: usize,
2587 lines: Option<usize>,
2588 data_doc: bool,
2589 ) -> OutlineFileEntry {
2590 OutlineFileEntry {
2591 path: path.to_string(),
2592 language: language.to_string(),
2593 symbols: Some(symbols),
2594 lines,
2595 absolute_path: PathBuf::from(path),
2596 data_doc,
2597 }
2598 }
2599
2600 #[test]
2601 fn outline_file_line_count_matches_text_and_binary_contract() {
2602 let temp = tempfile::tempdir().expect("tempdir");
2603 let terminated = temp.path().join("terminated.txt");
2604 let unterminated = temp.path().join("unterminated.txt");
2605 let empty = temp.path().join("empty.txt");
2606 let binary = temp.path().join("binary.dat");
2607 std::fs::write(&terminated, b"a\nb\nc\n").expect("write terminated");
2608 std::fs::write(&unterminated, b"a\nb\nc").expect("write unterminated");
2609 std::fs::write(&empty, b"").expect("write empty");
2610 std::fs::write(&binary, [0, 159, 146, 150, 0, 1]).expect("write binary");
2611
2612 assert_eq!(
2613 inspect_outline_file_content(&terminated)
2614 .expect("inspect terminated")
2615 .lines,
2616 Some(3)
2617 );
2618 assert_eq!(
2619 inspect_outline_file_content(&unterminated)
2620 .expect("inspect unterminated")
2621 .lines,
2622 Some(3)
2623 );
2624 assert_eq!(
2625 inspect_outline_file_content(&empty)
2626 .expect("inspect empty")
2627 .lines,
2628 Some(0)
2629 );
2630 let binary_stats = inspect_outline_file_content(&binary).expect("inspect binary");
2631 assert!(binary_stats.binary);
2632 assert_eq!(binary_stats.lines, None);
2633 }
2634
2635 #[test]
2636 fn outline_rows_put_code_before_data_files() {
2637 let files = vec![
2638 outline_file_entry_for_test("docs/readme.md", "markdown", 1, Some(4), true),
2639 outline_file_entry_for_test("docs/lib.rs", "rust", 2, Some(8), false),
2640 ];
2641 let mut directories = vec![OutlineDirectoryNode {
2642 path: String::new(),
2643 depth: 0,
2644 direct_files: vec![0, 1],
2645 children: Vec::new(),
2646 stats: OutlineDirectoryStats::default(),
2647 }];
2648 aggregate_outline_directory(0, &mut directories, &files);
2649
2650 assert_eq!(
2651 plan_outline_file_rows(&[0], &directories, &files, 30 * 1024),
2652 vec![OutlineTableRow::File(1), OutlineTableRow::File(0)]
2653 );
2654 }
2655
2656 #[test]
2657 fn data_only_leaf_stays_one_rollup_with_summed_lines() {
2658 let files = (0..3)
2659 .map(|index| {
2660 outline_file_entry_for_test(
2661 &format!("schema/json/{index}.json"),
2662 "json",
2663 0,
2664 Some(2),
2665 true,
2666 )
2667 })
2668 .collect::<Vec<_>>();
2669 let mut directories = vec![
2670 OutlineDirectoryNode {
2671 path: String::new(),
2672 depth: 0,
2673 direct_files: Vec::new(),
2674 children: vec![1],
2675 stats: OutlineDirectoryStats::default(),
2676 },
2677 OutlineDirectoryNode {
2678 path: "schema".to_string(),
2679 depth: 1,
2680 direct_files: Vec::new(),
2681 children: vec![2],
2682 stats: OutlineDirectoryStats::default(),
2683 },
2684 OutlineDirectoryNode {
2685 path: "schema/json".to_string(),
2686 depth: 2,
2687 direct_files: vec![0, 1, 2],
2688 children: Vec::new(),
2689 stats: OutlineDirectoryStats::default(),
2690 },
2691 ];
2692 aggregate_outline_directory(0, &mut directories, &files);
2693
2694 let rows = plan_outline_file_rows(&[0], &directories, &files, 30 * 1024);
2695 assert_eq!(rows, vec![OutlineTableRow::Rollup(2)]);
2696 let text = format_files_table(&rows, &directories, &files, 30 * 1024);
2697 assert!(text.contains("schema/json/"));
2698 assert!(text.contains("3 files"));
2699 assert!(text.contains("6 lines"));
2700 assert!(!text.contains("syms"), "rollup row: {text}");
2701 assert!(!text.contains("shown as a rollup"));
2702 assert!(!text.contains(".json "));
2703 }
2704
2705 #[test]
2706 fn cheapest_same_level_expansion_buys_breadth_before_large_directory() {
2707 let files = vec![
2708 outline_file_entry_for_test(
2709 "z-small/src/long-breadth-marker/lib.rs",
2710 "rust",
2711 1,
2712 Some(1),
2713 false,
2714 ),
2715 outline_file_entry_for_test("a-large/a.rs", "rust", 1, Some(1), false),
2716 outline_file_entry_for_test("a-large/b.rs", "rust", 1, Some(1), false),
2717 outline_file_entry_for_test("docs/readme.md", "markdown", 0, Some(1), true),
2718 ];
2719 let mut directories = vec![
2720 OutlineDirectoryNode {
2721 path: String::new(),
2722 depth: 0,
2723 direct_files: Vec::new(),
2724 children: vec![1, 2, 3],
2725 stats: OutlineDirectoryStats::default(),
2726 },
2727 OutlineDirectoryNode {
2728 path: "z-small".to_string(),
2729 depth: 1,
2730 direct_files: Vec::new(),
2731 children: vec![4],
2732 stats: OutlineDirectoryStats::default(),
2733 },
2734 OutlineDirectoryNode {
2735 path: "a-large".to_string(),
2736 depth: 1,
2737 direct_files: vec![1, 2],
2738 children: Vec::new(),
2739 stats: OutlineDirectoryStats::default(),
2740 },
2741 OutlineDirectoryNode {
2742 path: "docs".to_string(),
2743 depth: 1,
2744 direct_files: vec![3],
2745 children: Vec::new(),
2746 stats: OutlineDirectoryStats::default(),
2747 },
2748 OutlineDirectoryNode {
2749 path: "z-small/src/long-breadth-marker".to_string(),
2750 depth: 2,
2751 direct_files: vec![0],
2752 children: Vec::new(),
2753 stats: OutlineDirectoryStats::default(),
2754 },
2755 ];
2756 aggregate_outline_directory(0, &mut directories, &files);
2757 let small_only = vec![
2758 OutlineTableRow::Rollup(2),
2759 OutlineTableRow::Rollup(3),
2760 OutlineTableRow::Rollup(4),
2761 ];
2762 let large_only = vec![
2763 OutlineTableRow::File(1),
2764 OutlineTableRow::File(2),
2765 OutlineTableRow::Rollup(3),
2766 OutlineTableRow::Rollup(1),
2767 ];
2768 let both = vec![
2769 OutlineTableRow::File(1),
2770 OutlineTableRow::File(2),
2771 OutlineTableRow::Rollup(3),
2772 OutlineTableRow::Rollup(4),
2773 ];
2774 let mut budget = 0;
2775 for _ in 0..8 {
2776 let required = format_files_table(&small_only, &directories, &files, budget)
2777 .len()
2778 .max(format_files_table(&large_only, &directories, &files, budget).len());
2779 if required == budget {
2780 break;
2781 }
2782 budget = required;
2783 }
2784 assert!(format_files_table(&small_only, &directories, &files, budget).len() <= budget);
2785 assert!(format_files_table(&large_only, &directories, &files, budget).len() <= budget);
2786 assert!(format_files_table(&both, &directories, &files, budget).len() > budget);
2787
2788 assert_eq!(
2789 plan_outline_file_rows(&[0], &directories, &files, budget),
2790 small_only
2791 );
2792 }
2793
2794 #[test]
2795 fn top_level_rows_are_never_cut_by_the_budget() {
2796 let files = vec![
2797 outline_file_entry_for_test("a/lib.rs", "rust", 1, Some(1), false),
2798 outline_file_entry_for_test("b/lib.rs", "rust", 1, Some(1), false),
2799 ];
2800 let mut directories = vec![
2801 OutlineDirectoryNode {
2802 path: String::new(),
2803 depth: 0,
2804 direct_files: Vec::new(),
2805 children: vec![1, 2],
2806 stats: OutlineDirectoryStats::default(),
2807 },
2808 OutlineDirectoryNode {
2809 path: "a".to_string(),
2810 depth: 1,
2811 direct_files: vec![0],
2812 children: Vec::new(),
2813 stats: OutlineDirectoryStats::default(),
2814 },
2815 OutlineDirectoryNode {
2816 path: "b".to_string(),
2817 depth: 1,
2818 direct_files: vec![1],
2819 children: Vec::new(),
2820 stats: OutlineDirectoryStats::default(),
2821 },
2822 ];
2823 aggregate_outline_directory(0, &mut directories, &files);
2824
2825 let rows = plan_outline_file_rows(&[0], &directories, &files, 1);
2826 assert_eq!(
2827 rows,
2828 vec![OutlineTableRow::Rollup(1), OutlineTableRow::Rollup(2)]
2829 );
2830 let text = format_files_table(&rows, &directories, &files, 1);
2831 assert!(text.contains("a/"));
2832 assert!(text.contains("b/"));
2833 assert!(
2834 text.len() > 1,
2835 "level zero deliberately exceeds a tiny budget"
2836 );
2837 }
2838
2839 #[test]
2840 fn binary_file_row_uses_a_dash_for_lines() {
2841 let files = vec![outline_file_entry_for_test(
2842 "assets/blob.dat",
2843 "binary",
2844 0,
2845 None,
2846 true,
2847 )];
2848 let text = format_files_table(&[OutlineTableRow::File(0)], &[], &files, 30 * 1024);
2849 assert!(text.contains(" - lines"), "binary row: {text}");
2850 }
2851
2852 #[test]
2855 #[ignore = "manual release-mode outline files performance probe"]
2856 fn outline_files_walk_perf_probe() {
2857 const DIRECTORIES: usize = 100;
2858 const FILES_PER_DIRECTORY: usize = 100;
2859 const SAMPLES: usize = 9;
2860 const ITERATIONS: usize = 3;
2861
2862 let temp = tempfile::tempdir().expect("tempdir");
2863 for directory in 0..DIRECTORIES {
2864 let path = temp.path().join(format!("package-{directory:03}/src"));
2865 std::fs::create_dir_all(&path).expect("create package directory");
2866 for file in 0..FILES_PER_DIRECTORY {
2867 std::fs::write(
2868 path.join(format!("module-{file:03}.ts")),
2869 b"export const value = 1;\n",
2870 )
2871 .expect("write source fixture");
2872 }
2873 }
2874
2875 let discovery = discover_outline_files(temp.path());
2876 assert_eq!(discovery.files.len(), OUTLINE_FILE_WALK_CAP);
2877 assert!(discovery.walk_truncated);
2878
2879 let mut micros_per_operation = Vec::with_capacity(SAMPLES);
2880 for _ in 0..SAMPLES {
2881 let started = std::time::Instant::now();
2882 for _ in 0..ITERATIONS {
2883 let discovery = discover_outline_files(std::hint::black_box(temp.path()));
2884 std::hint::black_box(discovery);
2885 }
2886 micros_per_operation.push(started.elapsed().as_micros() / ITERATIONS as u128);
2887 }
2888 micros_per_operation.sort_unstable();
2889 let median = micros_per_operation[SAMPLES / 2];
2890
2891 eprintln!(
2892 "outline files walk: files={} samples={SAMPLES} iterations={ITERATIONS}",
2893 DIRECTORIES * FILES_PER_DIRECTORY
2894 );
2895 eprintln!("microseconds per outline operation: {micros_per_operation:?}");
2896 eprintln!("median: {median}us per outline operation");
2897 }
2898}