Skip to main content

krait/commands/
read.rs

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
14/// Bytes to scan for binary detection.
15const BINARY_SCAN_SIZE: usize = 8192;
16
17/// Read a file with optional line range and `max_lines`.
18///
19/// Pure file I/O — no LSP needed.
20///
21/// # Errors
22/// Returns an error if the file can't be read or is binary.
23pub 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    // Check file exists
37    if !abs_path.exists() {
38        bail!("file not found: {}", path.display());
39    }
40
41    // Binary detection: scan first 8KB for null bytes
42    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    // Apply from/to (1-indexed inclusive)
58    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    // Apply max_lines
72    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    // Format with cat -n style line numbers
81    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/// Read a symbol's body from source, using LSP to find its range.
103///
104/// Takes pre-found `SymbolMatch` candidates (from `workspace/symbol`) to avoid
105/// duplicate queries. Tries each candidate's file via `documentSymbol` until
106/// one resolves the symbol at the top level.
107///
108/// When `has_body` is true, skips overload stubs (1-2 line declarations ending in `;`
109/// and `.d.ts` files) and returns the first candidate with a real implementation body.
110/// Falls back to the first stub if no real body is found.
111///
112/// # Errors
113/// Returns an error if the symbol can't be found or the file can't be read.
114#[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    // Fallback when has_body=true but only stubs found
132    let mut stub_fallback: Option<Value> = None;
133
134    // Prioritise definition-like kinds over reference-like kinds.
135    // JS `module.exports = { Foo }` produces a `property` candidate at the
136    // exports line in addition to the actual `class`/`function` candidate.
137    // Iterating property last ensures we read the real body first.
138    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        // Skip .d.ts declaration files when has_body is requested
147        if has_body && sym.path.ends_with(".d.ts") {
148            continue;
149        }
150
151        let abs = project_root.join(&sym.path);
152        // Convert 1-indexed candidate line to 0-indexed hint for overload disambiguation
153        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        // For dotted names (e.g. "Config.new"), resolve the nested part
164        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        // Extract lines from file
177        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    // has_body requested but only stubs found — return first stub rather than nothing
240    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
248/// Format lines with `cat -n` style numbering.
249pub(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        // Should not contain lines outside range
307        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}