pub mod imports;
pub mod outline;
use std::fmt::Write as _;
use std::fs;
use std::path::Path;
use memmap2::Mmap;
use crate::cache::OutlineCache;
use crate::error::SrcwalkError;
use crate::format;
use crate::lang::detect_file_type;
use crate::lang::outline::get_outline_entries as lang_get_outline_entries;
use crate::types::{estimate_tokens, FileType, OutlineEntry, ViewMode};
pub(crate) const RAW_TOKEN_CAP: u64 = 5_000;
const RAW_LINE_CAP: u32 = 200;
const FILE_SIZE_CAP: u64 = 500_000;
fn section_token_limit() -> u64 {
std::env::var("SRCWALK_SECTION_SOFT_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(RAW_TOKEN_CAP)
}
fn raw_body_over_cap(tokens: u64, lines: u32) -> bool {
tokens > RAW_TOKEN_CAP || lines > RAW_LINE_CAP
}
fn capped_line_end(buf: &[u8], start_byte: usize, max_lines: u32, max_tokens: u64) -> (usize, u32) {
let max_bytes = (max_tokens * 4) as usize;
let mut end = start_byte;
let mut lines = 0u32;
while end < buf.len() && lines < max_lines && end.saturating_sub(start_byte) < max_bytes {
if let Some(rel) = memchr::memchr(b'\n', &buf[end..]) {
let next = end + rel + 1;
if next.saturating_sub(start_byte) > max_bytes {
break;
}
end = next;
lines += 1;
} else {
let next = buf.len().min(start_byte + max_bytes);
if next > end {
end = next;
lines += 1;
}
break;
}
}
if end == start_byte && start_byte < buf.len() {
end = buf.len().min(start_byte + max_bytes.max(1));
lines = 1;
}
(end, lines)
}
pub fn read_file(
path: &Path,
section: Option<&str>,
full: bool,
cache: &OutlineCache,
) -> Result<String, SrcwalkError> {
let meta = match fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(SrcwalkError::NotFound {
path: path.to_path_buf(),
suggestion: suggest_similar(path),
});
}
Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
return Err(SrcwalkError::PermissionDenied {
path: path.to_path_buf(),
});
}
Err(e) => {
return Err(SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
});
}
};
if meta.is_dir() {
return list_directory(path);
}
let byte_len = meta.len();
if byte_len == 0 {
return Ok(format::file_header(path, 0, 0, ViewMode::Empty));
}
if let Some(range) = section {
return read_section(path, range, cache);
}
let file = fs::File::open(path).map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let mmap = unsafe { Mmap::map(&file) }.map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let buf = &mmap[..];
if crate::lang::detection::is_binary(buf) {
let mime = mime_from_ext(path);
return Ok(format::binary_header(path, byte_len, mime));
}
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if crate::lang::detection::is_generated_by_name(name)
|| crate::lang::detection::is_generated_by_content(buf)
{
let line_count = memchr::memchr_iter(b'\n', buf).count() as u32 + 1;
return Ok(format::file_header(
path,
byte_len,
line_count,
ViewMode::Generated,
));
}
let tokens = estimate_tokens(byte_len);
let content = String::from_utf8_lossy(buf);
let line_count = memchr::memchr_iter(b'\n', buf).count() as u32 + 1;
let file_type = detect_file_type(path);
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
if full {
if !raw_body_over_cap(tokens, line_count) {
let header = format::file_header(path, byte_len, line_count, ViewMode::Full);
let numbered = format::number_lines(&content, 1);
return Ok(format!("{header}\n\n{numbered}"));
}
let (head_end, shown) = capped_line_end(buf, 0, RAW_LINE_CAP, RAW_TOKEN_CAP);
let head = String::from_utf8_lossy(&buf[..head_end]);
let numbered_head = format::number_lines(&head, 1);
let outline = cache.get_or_compute(path, mtime, || {
outline::generate(path, file_type, &content, buf, true)
});
let header = format::file_header(path, byte_len, line_count, ViewMode::Full);
let next_start = shown + 1;
return Ok(format!(
"{header}\n\n> **full=true capped**: raw body exceeds {RAW_TOKEN_CAP} tokens or {RAW_LINE_CAP} lines. Showing first {shown} of {line_count} lines.\n\n{numbered_head}\n\n## Outline\n\n{outline}\n\n> Tip: continue with --section {next_start}-<end> or use a narrower --section range."
));
}
let capped = byte_len > FILE_SIZE_CAP;
let outline = cache.get_or_compute(path, mtime, || {
outline::generate(path, file_type, &content, buf, capped)
});
let mode = match file_type {
FileType::StructuredData => ViewMode::Keys,
_ => ViewMode::Outline,
};
let header = format::file_header(path, byte_len, line_count, mode);
Ok(format!(
"{header}\n\n{outline}\n\n> Tip: drill into a symbol with --section <name> or a line range"
))
}
pub fn would_outline(path: &Path) -> bool {
std::fs::metadata(path).is_ok_and(|m| !m.is_dir() && m.len() > 0)
}
pub fn read_file_with_budget(
path: &Path,
section: Option<&str>,
full: bool,
budget: Option<u64>,
cache: &OutlineCache,
) -> Result<String, SrcwalkError> {
let Some(b) = budget else {
return read_file(path, section, full, cache);
};
if !full || section.is_some() {
return read_file(path, section, full, cache);
}
let full_out = read_file(path, section, full, cache)?;
if estimate_tokens(full_out.len() as u64) <= b {
return Ok(full_out);
}
let outline_out = render_outline_view(path, cache, ViewMode::OutlineCascade)?;
let with_note = append_cascade_note(&outline_out, "full body", full_out.len(), b);
if estimate_tokens(with_note.len() as u64) <= b {
return Ok(with_note);
}
let sig_out = render_signatures_view(path, cache)?;
let sig_with_note = append_cascade_note(&sig_out, "outline", outline_out.len(), b);
if estimate_tokens(sig_with_note.len() as u64) <= b {
return Ok(sig_with_note);
}
let meta = std::fs::metadata(path).map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let line_count =
std::fs::read(path).map_or(0, |buf| memchr::memchr_iter(b'\n', &buf).count() as u32 + 1);
let header = format::file_header(path, meta.len(), line_count, ViewMode::Signatures);
Ok(format!(
"{header}\n\n> File too large for budget {b} tokens at any granularity. \
Drill: `--section <fn-name>` or raise `--budget`."
))
}
fn render_outline_view(
path: &Path,
cache: &OutlineCache,
mode: ViewMode,
) -> Result<String, SrcwalkError> {
let meta = std::fs::metadata(path).map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let buf = std::fs::read(path).map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let content = String::from_utf8_lossy(&buf);
let line_count = memchr::memchr_iter(b'\n', &buf).count() as u32 + 1;
let file_type = detect_file_type(path);
let mtime = meta.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let outline = cache.get_or_compute(path, mtime, || {
outline::generate(path, file_type, &content, &buf, true)
});
let header = format::file_header(path, meta.len(), line_count, mode);
Ok(format!("{header}\n\n{outline}"))
}
fn render_signatures_view(path: &Path, cache: &OutlineCache) -> Result<String, SrcwalkError> {
let outline_full = render_outline_view(path, cache, ViewMode::Signatures)?;
let mut lines = outline_full.lines();
let header = lines.next().unwrap_or("");
let mut kept: Vec<&str> = vec![header];
for line in lines {
if line.is_empty() {
kept.push(line);
continue;
}
let indent = line.chars().take_while(|c| *c == ' ').count();
if indent <= 2 {
kept.push(line);
}
}
Ok(kept.join("\n"))
}
fn append_cascade_note(body: &str, prev_kind: &str, prev_bytes: usize, budget: u64) -> String {
let prev_tokens = estimate_tokens(prev_bytes as u64);
format!(
"{body}\n\n> Note: {prev_kind} ({prev_tokens} tokens) exceeded budget ({budget}). \
Drill: `--section <fn-name>` for specific symbol, or raise `--budget`."
)
}
fn resolve_heading(buf: &[u8], heading: &str) -> Option<(usize, usize)> {
let heading_trimmed = heading.trim_end();
let heading_level = heading_trimmed.chars().take_while(|&c| c == '#').count();
if heading_level == 0 {
return None;
}
let mut line_offsets: Vec<usize> = vec![0];
for pos in memchr::memchr_iter(b'\n', buf) {
line_offsets.push(pos + 1);
}
let total_lines = if buf.last() == Some(&b'\n') {
line_offsets.len() - 1
} else {
line_offsets.len()
};
let mut in_code_block = false;
let mut found_line: Option<usize> = None;
for (line_idx, &offset) in line_offsets.iter().enumerate() {
let line_end = if line_idx + 1 < line_offsets.len() {
line_offsets[line_idx + 1] - 1 } else {
buf.len()
};
if let Ok(line_str) = std::str::from_utf8(&buf[offset..line_end]) {
let trimmed = line_str.trim_end();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
let matches = trimmed == heading_trimmed
|| (trimmed.starts_with(heading_trimmed)
&& trimmed[heading_trimmed.len()..]
.chars()
.next()
.is_none_or(|c| matches!(c, ' ' | '\t' | '{' | '#')));
if matches {
found_line = Some(line_idx + 1); break;
}
}
}
let start_line = found_line?;
in_code_block = false;
let start_idx = start_line - 1;
for (line_idx, &offset) in line_offsets.iter().enumerate().skip(start_idx + 1) {
let line_end = if line_idx + 1 < line_offsets.len() {
line_offsets[line_idx + 1] - 1
} else {
buf.len()
};
if let Ok(line_str) = std::str::from_utf8(&buf[offset..line_end]) {
let trimmed = line_str.trim_end();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block {
continue;
}
if trimmed.starts_with('#') {
let level = trimmed.chars().take_while(|&c| c == '#').count();
if level <= heading_level {
return Some((start_line, line_idx));
}
}
}
}
Some((start_line, total_lines))
}
fn suggest_headings(buf: &[u8], query: &str, top_n: usize) -> Vec<String> {
let q = query.trim_end();
let q_text = q.trim_start_matches('#').trim();
if q_text.is_empty() {
return Vec::new();
}
let mut in_code_block = false;
let mut scored: Vec<(usize, String)> = Vec::new();
for line in buf.split(|&b| b == b'\n') {
let Ok(s) = std::str::from_utf8(line) else {
continue;
};
let trimmed = s.trim_end();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
continue;
}
if in_code_block || !trimmed.starts_with('#') {
continue;
}
let h_text = trimmed.trim_start_matches('#').trim();
if h_text.is_empty() {
continue;
}
let h_clean = h_text
.split('{')
.next()
.unwrap_or(h_text)
.trim_end_matches('#')
.trim();
let dist = edit_distance(&q_text.to_ascii_lowercase(), &h_clean.to_ascii_lowercase());
scored.push((dist, trimmed.to_string()));
}
scored.sort_by_key(|(d, _)| *d);
scored.into_iter().take(top_n).map(|(_, h)| h).collect()
}
fn read_section(path: &Path, range: &str, _cache: &OutlineCache) -> Result<String, SrcwalkError> {
let file = fs::File::open(path).map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let mmap = unsafe { Mmap::map(&file) }.map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let buf = &mmap[..];
let mut focus_line = None;
let (start, end) = if range.starts_with('#') {
resolve_heading(buf, range).ok_or_else(|| {
let suggestions = suggest_headings(buf, range, 5);
let reason = if suggestions.is_empty() {
"heading not found in file".to_string()
} else {
format!(
"heading not found in file. Closest matches:\n {}",
suggestions.join("\n ")
)
};
SrcwalkError::InvalidQuery {
query: range.to_string(),
reason,
}
})?
} else if let Some((start, end, focus)) = parse_range(range) {
focus_line = focus;
(start, end)
} else if let Some(r) = resolve_symbol(buf, path, range) {
r
} else {
if range.contains(',') {
return read_multi_symbol_section(path, buf, range);
}
let suggestions = suggest_symbols(buf, path, range, 3);
let reason = if suggestions.is_empty() {
"not a valid line number (e.g. \"45\"), line range (e.g. \"45-89\"), heading (e.g. \"## Foo\"), or symbol name in this file"
.to_string()
} else {
format!("symbol not found. Closest:\n {}", suggestions.join("\n "))
};
return Err(SrcwalkError::InvalidQuery {
query: range.to_string(),
reason,
});
};
let mut line_offsets: Vec<usize> = vec![0];
for pos in memchr::memchr_iter(b'\n', buf) {
line_offsets.push(pos + 1);
}
let total = line_offsets.len();
let s = (start.saturating_sub(1)).min(total);
let e = end.min(total);
if s >= e {
return Err(SrcwalkError::InvalidQuery {
query: range.to_string(),
reason: format!("range out of bounds (file has {total} lines)"),
});
}
let start_byte = line_offsets[s];
let end_byte = if e < line_offsets.len() {
line_offsets[e]
} else {
buf.len()
};
let selected = String::from_utf8_lossy(&buf[start_byte..end_byte]);
let byte_len = selected.len() as u64;
let line_count = (e - s) as u32;
let tok_est = estimate_tokens(byte_len);
let limit = section_token_limit();
if tok_est > limit || line_count > RAW_LINE_CAP {
let file_type = detect_file_type(path);
let content = String::from_utf8_lossy(buf);
let header = format::file_header(path, byte_len, line_count, ViewMode::SectionOutline);
let start32 = start as u32;
let end32 = end as u32;
if let crate::types::FileType::Code(lang) = file_type {
let entries = lang_get_outline_entries(&content, lang);
let filtered = filter_entries_in_range(&entries, start32, end32);
if !filtered.is_empty() {
let body = format_section_outline(&filtered);
return Ok(format!(
"{header}\n\n{body}\n\n\
> Section spans ~{tok_est} tokens / {line_count} lines (limits: {limit} tokens, {RAW_LINE_CAP} lines). Showing outline of {start}-{end}.\n\
> Drill: `--section <fn-name>` for a specific symbol."
));
}
}
return Ok(format!(
"{header}\n\n\
> Section spans ~{tok_est} tokens / {line_count} lines (limits: {limit} tokens, {RAW_LINE_CAP} lines).\n\
> Drill: `--section <fn-name>` for a specific symbol, or use a narrower line range."
));
}
let header = format::file_header(path, byte_len, line_count, ViewMode::Section);
let formatted = if let Some(focus) = focus_line {
format_focused_lines(&selected, start as u32, focus)
} else {
format::number_lines(&selected, start as u32)
};
Ok(format!("{header}\n\n{formatted}"))
}
fn format_focused_lines(content: &str, start: u32, focus_line: usize) -> String {
let lines: Vec<&str> = content.lines().collect();
let last = (start as usize + lines.len()).max(1);
let width = (last.ilog10() + 1).max(4) as usize;
let mut out = String::with_capacity(content.len() + lines.len() * (width + 5));
for (i, line) in lines.iter().enumerate() {
let num = start as usize + i;
let prefix = if num == focus_line { "► " } else { " " };
let _ = writeln!(out, "{prefix}{num:>width$} │ {line}");
}
out
}
fn read_multi_symbol_section(path: &Path, buf: &[u8], range: &str) -> Result<String, SrcwalkError> {
let symbols: Vec<&str> = range
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.collect();
if symbols.is_empty() {
return Err(SrcwalkError::InvalidQuery {
query: range.to_string(),
reason: "empty symbol list".to_string(),
});
}
let mut blocks: Vec<(usize, usize, String)> = Vec::new(); let mut errors: Vec<String> = Vec::new();
for sym in &symbols {
if let Some((start, end)) = resolve_symbol(buf, path, sym) {
blocks.push((start, end, sym.to_string()));
} else {
let suggestions = suggest_symbols(buf, path, sym, 3);
if suggestions.is_empty() {
errors.push(format!("{sym}: not found"));
} else {
errors.push(format!(
"{sym}: not found. Closest:\n {}",
suggestions.join("\n ")
));
}
}
}
if !errors.is_empty() && blocks.is_empty() {
return Err(SrcwalkError::InvalidQuery {
query: range.to_string(),
reason: format!("symbols not found:\n {}", errors.join("\n ")),
});
}
blocks.sort_by_key(|(start, _, _)| *start);
let mut line_offsets: Vec<usize> = vec![0];
for pos in memchr::memchr_iter(b'\n', buf) {
line_offsets.push(pos + 1);
}
let total = line_offsets.len();
let mut parts: Vec<String> = Vec::new();
let mut total_bytes: u64 = 0;
let mut total_lines: u32 = 0;
for (start, end, _name) in &blocks {
let s = (start.saturating_sub(1)).min(total);
let e = (*end).min(total);
if s >= e {
continue;
}
let start_byte = line_offsets[s];
let end_byte = if e < line_offsets.len() {
line_offsets[e]
} else {
buf.len()
};
let selected = String::from_utf8_lossy(&buf[start_byte..end_byte]);
total_bytes += selected.len() as u64;
total_lines += (e - s) as u32;
parts.push(format::number_lines(&selected, *start as u32));
}
let tok_est = estimate_tokens(total_bytes);
let limit = section_token_limit();
if tok_est > limit {
let header = format::file_header(path, total_bytes, total_lines, ViewMode::SectionOutline);
let names: Vec<&str> = blocks.iter().map(|(_, _, n)| n.as_str()).collect();
return Ok(format!(
"{header}\n\n\
> {count} symbols ({names}) span ~{tok_est} tokens (limit {limit}).\n\
> Drill: `--section <fn-name>` for one at a time.",
count = blocks.len(),
names = names.join(", "),
));
}
let sym_count = blocks.len();
let header = format::file_header(path, total_bytes, total_lines, ViewMode::Section);
let header = header.replace("[section]", &format!("[{sym_count} symbols, section]"));
let body = parts.join("\n\n");
if errors.is_empty() {
Ok(format!("{header}\n\n{body}"))
} else {
let missing = errors.join("\n ");
Ok(format!(
"{header}\n\n{body}\n\n> Missing symbols:\n> {missing}"
))
}
}
fn filter_entries_in_range(
entries: &[OutlineEntry],
range_start: u32,
range_end: u32,
) -> Vec<&OutlineEntry> {
let mut out = Vec::new();
for e in entries {
if !e.children.is_empty() && (e.start_line < range_start || e.end_line > range_end) {
for c in &e.children {
if c.start_line <= range_end && c.end_line >= range_start {
out.push(c);
}
}
} else if e.start_line <= range_end && e.end_line >= range_start {
out.push(e);
}
}
out
}
fn format_section_outline(entries: &[&OutlineEntry]) -> String {
const MAX_SECTION_OUTLINE_LINES: usize = 100;
let mut lines = Vec::new();
for e in entries {
if lines.len() >= MAX_SECTION_OUTLINE_LINES {
break;
}
let range = if e.start_line == e.end_line {
format!("[{}]", e.start_line)
} else {
format!("[{}-{}]", e.start_line, e.end_line)
};
let sig = e.signature.as_deref().unwrap_or(&e.name);
lines.push(format!(" {range:>14} {sig}"));
for c in &e.children {
if lines.len() >= MAX_SECTION_OUTLINE_LINES {
break;
}
let cr = if c.start_line == c.end_line {
format!("[{}]", c.start_line)
} else {
format!("[{}-{}]", c.start_line, c.end_line)
};
let csig = c.signature.as_deref().unwrap_or(&c.name);
lines.push(format!(" {cr:>12} {csig}"));
}
}
if entries.len() > MAX_SECTION_OUTLINE_LINES {
lines.push(format!(
" ... section outline capped at {MAX_SECTION_OUTLINE_LINES} entries; use a narrower --section range"
));
}
lines.join("\n")
}
fn parse_range(s: &str) -> Option<(usize, usize, Option<usize>)> {
if !s.contains('-') {
let line: usize = s.trim().parse().ok()?;
if line == 0 {
return None;
}
return Some((line.saturating_sub(2).max(1), line + 2, Some(line)));
}
let (a, b) = s.split_once('-')?;
let start: usize = a.trim().parse().ok()?;
let end: usize = b.trim().parse().ok()?;
if start == 0 || end < start {
return None;
}
Some((start, end, None))
}
fn resolve_symbol(buf: &[u8], path: &Path, symbol: &str) -> Option<(usize, usize)> {
let content = std::str::from_utf8(buf).ok()?;
let FileType::Code(lang) = detect_file_type(path) else {
return None;
};
let entries = lang_get_outline_entries(content, lang);
find_symbol_in_entries(&entries, symbol)
}
fn suggest_symbols(buf: &[u8], path: &Path, query: &str, top_n: usize) -> Vec<String> {
let Ok(content) = std::str::from_utf8(buf) else {
return Vec::new();
};
let FileType::Code(lang) = detect_file_type(path) else {
return Vec::new();
};
let entries = lang_get_outline_entries(content, lang);
let mut flat: Vec<(&str, usize, usize)> = Vec::new();
collect_symbol_names(&entries, &mut flat);
let q = query.to_ascii_lowercase();
let mut scored: Vec<(usize, &str, usize, usize)> = flat
.iter()
.map(|&(name, start, end)| {
let nl = name.to_ascii_lowercase();
let dist = if nl.starts_with(&q) {
0
} else {
edit_distance(&q, &nl)
};
(dist, name, start, end)
})
.collect();
scored.sort_by_key(|(d, _, _, _)| *d);
scored
.into_iter()
.take(top_n)
.map(|(_, name, start, end)| format!("{name} [{start}-{end}]"))
.collect()
}
fn collect_symbol_names<'a>(entries: &'a [OutlineEntry], out: &mut Vec<(&'a str, usize, usize)>) {
for entry in entries {
out.push((
&entry.name,
entry.start_line as usize,
entry.end_line as usize,
));
collect_symbol_names(&entry.children, out);
}
}
fn find_symbol_in_entries(entries: &[OutlineEntry], symbol: &str) -> Option<(usize, usize)> {
for entry in entries {
if entry.name == symbol {
return Some((entry.start_line as usize, entry.end_line as usize));
}
if let Some(range) = find_symbol_in_entries(&entry.children, symbol) {
return Some(range);
}
}
None
}
fn list_directory(path: &Path) -> Result<String, SrcwalkError> {
let mut entries: Vec<String> = Vec::new();
let read_dir = fs::read_dir(path).map_err(|e| SrcwalkError::IoError {
path: path.to_path_buf(),
source: e,
})?;
let mut items: Vec<_> = read_dir.filter_map(std::result::Result::ok).collect();
items.sort_by_key(std::fs::DirEntry::file_name);
for entry in &items {
let ft = entry.file_type().ok();
let name = entry.file_name();
let name = name.to_string_lossy();
let meta = entry.metadata().ok();
let suffix = match ft {
Some(t) if t.is_dir() => "/".to_string(),
Some(t) if t.is_symlink() => " →".to_string(),
_ => match meta {
Some(m) => {
let tokens = estimate_tokens(m.len());
format!(" ({tokens} tokens)")
}
None => String::new(),
},
};
entries.push(format!(" {name}{suffix}"));
}
let header = format!("# {} ({} items)", path.display(), items.len());
Ok(format!("{header}\n\n{}", entries.join("\n")))
}
pub fn suggest_similar_file(scope: &Path, query: &str) -> Option<String> {
let resolved = scope.join(query);
suggest_similar(&resolved)
}
fn suggest_similar(path: &Path) -> Option<String> {
let parent = path.parent()?;
let name = path.file_name()?.to_str()?;
let entries = fs::read_dir(parent).ok()?;
let mut best: Option<(usize, String)> = None;
for entry in entries.flatten() {
let candidate = entry.file_name();
let candidate = candidate.to_string_lossy();
let dist = edit_distance(name, &candidate);
if dist <= 3 {
match &best {
Some((d, _)) if dist < *d => best = Some((dist, candidate.into_owned())),
None => best = Some((dist, candidate.into_owned())),
_ => {}
}
}
}
best.map(|(_, name)| name)
}
pub(crate) fn edit_distance(a: &str, b: &str) -> usize {
let a = a.as_bytes();
let b = b.as_bytes();
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr = vec![0; b.len() + 1];
for (i, &ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, &cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
fn mime_from_ext(path: &Path) -> &'static str {
match path.extension().and_then(|e| e.to_str()) {
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("svg") => "image/svg+xml",
Some("webp") => "image/webp",
Some("ico") => "image/x-icon",
Some("pdf") => "application/pdf",
Some("zip") => "application/zip",
Some("gz" | "tgz") => "application/gzip",
Some("tar") => "application/x-tar",
Some("wasm") => "application/wasm",
Some("woff" | "woff2") => "font/woff2",
Some("ttf" | "otf") => "font/ttf",
Some("mp3") => "audio/mpeg",
Some("mp4") => "video/mp4",
_ => "application/octet-stream",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heading_found() {
let input = b"# Title\nSome content\n## Section\nSection content\n";
let result = resolve_heading(input, "## Section");
assert_eq!(result, Some((3, 4)));
}
#[test]
fn heading_not_found() {
let input = b"# Title\nContent\n";
let result = resolve_heading(input, "## Missing");
assert_eq!(result, None);
}
#[test]
fn heading_in_code_block() {
let input = b"# Real\n```\n## Fake\n```\n";
let result = resolve_heading(input, "## Fake");
assert_eq!(result, None);
}
#[test]
fn duplicate_headings() {
let input = b"## First\ntext\n## First\ntext\n";
let result = resolve_heading(input, "## First");
assert_eq!(result, Some((1, 2)));
}
#[test]
fn last_heading_to_eof() {
let input = b"# Start\ntext\n## End\nfinal line\n";
let result = resolve_heading(input, "## End");
assert_eq!(result, Some((3, 4)));
}
#[test]
fn nested_sections() {
let input = b"## A\ncontent\n### B\nmore\n## C\ntext\n";
let result = resolve_heading(input, "## A");
assert_eq!(result, Some((1, 4)));
}
#[test]
fn no_hashes() {
let input = b"# Heading\ntext\n";
assert_eq!(resolve_heading(input, ""), None);
assert_eq!(resolve_heading(input, "hello"), None);
}
#[test]
fn default_path_read_returns_outline_not_full() {
let path = std::env::temp_dir().join("srcwalk_default_outline.rs");
std::fs::write(&path, b"fn alpha() {}\nfn beta() {}\n").unwrap();
let cache = OutlineCache::new();
let out = read_file(&path, None, false, &cache).unwrap();
assert!(out.contains("[outline]"), "expected outline header: {out}");
assert!(
!out.contains("[full]"),
"default read must not be full: {out}"
);
assert!(
out.contains("alpha"),
"outline should include symbols: {out}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn explicit_full_fits_raw_caps() {
let path = std::env::temp_dir().join("srcwalk_full_fits.rs");
std::fs::write(&path, b"fn alpha() {}\nfn beta() {}\n").unwrap();
let cache = OutlineCache::new();
let out = read_file(&path, None, true, &cache).unwrap();
assert!(
out.contains("[full]"),
"explicit full should be full: {out}"
);
assert!(
out.contains("1 fn alpha()"),
"full body should be numbered: {out}"
);
assert!(
!out.contains("full=true capped"),
"small full should not cap: {out}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn explicit_full_caps_after_raw_line_limit() {
use std::io::Write;
let path = std::env::temp_dir().join("srcwalk_full_line_cap.rs");
let mut f = std::fs::File::create(&path).unwrap();
for i in 0..250 {
writeln!(f, "fn func_{i}() {{}}").unwrap();
}
drop(f);
let cache = OutlineCache::new();
let out = read_file(&path, None, true, &cache).unwrap();
assert!(
out.contains("full=true capped"),
"expected cap warning: {out}"
);
assert!(
out.contains("Showing first 200 of 251 lines"),
"expected 200-line page: {out}"
);
assert!(
out.contains("--section 201-<end>"),
"expected next-page hint: {out}"
);
assert!(
out.contains("func_0"),
"expected first page body/outline: {out}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn oversized_section_degrades_to_section_outline() {
use std::io::Write;
let path = std::env::temp_dir().join("srcwalk_section_line_cap.rs");
let mut f = std::fs::File::create(&path).unwrap();
for i in 0..250 {
writeln!(f, "fn func_{i}() {{}}").unwrap();
}
drop(f);
let cache = OutlineCache::new();
let out = read_file(&path, Some("1-250"), false, &cache).unwrap();
assert!(
out.contains("[section, outline (over limit)]"),
"expected section outline: {out}"
);
assert!(
out.contains("200 lines"),
"expected line cap mention: {out}"
);
assert!(
!out.contains("fn func_200"),
"oversized section outline should be capped: {out}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn budget_cascade_full_to_outline() {
let mut body = String::from("<?php\nclass Big {\n");
for i in 0..120 {
body.push_str(&format!(
" public function method_{i}() {{\n $x = {i}; // padding line {i}\n return $x * 2;\n }}\n"
));
}
body.push_str("}\n");
let path = std::env::temp_dir().join("srcwalk_p11_cascade.php");
std::fs::write(&path, body.as_bytes()).unwrap();
let cache = OutlineCache::new();
let out = read_file_with_budget(&path, None, true, Some(800), &cache).unwrap();
let tokens = estimate_tokens(out.len() as u64);
assert!(tokens <= 800, "cascade overshot budget: {tokens} tokens");
assert!(
out.contains("[outline (full requested, over budget)]") || out.contains("[signatures"),
"expected cascade header label, got: {}",
&out[..out.len().min(200)]
);
assert!(out.contains("exceeded budget"), "missing cascade note");
let _ = std::fs::remove_file(&path);
}
#[test]
fn budget_cascade_passthrough_when_fits() {
let path = std::env::temp_dir().join("srcwalk_p11_tiny.php");
std::fs::write(&path, b"<?php\nclass Tiny { public function f() {} }\n").unwrap();
let cache = OutlineCache::new();
let out = read_file_with_budget(&path, None, true, Some(2000), &cache).unwrap();
assert!(
out.contains("[full]"),
"expected [full] label, got header in: {out}"
);
assert!(
!out.contains("exceeded budget"),
"no cascade note for fitting file"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn suggest_symbols_prefix_match() {
let code = b"fn collect_ranges() {}\nfn collect_names() {}\nfn parse_input() {}\n";
let path = std::env::temp_dir().join("srcwalk_suggest_prefix.rs");
std::fs::write(&path, code).unwrap();
let suggestions = suggest_symbols(code, &path, "collect", 3);
assert!(
suggestions.len() >= 2,
"expected at least 2 prefix matches: {suggestions:?}"
);
assert!(
suggestions[0].starts_with("collect_"),
"first should be prefix match: {}",
suggestions[0]
);
assert!(
suggestions[1].starts_with("collect_"),
"second should be prefix match: {}",
suggestions[1]
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn suggest_symbols_edit_distance_fallback() {
let code = b"fn tag_comment_matches() {}\nfn find_symbol() {}\n";
let path = std::env::temp_dir().join("srcwalk_suggest_edit.rs");
std::fs::write(&path, code).unwrap();
let suggestions = suggest_symbols(code, &path, "tag_comment", 3);
assert!(!suggestions.is_empty(), "should have suggestions");
assert!(
suggestions[0].contains("tag_comment_matches"),
"closest should be tag_comment_matches: {}",
suggestions[0]
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn suggest_symbols_includes_line_ranges() {
let code = b"fn alpha() {}\nfn beta() {}\n";
let path = std::env::temp_dir().join("srcwalk_suggest_ranges.rs");
std::fs::write(&path, code).unwrap();
let suggestions = suggest_symbols(code, &path, "alph", 3);
assert!(!suggestions.is_empty());
assert!(
suggestions[0].contains('[') && suggestions[0].contains(']'),
"should include line range: {}",
suggestions[0]
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn suggest_symbols_empty_for_non_code() {
let md = b"# Heading\nSome text\n";
let path = std::env::temp_dir().join("srcwalk_suggest_md.md");
std::fs::write(&path, md).unwrap();
let suggestions = suggest_symbols(md, &path, "foo", 3);
assert!(
suggestions.is_empty(),
"non-code file should return empty suggestions"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn section_symbol_miss_shows_suggestions() {
let code = "fn resolve_heading() {}\nfn resolve_symbol() {}\nfn resolve_range() {}\n";
let path = std::env::temp_dir().join("srcwalk_section_miss.rs");
std::fs::write(&path, code).unwrap();
let cache = OutlineCache::new();
let err = read_section(&path, "resolve_sym", &cache).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("symbol not found. Closest:"),
"should show suggestions: {msg}"
);
assert!(
msg.contains("resolve_symbol"),
"should suggest resolve_symbol: {msg}"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn multi_symbol_section_returns_all_bodies() {
let code = "fn aaa() {\n 1\n}\nfn bbb() {\n 2\n}\nfn ccc() {\n 3\n}\n";
let path = std::env::temp_dir().join("srcwalk_multi_sym.rs");
std::fs::write(&path, code).unwrap();
let cache = OutlineCache::new();
let out = read_section(&path, "aaa,ccc", &cache).unwrap();
assert!(
out.contains("2 symbols, section"),
"header should show symbol count: {out}"
);
assert!(out.contains("aaa()"), "should contain aaa body");
assert!(out.contains("ccc()"), "should contain ccc body");
assert!(!out.contains("bbb()"), "should NOT contain bbb body");
let _ = std::fs::remove_file(&path);
}
#[test]
fn multi_symbol_section_sorted_by_line_order() {
let code = "fn first() {\n 1\n}\nfn second() {\n 2\n}\n";
let path = std::env::temp_dir().join("srcwalk_multi_order.rs");
std::fs::write(&path, code).unwrap();
let cache = OutlineCache::new();
let out = read_section(&path, "second,first", &cache).unwrap();
let pos_first = out.find("first()").unwrap();
let pos_second = out.find("second()").unwrap();
assert!(
pos_first < pos_second,
"should be sorted by line order, not request order"
);
let _ = std::fs::remove_file(&path);
}
#[test]
fn multi_symbol_section_partial_miss_returns_found() {
let code = "fn real_fn() {}\nfn other_fn() {}\n";
let path = std::env::temp_dir().join("srcwalk_multi_miss.rs");
std::fs::write(&path, code).unwrap();
let cache = OutlineCache::new();
let out = read_section(&path, "real_fn,nope_fn", &cache).unwrap();
assert!(
out.contains("real_fn()"),
"should contain found symbol: {out}"
);
assert!(
out.contains("Missing symbols"),
"should note missing: {out}"
);
assert!(out.contains("nope_fn"), "should name missing symbol: {out}");
let _ = std::fs::remove_file(&path);
}
#[test]
fn multi_symbol_section_all_miss_errors() {
let code = "fn real_fn() {}\n";
let path = std::env::temp_dir().join("srcwalk_multi_all_miss.rs");
std::fs::write(&path, code).unwrap();
let cache = OutlineCache::new();
let err = read_section(&path, "zzz_fake,yyy_fake", &cache).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("symbols not found"),
"all-miss should error: {msg}"
);
let _ = std::fs::remove_file(&path);
}
}