1use std::fmt::Write;
2use std::path::Path;
3
4use anyhow::{bail, Context};
5use serde_json::{json, Value};
6
7use super::DEFAULT_MAX_LINES;
8
9use crate::lang::typescript as lang_ts;
10use crate::lsp::client::LspClient;
11use crate::lsp::files::FileTracker;
12use crate::lsp::symbols::resolve_symbol_range;
13
14const BINARY_SCAN_SIZE: usize = 8192;
16
17pub fn handle_read_file(
24 path: &Path,
25 from: Option<u32>,
26 to: Option<u32>,
27 max_lines: Option<u32>,
28 project_root: &Path,
29) -> anyhow::Result<Value> {
30 let abs_path = if path.is_absolute() {
31 path.to_path_buf()
32 } else {
33 project_root.join(path)
34 };
35
36 if !abs_path.exists() {
38 bail!("file not found: {}", path.display());
39 }
40
41 let raw =
43 std::fs::read(&abs_path).with_context(|| format!("failed to read: {}", path.display()))?;
44
45 let scan_len = raw.len().min(BINARY_SCAN_SIZE);
46 if raw[..scan_len].contains(&0) {
47 bail!("binary file: {}", path.display());
48 }
49
50 let content = String::from_utf8(raw)
51 .with_context(|| format!("file is not valid UTF-8: {}", path.display()))?;
52
53 let all_lines: Vec<&str> = content.lines().collect();
54 #[allow(clippy::cast_possible_truncation)]
55 let total = all_lines.len() as u32;
56
57 let from_idx = from.unwrap_or(1).max(1).saturating_sub(1) as usize;
59 let to_idx = to.map_or(all_lines.len(), |t| (t as usize).min(all_lines.len()));
60
61 if from_idx >= all_lines.len() {
62 bail!(
63 "line {} is past end of file ({} lines)",
64 from_idx + 1,
65 total
66 );
67 }
68
69 let selected = &all_lines[from_idx..to_idx];
70
71 let max = max_lines.unwrap_or(DEFAULT_MAX_LINES) as usize;
73 let truncated = selected.len() > max;
74 let lines = if truncated {
75 &selected[..max]
76 } else {
77 selected
78 };
79
80 let numbered = format_numbered_lines(lines, from_idx + 1);
82
83 let display_from = from_idx + 1;
84 let display_to = from_idx + lines.len();
85
86 let rel_path = abs_path
87 .strip_prefix(project_root)
88 .unwrap_or(&abs_path)
89 .to_string_lossy()
90 .to_string();
91
92 Ok(json!({
93 "path": rel_path,
94 "content": numbered,
95 "from": display_from,
96 "to": display_to,
97 "total": total,
98 "truncated": truncated,
99 }))
100}
101
102#[allow(clippy::too_many_arguments)]
115pub async fn handle_read_symbol(
116 name: &str,
117 candidates: &[crate::commands::find::SymbolMatch],
118 signature_only: bool,
119 max_lines: Option<u32>,
120 has_body: bool,
121 client: &mut LspClient,
122 file_tracker: &mut FileTracker,
123 project_root: &Path,
124) -> anyhow::Result<Value> {
125 if candidates.is_empty() {
126 bail!("symbol '{name}' not found");
127 }
128
129 let lookup_name = name.split('.').next().unwrap_or(name);
130 let mut last_err = None;
131 let mut stub_fallback: Option<Value> = None;
133
134 let sorted: Vec<_> = {
139 let (preferred, rest): (Vec<_>, Vec<_>) = candidates
140 .iter()
141 .partition(|s| !matches!(s.kind.as_str(), "property" | "variable" | "field"));
142 preferred.into_iter().chain(rest).collect()
143 };
144
145 for sym in sorted {
146 if has_body && sym.path.ends_with(".d.ts") {
148 continue;
149 }
150
151 let abs = project_root.join(&sym.path);
152 let hint_line = sym.line.checked_sub(1);
154 let loc =
155 match resolve_symbol_range(lookup_name, &abs, hint_line, client, file_tracker).await {
156 Ok(loc) => loc,
157 Err(e) => {
158 last_err = Some(e);
159 continue;
160 }
161 };
162
163 let location = if name.contains('.') {
165 match resolve_symbol_range(name, &abs, hint_line, client, file_tracker).await {
166 Ok(l) => l,
167 Err(e) => {
168 last_err = Some(e);
169 continue;
170 }
171 }
172 } else {
173 loc
174 };
175
176 let content = match std::fs::read_to_string(&abs) {
178 Ok(c) => c,
179 Err(e) => {
180 last_err =
181 Some(anyhow::Error::from(e).context(format!("failed to read: {}", sym.path)));
182 continue;
183 }
184 };
185
186 let all_lines: Vec<&str> = content.lines().collect();
187 let start = location.start_line as usize;
188 let end = (location.end_line as usize + 1).min(all_lines.len());
189
190 if start >= all_lines.len() {
191 last_err = Some(anyhow::anyhow!("symbol range out of bounds"));
192 continue;
193 }
194
195 let selected = &all_lines[start..end];
196
197 let display_lines: &[&str] = if signature_only {
198 let sig_end = selected
199 .iter()
200 .position(|l| l.contains('{'))
201 .map_or(1, |i| i + 1);
202 &selected[..sig_end.min(selected.len())]
203 } else {
204 selected
205 };
206
207 let max = max_lines.unwrap_or(DEFAULT_MAX_LINES) as usize;
208 let truncated = display_lines.len() > max;
209 let display_lines = if truncated {
210 &display_lines[..max]
211 } else {
212 display_lines
213 };
214
215 let numbered = format_numbered_lines(display_lines, start + 1);
216 let display_from = start + 1;
217 let display_to = start + display_lines.len();
218
219 let result = json!({
220 "path": sym.path,
221 "symbol": location.name,
222 "kind": location.kind,
223 "content": numbered,
224 "from": display_from,
225 "to": display_to,
226 "truncated": truncated,
227 });
228
229 if has_body && lang_ts::is_overload_stub(selected) {
230 if stub_fallback.is_none() {
231 stub_fallback = Some(result);
232 }
233 continue;
234 }
235
236 return Ok(result);
237 }
238
239 if let Some(fallback) = stub_fallback {
241 return Ok(fallback);
242 }
243
244 Err(last_err
245 .unwrap_or_else(|| anyhow::anyhow!("symbol '{name}' not found in document symbols")))
246}
247
248pub(crate) fn format_numbered_lines(lines: &[&str], start_num: usize) -> String {
250 let last_num = start_num + lines.len();
251 let width = last_num.to_string().len().max(4);
252
253 let mut out = String::new();
254 for (i, line) in lines.iter().enumerate() {
255 let num = start_num + i;
256 let _ = writeln!(out, "{num:>width$}\t{line}");
257 }
258 out
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn binary_detection_rejects_null_bytes() {
267 let dir = tempfile::tempdir().unwrap();
268 let file = dir.path().join("binary.bin");
269 std::fs::write(&file, b"hello\x00world").unwrap();
270
271 let result = handle_read_file(&file, None, None, None, dir.path());
272 assert!(result.is_err());
273 assert!(result.unwrap_err().to_string().contains("binary file"));
274 }
275
276 #[test]
277 fn read_file_basic() {
278 let dir = tempfile::tempdir().unwrap();
279 let file = dir.path().join("test.txt");
280 std::fs::write(&file, "line1\nline2\nline3\nline4\nline5\n").unwrap();
281
282 let result = handle_read_file(Path::new("test.txt"), None, None, None, dir.path()).unwrap();
283
284 assert_eq!(result["total"], 5);
285 assert_eq!(result["from"], 1);
286 assert_eq!(result["to"], 5);
287 assert_eq!(result["truncated"], false);
288 assert!(result["content"].as_str().unwrap().contains("line1"));
289 }
290
291 #[test]
292 fn read_file_with_range() {
293 let dir = tempfile::tempdir().unwrap();
294 let file = dir.path().join("test.txt");
295 std::fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
296
297 let result =
298 handle_read_file(Path::new("test.txt"), Some(2), Some(4), None, dir.path()).unwrap();
299
300 assert_eq!(result["from"], 2);
301 assert_eq!(result["to"], 4);
302 let content = result["content"].as_str().unwrap();
303 assert!(content.contains('b'));
304 assert!(content.contains('c'));
305 assert!(content.contains('d'));
306 assert!(!content.contains("\ta\n"));
308 assert!(!content.contains("\te\n"));
309 }
310
311 #[test]
312 fn read_file_truncation() {
313 let dir = tempfile::tempdir().unwrap();
314 let file = dir.path().join("test.txt");
315 let mut content = String::new();
316 for i in 1..=10 {
317 use std::fmt::Write;
318 let _ = writeln!(content, "line{i}");
319 }
320 std::fs::write(&file, content).unwrap();
321
322 let result =
323 handle_read_file(Path::new("test.txt"), None, None, Some(3), dir.path()).unwrap();
324
325 assert_eq!(result["truncated"], true);
326 assert_eq!(result["to"], 3);
327 }
328
329 #[test]
330 fn read_file_not_found() {
331 let dir = tempfile::tempdir().unwrap();
332 let result = handle_read_file(Path::new("nonexistent.txt"), None, None, None, dir.path());
333 assert!(result.is_err());
334 assert!(result.unwrap_err().to_string().contains("not found"));
335 }
336
337 #[test]
338 fn format_numbered_lines_basic() {
339 let lines = vec!["hello", "world"];
340 let out = format_numbered_lines(&lines, 1);
341 assert!(out.contains(" 1\thello\n"));
342 assert!(out.contains(" 2\tworld\n"));
343 }
344
345 #[test]
346 fn format_numbered_lines_offset() {
347 let lines = vec!["a", "b"];
348 let out = format_numbered_lines(&lines, 98);
349 assert!(out.contains(" 98\ta\n"));
350 assert!(out.contains(" 99\tb\n"));
351 }
352
353 #[test]
354 fn read_file_past_end() {
355 let dir = tempfile::tempdir().unwrap();
356 let file = dir.path().join("test.txt");
357 std::fs::write(&file, "one\ntwo\n").unwrap();
358
359 let result = handle_read_file(Path::new("test.txt"), Some(100), None, None, dir.path());
360 assert!(result.is_err());
361 assert!(result.unwrap_err().to_string().contains("past end"));
362 }
363}