Skip to main content

gobby_code/commands/
symbol_at.rs

1use std::cmp::Ordering;
2
3use anyhow::{Context as AnyhowContext, anyhow, bail};
4use serde::Serialize;
5
6use super::scope;
7use crate::{
8    config::Context,
9    db,
10    models::Symbol,
11    output::{self, Format},
12    savings, visibility,
13};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16struct ParsedLocation {
17    file: String,
18    line: usize,
19    column: Option<usize>,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23struct SymbolAtTarget {
24    line: usize,
25    byte_offset: Option<usize>,
26}
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
29#[serde(rename_all = "snake_case")]
30enum MatchKind {
31    Containing,
32    Nearest,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
36struct SymbolAtLookup {
37    requested_file: String,
38    requested_line: usize,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    requested_column: Option<usize>,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    column_unit: Option<&'static str>,
43    match_kind: MatchKind,
44    distance_lines: usize,
45    #[serde(skip_serializing_if = "Option::is_none")]
46    distance_bytes: Option<usize>,
47}
48
49#[derive(Debug, Clone, Copy)]
50struct SelectedSymbol<'a> {
51    symbol: &'a Symbol,
52    match_kind: MatchKind,
53    distance_lines: usize,
54    distance_bytes: Option<usize>,
55}
56
57pub fn requested_file_for_freshness(
58    ctx: &Context,
59    location: &str,
60    line: Option<usize>,
61) -> anyhow::Result<String> {
62    let parsed = parse_location(location, line)?;
63    Ok(scope::normalize_file_arg(ctx, &parsed.file))
64}
65
66pub fn run(
67    ctx: &Context,
68    location: &str,
69    line: Option<usize>,
70    format: Format,
71) -> anyhow::Result<()> {
72    let request = parse_location(location, line)?;
73    let requested_file = scope::normalize_file_arg(ctx, &request.file);
74    let mut conn = db::connect_readonly(&ctx.database_url)?;
75    let symbols = visibility::visible_symbols_for_file(&mut conn, ctx, &requested_file)?;
76    if symbols.is_empty() {
77        bail!("No visible symbols found for file: {requested_file}");
78    }
79
80    let source_path = ctx.project_root.join(&requested_file);
81    let source = std::fs::read(&source_path)
82        .with_context(|| format!("failed to read {}", source_path.display()))?;
83    let byte_offset = request
84        .column
85        .map(|column| line_column_to_byte_offset(&source, request.line, column))
86        .transpose()
87        .with_context(|| {
88            format!(
89                "invalid location {}:{}:{}",
90                requested_file,
91                request.line,
92                request.column.unwrap_or_default()
93            )
94        })?;
95
96    let selected = select_symbol(
97        &symbols,
98        SymbolAtTarget {
99            line: request.line,
100            byte_offset,
101        },
102    )
103    .ok_or_else(|| anyhow!("No visible symbols found for file: {requested_file}"))?;
104    let lookup = lookup_for_selection(requested_file, &request, &selected);
105    let (snippet, symbol_bytes) = symbol_source(&source, selected.symbol);
106
107    if symbol_bytes > 0 && source.len() > symbol_bytes {
108        let url = gobby_core::daemon_url::daemon_url();
109        savings::report_savings(&url, source.len(), symbol_bytes);
110    }
111
112    if let Some(diagnostic) = fallback_diagnostic(selected.symbol, &lookup, ctx.quiet) {
113        eprintln!("{diagnostic}");
114    }
115
116    match format {
117        Format::Json => {
118            output::print_json(&symbol_at_json_value(selected.symbol, &snippet, &lookup)?)
119        }
120        Format::Text => output::print_text(&snippet),
121    }
122}
123
124fn parse_location(location: &str, explicit_line: Option<usize>) -> anyhow::Result<ParsedLocation> {
125    if location.is_empty() {
126        bail!("path is required");
127    }
128
129    if let Some(line) = explicit_line {
130        if line == 0 {
131            bail!("line must be greater than 0");
132        }
133        if has_encoded_line(location) {
134            bail!("line specified twice; use PATH:LINE or PATH LINE, not both");
135        }
136        return Ok(ParsedLocation {
137            file: location.to_string(),
138            line,
139            column: None,
140        });
141    }
142
143    let Some((prefix, last)) = location.rsplit_once(':') else {
144        bail!("missing line; use PATH:LINE, PATH:LINE:COLUMN, or PATH LINE");
145    };
146    if prefix.is_empty() {
147        bail!("path is required");
148    }
149
150    if let Some((path, line_text)) = prefix.rsplit_once(':')
151        && is_numeric_text(line_text)
152    {
153        if path.is_empty() {
154            bail!("path is required");
155        }
156        let line = parse_positive_component("line", line_text)?;
157        let column = parse_positive_component("column", last)?;
158        return Ok(ParsedLocation {
159            file: path.to_string(),
160            line,
161            column: Some(column),
162        });
163    }
164
165    let line = parse_positive_component("line", last)?;
166    Ok(ParsedLocation {
167        file: prefix.to_string(),
168        line,
169        column: None,
170    })
171}
172
173fn has_encoded_line(location: &str) -> bool {
174    let Some((prefix, last)) = location.rsplit_once(':') else {
175        return false;
176    };
177    if is_numeric_text(last) {
178        return true;
179    }
180    prefix
181        .rsplit_once(':')
182        .is_some_and(|(_, line)| is_numeric_text(line))
183}
184
185fn parse_positive_component(kind: &str, value: &str) -> anyhow::Result<usize> {
186    let parsed = value
187        .parse::<usize>()
188        .map_err(|_| anyhow!("{kind} must be a positive integer"))?;
189    if parsed == 0 {
190        bail!("{kind} must be greater than 0");
191    }
192    Ok(parsed)
193}
194
195fn is_numeric_text(value: &str) -> bool {
196    !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit())
197}
198
199/// Converts 1-based editor/LSP line and column inputs to a 0-based byte offset.
200///
201/// Line and column must be greater than zero; out-of-range coordinates bail.
202fn line_column_to_byte_offset(source: &[u8], line: usize, column: usize) -> anyhow::Result<usize> {
203    if line == 0 {
204        bail!("line must be greater than 0");
205    }
206    if column == 0 {
207        bail!("column must be greater than 0");
208    }
209
210    let Some((start, end)) = line_bounds(source, line) else {
211        bail!("line {line} is out of range");
212    };
213    let line_len = end.saturating_sub(start);
214    if column > line_len {
215        bail!("column {column} is out of range for line {line} ({line_len} bytes)");
216    }
217    Ok(start + column - 1)
218}
219
220fn line_bounds(source: &[u8], line: usize) -> Option<(usize, usize)> {
221    let mut current_line = 1usize;
222    let mut start = 0usize;
223    for (index, byte) in source.iter().enumerate() {
224        if *byte == b'\n' {
225            if current_line == line {
226                return Some((start, trim_cr(source, start, index)));
227            }
228            current_line += 1;
229            start = index + 1;
230        }
231    }
232    (current_line == line).then(|| (start, trim_cr(source, start, source.len())))
233}
234
235fn trim_cr(source: &[u8], start: usize, end: usize) -> usize {
236    if end > start && source[end - 1] == b'\r' {
237        end - 1
238    } else {
239        end
240    }
241}
242
243fn select_symbol(symbols: &[Symbol], target: SymbolAtTarget) -> Option<SelectedSymbol<'_>> {
244    if let Some(symbol) = symbols
245        .iter()
246        .filter(|symbol| contains_target(symbol, target))
247        .min_by(|left, right| compare_containing(left, right))
248    {
249        return Some(SelectedSymbol {
250            symbol,
251            match_kind: MatchKind::Containing,
252            distance_lines: 0,
253            distance_bytes: target.byte_offset.map(|_| 0),
254        });
255    }
256
257    symbols
258        .iter()
259        .min_by(|left, right| compare_nearest(left, right, target))
260        .map(|symbol| SelectedSymbol {
261            symbol,
262            match_kind: MatchKind::Nearest,
263            distance_lines: line_distance(symbol, target.line),
264            distance_bytes: target
265                .byte_offset
266                .map(|offset| byte_distance(symbol, offset)),
267        })
268}
269
270fn contains_target(symbol: &Symbol, target: SymbolAtTarget) -> bool {
271    if let Some(offset) = target.byte_offset {
272        return symbol.byte_start <= offset && offset < symbol.byte_end;
273    }
274    symbol.line_start <= target.line && target.line <= symbol.line_end
275}
276
277fn compare_containing(left: &Symbol, right: &Symbol) -> Ordering {
278    line_span(left)
279        .cmp(&line_span(right))
280        .then_with(|| byte_span(left).cmp(&byte_span(right)))
281        .then_with(|| right.byte_start.cmp(&left.byte_start))
282}
283
284fn compare_nearest(left: &Symbol, right: &Symbol, target: SymbolAtTarget) -> Ordering {
285    line_distance(left, target.line)
286        .cmp(&line_distance(right, target.line))
287        .then_with(|| match target.byte_offset {
288            Some(offset) => byte_distance(left, offset).cmp(&byte_distance(right, offset)),
289            None => Ordering::Equal,
290        })
291        .then_with(|| compare_previous_preference(left, right, target))
292}
293
294fn compare_previous_preference(left: &Symbol, right: &Symbol, target: SymbolAtTarget) -> Ordering {
295    match (
296        is_previous_symbol(left, target),
297        is_previous_symbol(right, target),
298    ) {
299        (true, false) => Ordering::Less,
300        (false, true) => Ordering::Greater,
301        (true, true) => right
302            .line_end
303            .cmp(&left.line_end)
304            .then_with(|| right.byte_end.cmp(&left.byte_end))
305            .then_with(|| right.byte_start.cmp(&left.byte_start)),
306        (false, false) => left
307            .line_start
308            .cmp(&right.line_start)
309            .then_with(|| left.byte_start.cmp(&right.byte_start)),
310    }
311}
312
313fn is_previous_symbol(symbol: &Symbol, target: SymbolAtTarget) -> bool {
314    if let Some(offset) = target.byte_offset {
315        if symbol.byte_end <= offset {
316            return true;
317        }
318        if symbol.byte_start > offset {
319            return false;
320        }
321    }
322    symbol.line_end < target.line
323}
324
325fn line_span(symbol: &Symbol) -> usize {
326    symbol.line_end.saturating_sub(symbol.line_start)
327}
328
329fn byte_span(symbol: &Symbol) -> usize {
330    symbol.byte_end.saturating_sub(symbol.byte_start)
331}
332
333fn line_distance(symbol: &Symbol, line: usize) -> usize {
334    if line < symbol.line_start {
335        symbol.line_start - line
336    } else {
337        line.saturating_sub(symbol.line_end)
338    }
339}
340
341fn byte_distance(symbol: &Symbol, offset: usize) -> usize {
342    if offset < symbol.byte_start {
343        symbol.byte_start - offset
344    } else if offset >= symbol.byte_end {
345        offset.saturating_sub(symbol.byte_end)
346    } else {
347        0
348    }
349}
350
351fn lookup_for_selection(
352    requested_file: String,
353    request: &ParsedLocation,
354    selected: &SelectedSymbol<'_>,
355) -> SymbolAtLookup {
356    SymbolAtLookup {
357        requested_file,
358        requested_line: request.line,
359        requested_column: request.column,
360        column_unit: request.column.map(|_| "byte"),
361        match_kind: selected.match_kind,
362        distance_lines: selected.distance_lines,
363        distance_bytes: selected.distance_bytes,
364    }
365}
366
367fn symbol_source(source: &[u8], symbol: &Symbol) -> (String, usize) {
368    let end = symbol.byte_end.min(source.len());
369    let start = symbol.byte_start.min(end);
370    let bytes = &source[start..end];
371    (String::from_utf8_lossy(bytes).to_string(), bytes.len())
372}
373
374fn symbol_at_json_value(
375    symbol: &Symbol,
376    source: &str,
377    lookup: &SymbolAtLookup,
378) -> anyhow::Result<serde_json::Value> {
379    let mut value = serde_json::to_value(symbol)?;
380    value["source"] = serde_json::Value::String(source.to_string());
381    value["lookup"] = serde_json::to_value(lookup)?;
382    Ok(value)
383}
384
385fn fallback_diagnostic(symbol: &Symbol, lookup: &SymbolAtLookup, quiet: bool) -> Option<String> {
386    if quiet || lookup.match_kind != MatchKind::Nearest {
387        return None;
388    }
389
390    let requested = match lookup.requested_column {
391        Some(column) => format!(
392            "{}:{}:{}",
393            lookup.requested_file, lookup.requested_line, column
394        ),
395        None => format!("{}:{}", lookup.requested_file, lookup.requested_line),
396    };
397    let mut distance = format!(
398        "{} {}",
399        lookup.distance_lines,
400        plural("line", lookup.distance_lines)
401    );
402    if let Some(bytes) = lookup.distance_bytes {
403        distance.push_str(&format!(", {bytes} {}", plural("byte", bytes)));
404    }
405
406    Some(format!(
407        "gcode symbol-at: no symbol contains {requested}; using nearest visible symbol {}:{} [{}] {} ({distance} away)",
408        symbol.file_path, symbol.line_start, symbol.kind, symbol.qualified_name
409    ))
410}
411
412fn plural(unit: &'static str, value: usize) -> &'static str {
413    if value == 1 {
414        unit
415    } else {
416        match unit {
417            "line" => "lines",
418            "byte" => "bytes",
419            _ => unit,
420        }
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use crate::models::Symbol;
428
429    fn symbol(
430        name: &str,
431        line_start: usize,
432        line_end: usize,
433        byte_start: usize,
434        byte_end: usize,
435    ) -> Symbol {
436        Symbol {
437            id: format!("{name}-id"),
438            project_id: "project".to_string(),
439            file_path: "src/auth.ts".to_string(),
440            name: name.to_string(),
441            qualified_name: name.to_string(),
442            kind: "function".to_string(),
443            language: "typescript".to_string(),
444            byte_start,
445            byte_end,
446            line_start,
447            line_end,
448            signature: Some(format!("function {name}()")),
449            docstring: None,
450            parent_symbol_id: None,
451            content_hash: String::new(),
452            summary: None,
453            created_at: String::new(),
454            updated_at: String::new(),
455        }
456    }
457
458    fn error_text<T>(result: anyhow::Result<T>) -> String {
459        match result {
460            Ok(_) => panic!("expected error"),
461            Err(error) => error.to_string(),
462        }
463    }
464
465    #[test]
466    fn parses_path_line_and_column_from_the_right() {
467        let line = parse_location("src:auth.ts:42", None).expect("PATH:LINE parses");
468        assert_eq!(line.file, "src:auth.ts");
469        assert_eq!(line.line, 42);
470        assert_eq!(line.column, None);
471
472        let column = parse_location("src:auth.ts:42:7", None).expect("PATH:LINE:COLUMN parses");
473        assert_eq!(column.file, "src:auth.ts");
474        assert_eq!(column.line, 42);
475        assert_eq!(column.column, Some(7));
476    }
477
478    #[test]
479    fn parses_path_with_separate_line() {
480        let parsed = parse_location("src:auth.ts", Some(42)).expect("PATH LINE parses");
481
482        assert_eq!(parsed.file, "src:auth.ts");
483        assert_eq!(parsed.line, 42);
484        assert_eq!(parsed.column, None);
485    }
486
487    #[test]
488    fn rejects_invalid_location_forms() {
489        assert!(error_text(parse_location("src/auth.ts", None)).contains("missing line"));
490        assert!(
491            error_text(parse_location("src/auth.ts:42", Some(7))).contains("line specified twice")
492        );
493        assert!(
494            error_text(parse_location("src/auth.ts:0", None))
495                .contains("line must be greater than 0")
496        );
497        assert!(
498            error_text(parse_location("src/auth.ts:42:0", None))
499                .contains("column must be greater than 0")
500        );
501        assert!(
502            error_text(parse_location("src/auth.ts:nope", None))
503                .contains("line must be a positive integer")
504        );
505        assert!(
506            error_text(parse_location("src/auth.ts:42:nope", None))
507                .contains("column must be a positive integer")
508        );
509    }
510
511    #[test]
512    fn converts_one_based_byte_columns_to_offsets() {
513        let source = "abc\néx\n".as_bytes();
514
515        assert_eq!(line_column_to_byte_offset(source, 1, 1).unwrap(), 0);
516        assert_eq!(line_column_to_byte_offset(source, 1, 3).unwrap(), 2);
517        assert_eq!(line_column_to_byte_offset(source, 2, 1).unwrap(), 4);
518        assert_eq!(line_column_to_byte_offset(source, 2, 2).unwrap(), 5);
519        assert_eq!(line_column_to_byte_offset(source, 2, 3).unwrap(), 6);
520    }
521
522    #[test]
523    fn rejects_out_of_range_columns() {
524        assert!(
525            error_text(line_column_to_byte_offset("abc\n".as_bytes(), 1, 4),)
526                .contains("column 4 is out of range")
527        );
528    }
529
530    #[test]
531    fn containing_selection_prefers_smallest_span_then_later_start() {
532        let outer = symbol("outer", 1, 10, 0, 100);
533        let earlier = symbol("earlier", 4, 4, 20, 30);
534        let later = symbol("later", 4, 4, 40, 50);
535        let symbols = vec![outer, earlier, later];
536
537        let selected = select_symbol(
538            &symbols,
539            SymbolAtTarget {
540                line: 4,
541                byte_offset: None,
542            },
543        )
544        .expect("symbol selected");
545
546        assert_eq!(selected.symbol.name, "later");
547        assert_eq!(selected.match_kind, MatchKind::Containing);
548        assert_eq!(selected.distance_lines, 0);
549    }
550
551    #[test]
552    fn nearest_selection_prefers_previous_on_equal_line_distance() {
553        let before = symbol("before", 2, 3, 20, 30);
554        let after = symbol("after", 7, 8, 70, 80);
555        let symbols = vec![before, after];
556
557        let selected = select_symbol(
558            &symbols,
559            SymbolAtTarget {
560                line: 5,
561                byte_offset: None,
562            },
563        )
564        .expect("symbol selected");
565
566        assert_eq!(selected.symbol.name, "before");
567        assert_eq!(selected.match_kind, MatchKind::Nearest);
568        assert_eq!(selected.distance_lines, 2);
569    }
570
571    #[test]
572    fn nearest_selection_uses_byte_distance_for_column_ties() {
573        let left = symbol("left", 10, 10, 10, 20);
574        let right = symbol("right", 10, 10, 24, 34);
575        let symbols = vec![left, right];
576
577        let selected = select_symbol(
578            &symbols,
579            SymbolAtTarget {
580                line: 10,
581                byte_offset: Some(23),
582            },
583        )
584        .expect("symbol selected");
585
586        assert_eq!(selected.symbol.name, "right");
587        assert_eq!(selected.match_kind, MatchKind::Nearest);
588        assert_eq!(selected.distance_lines, 0);
589        assert_eq!(selected.distance_bytes, Some(1));
590    }
591
592    #[test]
593    fn lookup_json_includes_source_and_metadata() {
594        let sym = symbol("handler", 7, 9, 0, 12);
595        let lookup = SymbolAtLookup {
596            requested_file: "src/auth.ts".to_string(),
597            requested_line: 12,
598            requested_column: Some(3),
599            column_unit: Some("byte"),
600            match_kind: MatchKind::Nearest,
601            distance_lines: 3,
602            distance_bytes: Some(8),
603        };
604
605        let value = symbol_at_json_value(&sym, "source text", &lookup).expect("json builds");
606
607        assert_eq!(value["name"], "handler");
608        assert_eq!(value["source"], "source text");
609        assert_eq!(value["lookup"]["requested_file"], "src/auth.ts");
610        assert_eq!(value["lookup"]["requested_line"], 12);
611        assert_eq!(value["lookup"]["requested_column"], 3);
612        assert_eq!(value["lookup"]["column_unit"], "byte");
613        assert_eq!(value["lookup"]["match_kind"], "nearest");
614        assert_eq!(value["lookup"]["distance_lines"], 3);
615        assert_eq!(value["lookup"]["distance_bytes"], 8);
616    }
617
618    #[test]
619    fn nearest_diagnostic_is_suppressed_when_quiet_or_containing() {
620        let sym = symbol("handler", 7, 9, 0, 12);
621        let mut lookup = SymbolAtLookup {
622            requested_file: "src/auth.ts".to_string(),
623            requested_line: 12,
624            requested_column: None,
625            column_unit: None,
626            match_kind: MatchKind::Nearest,
627            distance_lines: 3,
628            distance_bytes: None,
629        };
630
631        let diagnostic =
632            fallback_diagnostic(&sym, &lookup, false).expect("nearest emits diagnostic");
633        assert!(diagnostic.contains("using nearest visible symbol"));
634        assert!(diagnostic.contains("src/auth.ts:7"));
635
636        assert!(fallback_diagnostic(&sym, &lookup, true).is_none());
637
638        lookup.match_kind = MatchKind::Containing;
639        assert!(fallback_diagnostic(&sym, &lookup, false).is_none());
640    }
641}