1use std::collections::HashSet;
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use anyhow::Result;
24use clap::Args;
25use tree_sitter::{Node, Parser};
26use tree_sitter_python::LANGUAGE as PYTHON_LANGUAGE;
27
28use super::error::{RemainingError, RemainingResult};
29use super::types::{DefinitionResult, Location, SymbolInfo, SymbolKind};
30use crate::output::OutputWriter;
31
32use tldr_core::Language;
33
34const MAX_IMPORT_DEPTH: usize = 10;
40
41const PYTHON_BUILTINS: &[&str] = &[
43 "abs",
44 "aiter",
45 "all",
46 "any",
47 "anext",
48 "ascii",
49 "bin",
50 "bool",
51 "breakpoint",
52 "bytearray",
53 "bytes",
54 "callable",
55 "chr",
56 "classmethod",
57 "compile",
58 "complex",
59 "delattr",
60 "dict",
61 "dir",
62 "divmod",
63 "enumerate",
64 "eval",
65 "exec",
66 "filter",
67 "float",
68 "format",
69 "frozenset",
70 "getattr",
71 "globals",
72 "hasattr",
73 "hash",
74 "help",
75 "hex",
76 "id",
77 "input",
78 "int",
79 "isinstance",
80 "issubclass",
81 "iter",
82 "len",
83 "list",
84 "locals",
85 "map",
86 "max",
87 "memoryview",
88 "min",
89 "next",
90 "object",
91 "oct",
92 "open",
93 "ord",
94 "pow",
95 "print",
96 "property",
97 "range",
98 "repr",
99 "reversed",
100 "round",
101 "set",
102 "setattr",
103 "slice",
104 "sorted",
105 "staticmethod",
106 "str",
107 "sum",
108 "super",
109 "tuple",
110 "type",
111 "vars",
112 "zip",
113 "__import__",
114];
115
116pub struct DefinitionCycleDetector {
122 visited: HashSet<(PathBuf, String)>,
123}
124
125impl DefinitionCycleDetector {
126 pub fn new() -> Self {
128 Self {
129 visited: HashSet::new(),
130 }
131 }
132
133 pub fn visit(&mut self, file: &Path, symbol: &str) -> bool {
135 let key = (file.to_path_buf(), symbol.to_string());
136 !self.visited.insert(key)
137 }
138}
139
140impl Default for DefinitionCycleDetector {
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146#[derive(Debug, Args)]
166pub struct DefinitionArgs {
167 pub file: Option<PathBuf>,
169
170 pub line: Option<u32>,
172
173 pub column: Option<u32>,
175
176 #[arg(long)]
178 pub symbol: Option<String>,
179
180 #[arg(long = "file", name = "target_file")]
182 pub target_file: Option<PathBuf>,
183
184 #[arg(long)]
186 pub project: Option<PathBuf>,
187
188 #[arg(long, short = 'O')]
190 pub output: Option<PathBuf>,
191}
192
193impl DefinitionArgs {
194 pub fn run(
196 &self,
197 format: crate::output::OutputFormat,
198 quiet: bool,
199 lang: Option<Language>,
200 ) -> Result<()> {
201 let writer = OutputWriter::new(format, quiet);
202
203 let lang_hint = match lang {
205 Some(l) => format!("{:?}", l).to_lowercase(),
206 None => "auto".to_string(),
207 };
208
209 let result = if let Some(ref symbol_name) = self.symbol {
211 let file = self.target_file.as_ref().ok_or_else(|| {
213 RemainingError::invalid_argument("--file is required with --symbol")
214 })?;
215
216 writer.progress(&format!(
217 "Finding definition of '{}' in {}...",
218 symbol_name,
219 file.display()
220 ));
221
222 find_definition_by_name(symbol_name, file, self.project.as_deref(), &lang_hint)?
223 } else {
224 let file = self
226 .file
227 .as_ref()
228 .ok_or_else(|| RemainingError::invalid_argument("file argument is required"))?;
229 let line = self
230 .line
231 .ok_or_else(|| RemainingError::invalid_argument("line argument is required"))?;
232 let column = self
233 .column
234 .ok_or_else(|| RemainingError::invalid_argument("column argument is required"))?;
235
236 writer.progress(&format!(
237 "Finding definition at {}:{}:{}...",
238 file.display(),
239 line,
240 column
241 ));
242
243 match find_definition_by_position(
244 file,
245 line,
246 column,
247 self.project.as_deref(),
248 &lang_hint,
249 ) {
250 Ok(result) => result,
251 Err(_) => {
252 DefinitionResult {
254 symbol: SymbolInfo {
255 name: format!("<unknown at {}:{}:{}>", file.display(), line, column),
256 kind: SymbolKind::Variable,
257 location: Some(Location::with_column(
258 file.display().to_string(),
259 line,
260 column,
261 )),
262 type_annotation: None,
263 docstring: None,
264 is_builtin: false,
265 module: None,
266 },
267 definition: None,
268 type_definition: None,
269 }
270 }
271 }
272 };
273
274 let use_text = format == crate::output::OutputFormat::Text;
276
277 if let Some(ref output_path) = self.output {
279 if use_text {
280 let text = format_definition_text(&result);
281 fs::write(output_path, text)?;
282 } else {
283 let json = serde_json::to_string_pretty(&result)?;
284 fs::write(output_path, json)?;
285 }
286 } else if use_text {
287 let text = format_definition_text(&result);
288 writer.write_text(&text)?;
289 } else {
290 writer.write(&result)?;
291 }
292
293 Ok(())
294 }
295}
296
297pub fn find_definition_by_name(
303 symbol: &str,
304 file: &Path,
305 project: Option<&Path>,
306 lang_hint: &str,
307) -> RemainingResult<DefinitionResult> {
308 if !file.exists() {
310 return Err(RemainingError::file_not_found(file));
311 }
312
313 let language = detect_language(file, lang_hint)?;
315
316 if language != Language::Python {
318 return Err(RemainingError::unsupported_language(format!(
319 "{:?}",
320 language
321 )));
322 }
323
324 if is_builtin(symbol, &language) {
326 return Ok(DefinitionResult {
327 symbol: SymbolInfo {
328 name: symbol.to_string(),
329 kind: SymbolKind::Function,
330 location: None,
331 type_annotation: None,
332 docstring: None,
333 is_builtin: true,
334 module: Some("builtins".to_string()),
335 },
336 definition: None,
337 type_definition: None,
338 });
339 }
340
341 let source = fs::read_to_string(file).map_err(RemainingError::Io)?;
343
344 if let Some(result) = find_symbol_in_file(symbol, file, &source)? {
346 return Ok(result);
347 }
348
349 if let Some(project_root) = project {
351 let mut detector = DefinitionCycleDetector::new();
352 if let Some(result) = resolve_cross_file(symbol, file, project_root, &mut detector, 0)? {
353 return Ok(result);
354 }
355 }
356
357 Err(RemainingError::symbol_not_found(symbol, file))
358}
359
360pub fn find_definition_by_position(
362 file: &Path,
363 line: u32,
364 column: u32,
365 project: Option<&Path>,
366 lang_hint: &str,
367) -> RemainingResult<DefinitionResult> {
368 if !file.exists() {
370 return Err(RemainingError::file_not_found(file));
371 }
372
373 let language = detect_language(file, lang_hint)?;
375
376 if language != Language::Python {
378 return Err(RemainingError::unsupported_language(format!(
379 "{:?}",
380 language
381 )));
382 }
383
384 let source = fs::read_to_string(file).map_err(RemainingError::Io)?;
386
387 let symbol_name = find_symbol_at_position(&source, line, column)?;
389
390 find_definition_by_name(&symbol_name, file, project, lang_hint)
392}
393
394fn find_symbol_at_position(source: &str, line: u32, column: u32) -> RemainingResult<String> {
396 let mut parser = Parser::new();
397 parser
398 .set_language(&PYTHON_LANGUAGE.into())
399 .map_err(|e| RemainingError::parse_error(PathBuf::from("<input>"), e.to_string()))?;
400
401 let tree = parser.parse(source, None).ok_or_else(|| {
402 RemainingError::parse_error(PathBuf::from("<input>"), "Failed to parse".to_string())
403 })?;
404
405 let target_line = line.saturating_sub(1) as usize;
407 let target_col = column as usize;
408
409 let root = tree.root_node();
411 let point = tree_sitter::Point::new(target_line, target_col);
412
413 let node = root
414 .descendant_for_point_range(point, point)
415 .ok_or_else(|| {
416 RemainingError::invalid_argument(format!(
417 "No symbol found at line {}, column {}",
418 line, column
419 ))
420 })?;
421
422 let text = node.utf8_text(source.as_bytes()).map_err(|_| {
424 RemainingError::parse_error(PathBuf::from("<input>"), "Invalid UTF-8".to_string())
425 })?;
426
427 if node.kind() == "identifier" || node.kind() == "property_identifier" {
429 return Ok(text.to_string());
430 }
431
432 let mut current = Some(node);
434 while let Some(n) = current {
435 if n.kind() == "identifier" || n.kind() == "property_identifier" {
436 let text = n.utf8_text(source.as_bytes()).map_err(|_| {
437 RemainingError::parse_error(PathBuf::from("<input>"), "Invalid UTF-8".to_string())
438 })?;
439 return Ok(text.to_string());
440 }
441 current = n.parent();
442 }
443
444 Ok(text.to_string())
446}
447
448fn find_symbol_in_file(
450 symbol: &str,
451 file: &Path,
452 source: &str,
453) -> RemainingResult<Option<DefinitionResult>> {
454 let mut parser = Parser::new();
455 parser
456 .set_language(&PYTHON_LANGUAGE.into())
457 .map_err(|e| RemainingError::parse_error(file.to_path_buf(), e.to_string()))?;
458
459 let tree = parser.parse(source, None).ok_or_else(|| {
460 RemainingError::parse_error(file.to_path_buf(), "Failed to parse".to_string())
461 })?;
462
463 let root = tree.root_node();
464
465 if let Some((kind, location)) = find_definition_recursive(root, source, symbol, file) {
467 return Ok(Some(DefinitionResult {
468 symbol: SymbolInfo {
469 name: symbol.to_string(),
470 kind,
471 location: Some(location.clone()),
472 type_annotation: None,
473 docstring: None,
474 is_builtin: false,
475 module: None,
476 },
477 definition: Some(location),
478 type_definition: None,
479 }));
480 }
481
482 Ok(None)
483}
484
485fn find_definition_recursive(
487 node: Node,
488 source: &str,
489 target_name: &str,
490 file: &Path,
491) -> Option<(SymbolKind, Location)> {
492 match node.kind() {
493 "function_definition" => {
494 if let Some(name_node) = node.child_by_field_name("name") {
496 if let Ok(name) = name_node.utf8_text(source.as_bytes()) {
497 if name == target_name {
498 let in_class = is_inside_class(node);
500 let kind = if in_class {
501 SymbolKind::Method
502 } else {
503 SymbolKind::Function
504 };
505 let location = Location::with_column(
506 file.display().to_string(),
507 name_node.start_position().row as u32 + 1,
508 name_node.start_position().column as u32,
509 );
510 return Some((kind, location));
511 }
512 }
513 }
514 }
515 "class_definition" => {
516 if let Some(name_node) = node.child_by_field_name("name") {
518 if let Ok(name) = name_node.utf8_text(source.as_bytes()) {
519 if name == target_name {
520 let location = Location::with_column(
521 file.display().to_string(),
522 name_node.start_position().row as u32 + 1,
523 name_node.start_position().column as u32,
524 );
525 return Some((SymbolKind::Class, location));
526 }
527 }
528 }
529 }
530 "assignment" => {
531 if let Some(left) = node.child_by_field_name("left") {
533 if left.kind() == "identifier" {
534 if let Ok(name) = left.utf8_text(source.as_bytes()) {
535 if name == target_name {
536 let location = Location::with_column(
537 file.display().to_string(),
538 left.start_position().row as u32 + 1,
539 left.start_position().column as u32,
540 );
541 return Some((SymbolKind::Variable, location));
542 }
543 }
544 }
545 }
546 }
547 _ => {}
548 }
549
550 for i in 0..node.child_count() {
552 if let Some(child) = node.child(i) {
553 if let Some(result) = find_definition_recursive(child, source, target_name, file) {
554 return Some(result);
555 }
556 }
557 }
558
559 None
560}
561
562fn is_inside_class(node: Node) -> bool {
564 let mut current = node.parent();
565 while let Some(n) = current {
566 if n.kind() == "class_definition" {
567 return true;
568 }
569 current = n.parent();
570 }
571 false
572}
573
574fn resolve_cross_file(
576 symbol: &str,
577 current_file: &Path,
578 project_root: &Path,
579 detector: &mut DefinitionCycleDetector,
580 depth: usize,
581) -> RemainingResult<Option<DefinitionResult>> {
582 if depth >= MAX_IMPORT_DEPTH {
584 return Ok(None);
585 }
586
587 if detector.visit(current_file, symbol) {
589 return Ok(None);
590 }
591
592 let source = fs::read_to_string(current_file).map_err(RemainingError::Io)?;
594
595 let imports = extract_imports(&source);
597
598 for (module_path, imported_names) in imports {
599 let is_imported = imported_names.is_empty() || imported_names.contains(&symbol.to_string());
602
603 if is_imported {
604 if let Some(resolved_path) =
606 resolve_module_path(&module_path, current_file, project_root)
607 {
608 if resolved_path.exists() {
609 let module_source =
610 fs::read_to_string(&resolved_path).map_err(RemainingError::Io)?;
611
612 if let Some(result) =
613 find_symbol_in_file(symbol, &resolved_path, &module_source)?
614 {
615 return Ok(Some(result));
616 }
617
618 if let Some(result) = resolve_cross_file(
620 symbol,
621 &resolved_path,
622 project_root,
623 detector,
624 depth + 1,
625 )? {
626 return Ok(Some(result));
627 }
628 }
629 }
630 }
631 }
632
633 Ok(None)
634}
635
636fn extract_imports(source: &str) -> Vec<(String, Vec<String>)> {
638 let mut imports = Vec::new();
639
640 for line in source.lines() {
641 let line = line.trim();
642 if line.starts_with("from ") {
643 if let Some(import_idx) = line.find(" import ") {
644 let module = &line[5..import_idx];
645 let names_str = &line[import_idx + 8..];
646 let names: Vec<String> = names_str
647 .split(',')
648 .map(|s| {
649 s.trim()
650 .split(" as ")
651 .next()
652 .unwrap_or("")
653 .trim()
654 .to_string()
655 })
656 .filter(|s| !s.is_empty() && s != "*")
657 .collect();
658 imports.push((module.trim().to_string(), names));
659 }
660 } else if let Some(module) = line.strip_prefix("import ") {
661 let module = module.split(" as ").next().unwrap_or(module).trim();
662 imports.push((module.to_string(), Vec::new()));
663 }
664 }
665
666 imports
667}
668
669fn resolve_module_path(module: &str, current_file: &Path, project_root: &Path) -> Option<PathBuf> {
675 let current_dir = current_file.parent()?;
676
677 let dot_count = module.chars().take_while(|&c| c == '.').count();
679
680 if dot_count > 0 {
681 let remainder = &module[dot_count..];
683
684 let mut base = current_dir.to_path_buf();
689 for _ in 1..dot_count {
690 base = base.parent()?.to_path_buf();
691 }
692
693 if remainder.is_empty() {
694 let pkg_candidate = base.join("__init__.py");
696 if pkg_candidate.exists() {
697 return Some(pkg_candidate);
698 }
699 return None;
700 }
701
702 let rel_path = remainder.replace('.', "/");
704
705 let candidate = base.join(&rel_path).with_extension("py");
707 if candidate.exists() {
708 return Some(candidate);
709 }
710
711 let pkg_candidate = base.join(&rel_path).join("__init__.py");
713 if pkg_candidate.exists() {
714 return Some(pkg_candidate);
715 }
716
717 return None;
718 }
719
720 let rel_path = module.replace('.', "/");
722
723 let candidate = current_dir.join(&rel_path).with_extension("py");
725 if candidate.exists() {
726 return Some(candidate);
727 }
728
729 let pkg_candidate = current_dir.join(&rel_path).join("__init__.py");
731 if pkg_candidate.exists() {
732 return Some(pkg_candidate);
733 }
734
735 let candidate = project_root.join(&rel_path).with_extension("py");
737 if candidate.exists() {
738 return Some(candidate);
739 }
740
741 let pkg_candidate = project_root.join(&rel_path).join("__init__.py");
742 if pkg_candidate.exists() {
743 return Some(pkg_candidate);
744 }
745
746 None
747}
748
749pub fn is_builtin(name: &str, language: &Language) -> bool {
755 match language {
756 Language::Python => PYTHON_BUILTINS.contains(&name),
757 _ => false,
758 }
759}
760
761fn detect_language(file: &Path, hint: &str) -> RemainingResult<Language> {
763 if hint != "auto" {
764 return match hint.to_lowercase().as_str() {
765 "python" | "py" => Ok(Language::Python),
766 "typescript" | "ts" => Ok(Language::TypeScript),
767 "javascript" | "js" => Ok(Language::JavaScript),
768 "rust" | "rs" => Ok(Language::Rust),
769 "go" | "golang" => Ok(Language::Go),
770 _ => Err(RemainingError::unsupported_language(hint)),
771 };
772 }
773
774 let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
775 match ext {
776 "py" => Ok(Language::Python),
777 "ts" | "tsx" => Ok(Language::TypeScript),
778 "js" | "jsx" => Ok(Language::JavaScript),
779 "rs" => Ok(Language::Rust),
780 "go" => Ok(Language::Go),
781 _ => Err(RemainingError::unsupported_language(ext)),
782 }
783}
784
785fn format_definition_text(result: &DefinitionResult) -> String {
787 let mut output = String::new();
788
789 output.push_str("=== Definition Result ===\n\n");
790 output.push_str(&format!("Symbol: {}\n", result.symbol.name));
791 output.push_str(&format!("Kind: {:?}\n", result.symbol.kind));
792
793 if result.symbol.is_builtin {
794 output.push_str("Type: Built-in\n");
795 if let Some(ref module) = result.symbol.module {
796 output.push_str(&format!("Module: {}\n", module));
797 }
798 } else if let Some(ref location) = result.definition {
799 output.push_str("\nDefinition Location:\n");
800 output.push_str(&format!(" File: {}\n", location.file));
801 output.push_str(&format!(" Line: {}\n", location.line));
802 if location.column > 0 {
803 output.push_str(&format!(" Column: {}\n", location.column));
804 }
805 } else {
806 output.push_str("\nDefinition: Not found\n");
807 }
808
809 if let Some(ref type_def) = result.type_definition {
810 output.push_str("\nType Definition:\n");
811 output.push_str(&format!(" File: {}\n", type_def.file));
812 output.push_str(&format!(" Line: {}\n", type_def.line));
813 }
814
815 if let Some(ref docstring) = result.symbol.docstring {
816 output.push_str(&format!("\nDocstring:\n {}\n", docstring));
817 }
818
819 output
820}
821
822#[cfg(test)]
827mod tests {
828 use super::*;
829
830 #[test]
831 fn test_is_builtin_python() {
832 assert!(is_builtin("len", &Language::Python));
833 assert!(is_builtin("print", &Language::Python));
834 assert!(is_builtin("range", &Language::Python));
835 assert!(!is_builtin("my_func", &Language::Python));
836 }
837
838 #[test]
839 fn test_cycle_detector() {
840 let mut detector = DefinitionCycleDetector::new();
841
842 assert!(!detector.visit(Path::new("file.py"), "symbol"));
844
845 assert!(detector.visit(Path::new("file.py"), "symbol"));
847
848 assert!(!detector.visit(Path::new("other.py"), "symbol"));
850 }
851
852 #[test]
853 fn test_detect_language() {
854 assert_eq!(
855 detect_language(Path::new("test.py"), "auto").unwrap(),
856 Language::Python
857 );
858 }
859
860 #[test]
861 fn test_detect_language_with_hint() {
862 assert_eq!(
863 detect_language(Path::new("test.txt"), "python").unwrap(),
864 Language::Python
865 );
866 }
867
868 #[test]
869 fn test_extract_imports() {
870 let source = r#"
871from os import path, getcwd
872from sys import argv
873import json
874import re as regex
875"#;
876 let imports = extract_imports(source);
877
878 assert_eq!(imports.len(), 4);
879 assert_eq!(imports[0].0, "os");
880 assert!(imports[0].1.contains(&"path".to_string()));
881 assert!(imports[0].1.contains(&"getcwd".to_string()));
882 assert_eq!(imports[1].0, "sys");
883 assert!(imports[1].1.contains(&"argv".to_string()));
884 assert_eq!(imports[2].0, "json");
885 assert_eq!(imports[3].0, "re");
886 }
887
888 #[test]
889 fn test_extract_imports_relative() {
890 let source = r#"
891from .utils import echo, make_str
892from .exceptions import Abort
893from ._utils import FLAG_NEEDS_VALUE
894from . import types
895"#;
896 let imports = extract_imports(source);
897
898 assert_eq!(imports.len(), 4);
899 assert_eq!(imports[0].0, ".utils");
901 assert!(imports[0].1.contains(&"echo".to_string()));
902 assert!(imports[0].1.contains(&"make_str".to_string()));
903 assert_eq!(imports[1].0, ".exceptions");
904 assert!(imports[1].1.contains(&"Abort".to_string()));
905 assert_eq!(imports[2].0, "._utils");
906 assert!(imports[2].1.contains(&"FLAG_NEEDS_VALUE".to_string()));
907 assert_eq!(imports[3].0, ".");
908 assert!(imports[3].1.contains(&"types".to_string()));
909 }
910
911 #[test]
912 fn test_resolve_module_path_relative_import() {
913 let dir = tempfile::tempdir().unwrap();
915 let pkg = dir.path().join("mypkg");
916 fs::create_dir_all(&pkg).unwrap();
917
918 fs::write(pkg.join("__init__.py"), "").unwrap();
920 fs::write(pkg.join("core.py"), "from .utils import helper\n").unwrap();
921 fs::write(pkg.join("utils.py"), "def helper(): pass\n").unwrap();
922
923 let current_file = pkg.join("core.py");
924 let project_root = dir.path();
925
926 let resolved = resolve_module_path(".utils", ¤t_file, project_root);
928 assert!(
929 resolved.is_some(),
930 "resolve_module_path should find .utils relative to core.py"
931 );
932 assert_eq!(
933 resolved.unwrap(),
934 pkg.join("utils.py"),
935 "Should resolve to sibling utils.py"
936 );
937 }
938
939 #[test]
940 fn test_resolve_module_path_relative_import_subpackage() {
941 let dir = tempfile::tempdir().unwrap();
942 let pkg = dir.path().join("mypkg");
943 let sub = pkg.join("sub");
944 fs::create_dir_all(&sub).unwrap();
945
946 fs::write(pkg.join("__init__.py"), "").unwrap();
947 fs::write(sub.join("__init__.py"), "").unwrap();
948 fs::write(pkg.join("core.py"), "").unwrap();
949 fs::write(sub.join("helpers.py"), "def helper(): pass\n").unwrap();
950
951 let current_file = pkg.join("core.py");
952 let project_root = dir.path();
953
954 let resolved = resolve_module_path(".sub.helpers", ¤t_file, project_root);
956 assert!(
957 resolved.is_some(),
958 "resolve_module_path should find .sub.helpers relative to core.py"
959 );
960 assert_eq!(
961 resolved.unwrap(),
962 sub.join("helpers.py"),
963 "Should resolve to sub/helpers.py"
964 );
965 }
966
967 #[test]
968 fn test_cross_file_definition_via_relative_import() {
969 let dir = tempfile::tempdir().unwrap();
970 let pkg = dir.path().join("mypkg");
971 fs::create_dir_all(&pkg).unwrap();
972
973 fs::write(pkg.join("__init__.py"), "").unwrap();
974 fs::write(
975 pkg.join("core.py"),
976 "from .utils import echo\n\ndef main():\n echo('hello')\n",
977 )
978 .unwrap();
979 fs::write(pkg.join("utils.py"), "def echo(msg):\n print(msg)\n").unwrap();
980
981 let result =
983 find_definition_by_name("echo", &pkg.join("core.py"), Some(dir.path()), "python");
984
985 assert!(
986 result.is_ok(),
987 "Should find echo via cross-file resolution: {:?}",
988 result.err()
989 );
990 let result = result.unwrap();
991 assert_eq!(result.symbol.name, "echo");
992 assert_eq!(result.symbol.kind, SymbolKind::Function);
993 assert!(
994 result.definition.is_some(),
995 "Should have a definition location"
996 );
997 let def_loc = result.definition.unwrap();
998 assert!(
999 def_loc.file.contains("utils.py"),
1000 "Definition should be in utils.py, got: {}",
1001 def_loc.file
1002 );
1003 assert_eq!(def_loc.line, 1, "echo is defined on line 1 of utils.py");
1004 }
1005}