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
199fn 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}