Skip to main content

tldr_cli/commands/
slice.rs

1//! Slice command - Program slicing
2//!
3//! Computes backward or forward program slices from a line.
4//! Auto-routes through daemon when available for ~35x speedup.
5
6use std::path::PathBuf;
7
8use anyhow::Result;
9use clap::Args;
10use serde::{Deserialize, Serialize};
11
12use tldr_core::ast::function_finder::find_function_bounds_from_path_or_source;
13use tldr_core::{get_slice_rich, Language, SliceDirection};
14
15use crate::commands::daemon_router::{params_with_file_function_line, try_daemon_route};
16use crate::output::{OutputFormat, OutputWriter};
17
18/// Compute program slice from a line
19#[derive(Debug, Args)]
20pub struct SliceArgs {
21    /// Source file path
22    pub file: PathBuf,
23
24    /// Function name containing the line
25    pub function: String,
26
27    /// Line number to slice from
28    pub line: u32,
29
30    /// Slice direction: backward (what affects this line) or forward (what this line affects)
31    #[arg(long, short = 'd', default_value = "backward")]
32    pub direction: SliceDirectionArg,
33
34    /// Variable to filter by (optional - traces all if not specified)
35    #[arg(long)]
36    pub variable: Option<String>,
37
38    /// Programming language (auto-detected from file extension if not specified)
39    #[arg(long, short = 'l')]
40    pub lang: Option<Language>,
41}
42
43/// CLI wrapper for slice direction
44#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
45pub enum SliceDirectionArg {
46    /// Backward slice - what affects this line?
47    #[default]
48    Backward,
49    /// Forward slice - what does this line affect?
50    Forward,
51}
52
53impl From<SliceDirectionArg> for SliceDirection {
54    fn from(arg: SliceDirectionArg) -> Self {
55        match arg {
56            SliceDirectionArg::Backward => SliceDirection::Backward,
57            SliceDirectionArg::Forward => SliceDirection::Forward,
58        }
59    }
60}
61
62/// Rich slice line for output
63#[derive(Debug, Serialize, Deserialize)]
64struct SliceLine {
65    line: u32,
66    code: String,
67    #[serde(skip_serializing_if = "Vec::is_empty")]
68    definitions: Vec<String>,
69    #[serde(skip_serializing_if = "Vec::is_empty")]
70    uses: Vec<String>,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    dep_type: Option<String>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    dep_label: Option<String>,
75}
76
77/// Edge in slice output
78#[derive(Debug, Serialize, Deserialize)]
79struct SliceEdgeOutput {
80    from_line: u32,
81    to_line: u32,
82    dep_type: String,
83    label: String,
84}
85
86/// Slice result output format (backward-compatible: keeps `lines` as Vec<u32>)
87#[derive(Debug, Serialize, Deserialize)]
88struct SliceOutput {
89    file: PathBuf,
90    function: String,
91    criterion_line: u32,
92    direction: String,
93    variable: Option<String>,
94    /// Bare line numbers (backward-compatible)
95    lines: Vec<u32>,
96    /// Rich line data with code and metadata
97    #[serde(skip_serializing_if = "Vec::is_empty")]
98    slice_lines: Vec<SliceLine>,
99    /// Dependency edges within the slice
100    #[serde(skip_serializing_if = "Vec::is_empty")]
101    edges: Vec<SliceEdgeOutput>,
102    line_count: usize,
103    /// Diagnostic explanation when the result is empty for a known
104    /// reason (e.g. criterion line is outside the function bounds).
105    /// ux-and-explain-completeness-v1 (P12.AGG12-15): mirrors `chop`'s
106    /// pattern so empty results are not silent.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    explanation: Option<String>,
109}
110
111/// Legacy daemon output (old format without rich data)
112#[derive(Debug, Serialize, Deserialize)]
113struct LegacySliceOutput {
114    file: PathBuf,
115    function: String,
116    criterion_line: u32,
117    direction: String,
118    variable: Option<String>,
119    lines: Vec<u32>,
120    line_count: usize,
121}
122
123impl SliceArgs {
124    /// Run the slice command
125    pub fn run(&self, format: OutputFormat, quiet: bool) -> Result<()> {
126        let writer = OutputWriter::new(format, quiet);
127
128        // Determine language from file extension or argument
129        let language = self
130            .lang
131            .unwrap_or_else(|| Language::from_path(&self.file).unwrap_or(Language::Python));
132
133        let direction: SliceDirection = self.direction.into();
134        let direction_str = match direction {
135            SliceDirection::Backward => "backward",
136            SliceDirection::Forward => "forward",
137        };
138
139        // Try daemon first for cached result (use file's parent as project root)
140        let project = self.file.parent().unwrap_or(&self.file);
141        if let Some(output) = try_daemon_route::<LegacySliceOutput>(
142            project,
143            "slice",
144            params_with_file_function_line(&self.file, &self.function, self.line),
145        ) {
146            // Daemon returns legacy format -- enrich with source code if possible
147            let source_lines = read_file_lines(&self.file);
148            if writer.is_text() {
149                let mut text = String::new();
150                text.push_str(&format!(
151                    "Program Slice ({} from line {})\n",
152                    output.direction, output.criterion_line
153                ));
154                text.push_str(&format!(
155                    "Function: {}::{}\n",
156                    output.file.display(),
157                    output.function
158                ));
159                if let Some(var) = &output.variable {
160                    text.push_str(&format!("Variable: {}\n", var));
161                }
162                // P12.AGG12-15: surface OOR diagnostic in text output too.
163                if output.lines.is_empty() {
164                    if let Some(diag) = slice_oor_explanation(
165                        self.file.to_str().unwrap_or_default(),
166                        &self.function,
167                        self.line,
168                        language,
169                    ) {
170                        text.push_str(&format!("\n{}\n", diag));
171                        writer.write_text(&text)?;
172                        return Ok(());
173                    }
174                }
175                text.push_str(&format!(
176                    "\nSlice contains {} lines:\n\n",
177                    output.lines.len()
178                ));
179                for &line_num in &output.lines {
180                    let code = source_lines
181                        .get((line_num as usize).wrapping_sub(1))
182                        .map(|s| s.trim_end())
183                        .unwrap_or("");
184                    let marker = if line_num == output.criterion_line {
185                        ">"
186                    } else {
187                        " "
188                    };
189                    let criterion_flag = if line_num == output.criterion_line {
190                        "  <-- criterion"
191                    } else {
192                        ""
193                    };
194                    text.push_str(&format!(
195                        "{} {:>5} | {}{}\n",
196                        marker, line_num, code, criterion_flag
197                    ));
198                }
199                writer.write_text(&text)?;
200                return Ok(());
201            } else {
202                // Convert legacy to new format for JSON
203                let slice_lines: Vec<SliceLine> = output
204                    .lines
205                    .iter()
206                    .map(|&l| {
207                        let code = source_lines
208                            .get((l as usize).wrapping_sub(1))
209                            .map(|s| s.trim_end().to_string())
210                            .unwrap_or_default();
211                        SliceLine {
212                            line: l,
213                            code,
214                            definitions: Vec::new(),
215                            uses: Vec::new(),
216                            dep_type: None,
217                            dep_label: None,
218                        }
219                    })
220                    .collect();
221                // P12.AGG12-15: same OOR diagnostic as the direct-compute
222                // path, applied to the daemon's legacy output.
223                let explanation = if output.lines.is_empty() {
224                    slice_oor_explanation(
225                        self.file.to_str().unwrap_or_default(),
226                        &self.function,
227                        self.line,
228                        language,
229                    )
230                } else {
231                    None
232                };
233                let rich_output = SliceOutput {
234                    file: output.file,
235                    function: output.function,
236                    criterion_line: output.criterion_line,
237                    direction: output.direction,
238                    variable: output.variable,
239                    line_count: output.line_count,
240                    lines: output.lines,
241                    slice_lines,
242                    edges: Vec::new(),
243                    explanation,
244                };
245                writer.write(&rich_output)?;
246                return Ok(());
247            }
248        }
249
250        // Fallback to direct compute with rich output
251        writer.progress(&format!(
252            "Computing {} slice for line {} in {}::{}...",
253            direction_str,
254            self.line,
255            self.file.display(),
256            self.function
257        ));
258
259        // Get rich slice
260        let rich = get_slice_rich(
261            self.file.to_str().unwrap_or_default(),
262            &self.function,
263            self.line,
264            direction,
265            self.variable.as_deref(),
266            language,
267        )?;
268
269        // Build backward-compatible line list
270        let lines: Vec<u32> = rich.nodes.iter().map(|n| n.line).collect();
271
272        // Build rich line data
273        let slice_lines: Vec<SliceLine> = rich
274            .nodes
275            .iter()
276            .map(|n| SliceLine {
277                line: n.line,
278                code: n.code.clone(),
279                definitions: n.definitions.clone(),
280                uses: n.uses.clone(),
281                dep_type: n.dep_type.clone(),
282                dep_label: n.dep_label.clone(),
283            })
284            .collect();
285
286        // Build edge output
287        let edges: Vec<SliceEdgeOutput> = rich
288            .edges
289            .iter()
290            .map(|e| SliceEdgeOutput {
291                from_line: e.from_line,
292                to_line: e.to_line,
293                dep_type: e.dep_type.clone(),
294                label: e.label.clone(),
295            })
296            .collect();
297
298        let data_count = edges.iter().filter(|e| e.dep_type == "data").count();
299        let ctrl_count = edges.iter().filter(|e| e.dep_type == "control").count();
300
301        // ux-and-explain-completeness-v1 (P12.AGG12-15): when the slice
302        // is empty, attribute it. The most common cause is the criterion
303        // line being outside the resolved function bounds — mirror chop's
304        // diagnostic pattern so users aren't left guessing.
305        let explanation = if lines.is_empty() {
306            slice_oor_explanation(
307                self.file.to_str().unwrap_or_default(),
308                &self.function,
309                self.line,
310                language,
311            )
312        } else {
313            None
314        };
315
316        let output = SliceOutput {
317            file: self.file.clone(),
318            function: self.function.clone(),
319            criterion_line: self.line,
320            direction: direction_str.to_string(),
321            variable: self.variable.clone(),
322            line_count: lines.len(),
323            lines,
324            slice_lines,
325            edges,
326            explanation,
327        };
328
329        // Output based on format
330        if writer.is_text() {
331            let text = format_rich_text(&output, data_count, ctrl_count);
332            writer.write_text(&text)?;
333        } else {
334            writer.write(&output)?;
335        }
336
337        Ok(())
338    }
339}
340
341/// Format rich slice as compact text for LLM consumption
342fn format_rich_text(output: &SliceOutput, data_count: usize, ctrl_count: usize) -> String {
343    let mut text = String::new();
344
345    text.push_str(&format!(
346        "Program Slice ({} from line {})\n",
347        output.direction, output.criterion_line
348    ));
349    text.push_str(&format!(
350        "Function: {}::{}\n",
351        output.file.display(),
352        output.function
353    ));
354    if let Some(var) = &output.variable {
355        text.push_str(&format!("Variable: {}\n", var));
356    }
357
358    // P12.AGG12-15: emit the OOR diagnostic prominently when present.
359    if let Some(diag) = &output.explanation {
360        text.push_str(&format!("\n{}\n", diag));
361        return text;
362    }
363
364    // Count non-blank lines for accurate summary
365    let non_blank_count = output
366        .slice_lines
367        .iter()
368        .filter(|sl| !sl.code.trim().is_empty())
369        .count();
370
371    // Summary line with dep counts
372    if data_count > 0 || ctrl_count > 0 {
373        text.push_str(&format!(
374            "\nSlice contains {} lines ({} data deps, {} control deps):\n\n",
375            non_blank_count, data_count, ctrl_count
376        ));
377    } else {
378        text.push_str(&format!("\nSlice contains {} lines:\n\n", non_blank_count));
379    }
380
381    // Code lines with annotations
382    // Track previous defs/uses to avoid repeating identical annotations
383    // (PDG nodes span multiple lines but carry one set of defs/uses)
384    let mut prev_defs: Option<&Vec<String>> = None;
385    let mut prev_uses: Option<&Vec<String>> = None;
386
387    for sl in &output.slice_lines {
388        // Skip blank lines — they waste tokens and carry no insight
389        if sl.code.trim().is_empty() {
390            continue;
391        }
392
393        let marker = if sl.line == output.criterion_line {
394            ">"
395        } else {
396            " "
397        };
398
399        // Only show defs/uses on the first line of each node span
400        let same_as_prev = prev_defs == Some(&sl.definitions) && prev_uses == Some(&sl.uses);
401
402        let mut annotations = Vec::new();
403        if !same_as_prev {
404            if !sl.definitions.is_empty() {
405                annotations.push(format!("[defines: {}]", sl.definitions.join(", ")));
406            }
407            if !sl.uses.is_empty() {
408                annotations.push(format!("[uses: {}]", sl.uses.join(", ")));
409            }
410        }
411        if let Some(dt) = &sl.dep_type {
412            if dt == "control" && !same_as_prev {
413                annotations.push("ctrl".to_string());
414            }
415        }
416
417        prev_defs = Some(&sl.definitions);
418        prev_uses = Some(&sl.uses);
419
420        let criterion_flag = if sl.line == output.criterion_line {
421            "  <-- criterion"
422        } else {
423            ""
424        };
425
426        let annotation_str = if annotations.is_empty() {
427            String::new()
428        } else {
429            format!("     {}", annotations.join(" "))
430        };
431
432        text.push_str(&format!(
433            "{} {:>5} | {}{}{}\n",
434            marker, sl.line, sl.code, annotation_str, criterion_flag
435        ));
436    }
437
438    // Dependencies section
439    if !output.edges.is_empty() {
440        text.push_str("\nDependencies:\n");
441        for edge in &output.edges {
442            if edge.dep_type == "data" && !edge.label.is_empty() {
443                text.push_str(&format!(
444                    "  {}@{} <- {}@{} (data: {})\n",
445                    edge.label, edge.to_line, edge.label, edge.from_line, edge.label
446                ));
447            } else {
448                text.push_str(&format!(
449                    "  {} <- {} ({})\n",
450                    edge.to_line, edge.from_line, edge.dep_type
451                ));
452            }
453        }
454    }
455
456    text
457}
458
459/// Produce a `LineOutsideFunction`-style diagnostic when slice's
460/// criterion line falls outside the resolved bounds of the named
461/// function. ux-and-explain-completeness-v1 (P12.AGG12-15): mirrors
462/// the diagnostic emitted by `chop` so empty slices on out-of-range
463/// criterion lines are not silent. Returns None when the function
464/// cannot be located in source (a different failure mode that should
465/// not be reported as "outside function").
466fn slice_oor_explanation(
467    source_or_path: &str,
468    function_name: &str,
469    line: u32,
470    language: Language,
471) -> Option<String> {
472    let (start, end) =
473        find_function_bounds_from_path_or_source(source_or_path, function_name, language)?;
474    if line < start || line > end {
475        Some(format!(
476            "Analysis could not be completed: line {} is outside function '{}' (lines {}-{})",
477            line, function_name, start, end
478        ))
479    } else {
480        None
481    }
482}
483
484// Optional helper accessor used in tests and richtext path; matches
485// `read_file_lines` location in the file.
486/// Read file lines for source enrichment
487fn read_file_lines(path: &PathBuf) -> Vec<String> {
488    std::fs::read_to_string(path)
489        .map(|c| c.lines().map(|l| l.to_string()).collect())
490        .unwrap_or_default()
491}