use anyhow::Result;
const PARSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2);
use std::path::Path;
use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
pub const KINDS: [&str; 6] = [
"defines",
"calls",
"imports",
"references",
"contains",
"aliases",
];
pub const EXTRACTED: &str = "extracted";
pub const INFERRED: &str = "inferred";
pub const RESOLVED: &str = "resolved";
pub const AMBIGUOUS: &str = "ambiguous";
pub const CONFIDENCES: [&str; 4] = [EXTRACTED, RESOLVED, INFERRED, AMBIGUOUS];
pub fn confidence_rank(confidence: &str) -> usize {
CONFIDENCES
.iter()
.position(|c| *c == confidence)
.unwrap_or(CONFIDENCES.len())
}
#[derive(Debug, Clone, PartialEq)]
pub struct Symbol {
pub kind: String,
pub name: String,
pub qualified: String,
pub start_line: u32,
pub end_line: u32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Edge {
pub from: String,
pub to: String,
pub kind: String,
pub confidence: String,
pub hint: Option<String>,
pub line: Option<u32>,
}
#[derive(Debug, Clone, Default)]
pub struct Extraction {
pub symbols: Vec<Symbol>,
pub edges: Vec<Edge>,
}
pub const WITHOUT_GRAMMAR: &[(&str, &str)] = &[];
pub fn has_graph(lang: &str) -> bool {
grammar(lang).is_some()
}
pub fn languages() -> Vec<&'static str> {
crate::filter::LANGUAGES
.iter()
.map(|entry| entry.name)
.filter(|name| has_graph(name))
.collect()
}
pub fn language_of(path: &Path) -> Option<&'static str> {
let entry = crate::filter::language_of_path(&path.to_string_lossy())?;
has_graph(entry.name).then_some(entry.name)
}
fn supplement(lang: &str) -> &'static str {
match lang {
"rust" => {
r#"
(use_declaration argument: (_) @reference.import)
; `use store::Semlith as Store;` — the alias is a symbol of its own
; and the path it stands for is what it reaches. Without the edge a
; walk stops at `Store`, which is defined nowhere.
(use_as_clause
path: (_) @reference.alias
alias: (_) @name) @definition.alias
; A scoped call names the function and the thing it went through.
; Only the function earns an edge: `store::edges_out` is a call to
; `edges_out`, and `store` is what says *which* `edges_out`. Until
; 0.15.0 the path segment was emitted as a second `calls` edge,
; which recorded a call the source does not make and put a module
; name into every path the finder walked. It is a hint now.
(call_expression
function: (scoped_identifier
path: (identifier) @hint
name: (identifier) @name)) @reference.call
; A longer path, `crate::store::edges_out`: the hint is the segment
; nearest the name, because that is the one that names the module.
(call_expression
function: (scoped_identifier
path: (scoped_identifier name: (identifier) @hint)
name: (identifier) @name)) @reference.call
; A method call: `self.flush()`, `store.db()`, `self.index.search()`.
; The receiver is the hint, reduced to its last identifier.
(call_expression
function: (field_expression
value: (_) @hint
field: (field_identifier) @name)) @reference.call
"#
}
"typescript" => {
r#"
(import_statement source: (string) @reference.import)
; `import { search as find }` and `export { search as find }`.
(import_specifier
name: (_) @reference.alias
alias: (identifier) @name) @definition.alias
(export_specifier
name: (_) @reference.alias
alias: (identifier) @name) @definition.alias
(function_declaration name: (identifier) @name) @definition.function
(class_declaration name: (type_identifier) @name) @definition.class
(interface_declaration name: (type_identifier) @name) @definition.interface
(method_definition name: (property_identifier) @name) @definition.method
(variable_declarator
name: (identifier) @name
value: [(arrow_function) (function_expression)]) @definition.function
(call_expression function: (identifier) @name) @reference.call
; `store.search(...)`, `this.index.search(...)`: the object is the
; hint, reduced to its last identifier.
(call_expression
function: (member_expression
object: (_) @hint
property: (property_identifier) @name)) @reference.call
"#
}
"python" => {
r#"
(import_statement name: (_) @reference.import)
(import_from_statement module_name: (_) @reference.import)
; `import numpy as np`, `from x import search as find`.
(aliased_import
name: (_) @reference.alias
alias: (identifier) @name) @definition.alias
; `json.loads(...)`, `self.store.search(...)`: the object names the
; module or the attribute the call went through.
(call
function: (attribute
object: (_) @hint
attribute: (identifier) @name)) @reference.call
"#
}
"go" => {
r#"
(import_spec path: (interpreted_string_literal) @reference.import)
; `import fp "path/filepath"` — the alias is what the file then
; writes, and the path is what it stands for.
(import_spec
name: (package_identifier) @name
path: (interpreted_string_literal) @reference.alias) @definition.alias
"#
}
"java" => {
r#"
(import_declaration (scoped_identifier) @reference.import)
"#
}
"cpp" => {
r#"
(preproc_include path: (_) @reference.import)
(call_expression function: (identifier) @reference.call)
(function_definition
declarator: (function_declarator
declarator: (identifier) @name)) @definition.function
"#
}
"csharp" => {
r#"
(using_directive (identifier) @reference.import)
(invocation_expression function: (identifier) @name) @reference.call
; `store.Search(...)`: the object is the hint.
(invocation_expression
function: (member_access_expression
expression: (_) @hint
name: (identifier) @name)) @reference.call
"#
}
"elm" => {
r#"
(value_declaration
functionDeclarationLeft: (function_declaration_left
(lower_case_identifier) @name)) @definition.function
(value_expr name: (value_qid (lower_case_identifier) @name)) @reference.call
(import_clause moduleName: (upper_case_qid) @reference.import)
"#
}
"javascript" => {
r#"
(import_statement source: (string) @reference.import)
(import_specifier
name: (_) @reference.alias
alias: (identifier) @name) @definition.alias
(export_specifier
name: (_) @reference.alias
alias: (identifier) @name) @definition.alias
"#
}
"php" => {
r#"
(require_expression (string (string_content) @reference.import))
(include_expression (string (string_content) @reference.import))
(function_call_expression function: (name) @name) @reference.call
(member_call_expression
object: (_) @hint
name: (name) @name) @reference.call
"#
}
"swift" => {
r#"
(import_declaration (identifier) @reference.import)
(call_expression (simple_identifier) @name) @reference.call
(call_expression
(navigation_expression
target: (_) @hint
suffix: (navigation_suffix (simple_identifier) @name))) @reference.call
"#
}
"c" => {
r#"
(preproc_include path: (_) @reference.import)
(call_expression function: (identifier) @reference.call)
; C's bundled query tags the declarator, so a function's span stops
; at its signature and every call inside its body was attributed to
; the file instead of to the function that makes it. This spans the
; whole definition; the narrower duplicate is dropped by the
; contained-definition rule below.
(function_definition
declarator: (function_declarator
declarator: (identifier) @name)) @definition.function
"#
}
_ => "",
}
}
pub fn language_grammar(lang: &str) -> Option<Language> {
grammar(lang).map(|(language, _)| language)
}
fn grammar(lang: &str) -> Option<(Language, &'static str)> {
match lang {
"rust" => Some((
tree_sitter_rust::LANGUAGE.into(),
tree_sitter_rust::TAGS_QUERY,
)),
"typescript" => Some((
tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(),
tree_sitter_typescript::TAGS_QUERY,
)),
"python" => Some((
tree_sitter_python::LANGUAGE.into(),
tree_sitter_python::TAGS_QUERY,
)),
"go" => Some((tree_sitter_go::LANGUAGE.into(), tree_sitter_go::TAGS_QUERY)),
"java" => Some((
tree_sitter_java::LANGUAGE.into(),
tree_sitter_java::TAGS_QUERY,
)),
"c" => Some((tree_sitter_c::LANGUAGE.into(), tree_sitter_c::TAGS_QUERY)),
"clojure" => Some((
tree_sitter_clojure_orchard::LANGUAGE.into(),
include_str!("../queries/clojure/tags.scm"),
)),
"cpp" => Some((
tree_sitter_cpp::LANGUAGE.into(),
tree_sitter_cpp::TAGS_QUERY,
)),
"csharp" => Some((
tree_sitter_c_sharp::LANGUAGE.into(),
tree_sitter_c_sharp::TAGS_QUERY,
)),
"dart" => Some((
tree_sitter_dart::LANGUAGE.into(),
tree_sitter_dart::TAGS_QUERY,
)),
"dockerfile" => Some((
tree_sitter_containerfile::LANGUAGE.into(),
include_str!("../queries/dockerfile/tags.scm"),
)),
"elixir" => Some((
tree_sitter_elixir::LANGUAGE.into(),
tree_sitter_elixir::TAGS_QUERY,
)),
"elm" => Some((
tree_sitter_elm::LANGUAGE.into(),
tree_sitter_elm::TAGS_QUERY,
)),
"erlang" => Some((
tree_sitter_erlang::LANGUAGE.into(),
include_str!("../queries/erlang/tags.scm"),
)),
"fortran" => Some((
tree_sitter_fortran::LANGUAGE.into(),
include_str!("../queries/fortran/tags.scm"),
)),
"graphql" => Some((
tree_sitter_graphql::LANGUAGE.into(),
include_str!("../queries/graphql/tags.scm"),
)),
"groovy" => Some((
tree_sitter_groovy::LANGUAGE.into(),
include_str!("../queries/groovy/tags.scm"),
)),
"javascript" => Some((
tree_sitter_javascript::LANGUAGE.into(),
tree_sitter_javascript::TAGS_QUERY,
)),
"lua" => Some((
tree_sitter_lua::LANGUAGE.into(),
tree_sitter_lua::TAGS_QUERY,
)),
"nix" => Some((
tree_sitter_nix::LANGUAGE.into(),
include_str!("../queries/nix/tags.scm"),
)),
"ocaml" => Some((
tree_sitter_ocaml::LANGUAGE_OCAML.into(),
tree_sitter_ocaml::TAGS_QUERY,
)),
"php" => Some((
tree_sitter_php::LANGUAGE_PHP.into(),
tree_sitter_php::TAGS_QUERY,
)),
"powershell" => Some((
tree_sitter_powershell::LANGUAGE.into(),
include_str!("../queries/powershell/tags.scm"),
)),
"proto" => Some((
tree_sitter_proto::LANGUAGE.into(),
include_str!("../queries/proto/tags.scm"),
)),
"r" => Some((tree_sitter_r::LANGUAGE.into(), tree_sitter_r::TAGS_QUERY)),
"ruby" => Some((
tree_sitter_ruby::LANGUAGE.into(),
tree_sitter_ruby::TAGS_QUERY,
)),
"sql" => Some((
tree_sitter_sequel::LANGUAGE.into(),
include_str!("../queries/sql/tags.scm"),
)),
"swift" => Some((
tree_sitter_swift::LANGUAGE.into(),
tree_sitter_swift::TAGS_QUERY,
)),
"vue" => Some((
tree_sitter_vue_next::LANGUAGE.into(),
include_str!("../queries/vue/tags.scm"),
)),
"css" => Some((
tree_sitter_css::LANGUAGE.into(),
include_str!("../queries/css/tags.scm"),
)),
"haskell" => Some((
tree_sitter_haskell::LANGUAGE.into(),
include_str!("../queries/haskell/tags.scm"),
)),
"html" => Some((
tree_sitter_html::LANGUAGE.into(),
include_str!("../queries/html/tags.scm"),
)),
"json" => Some((
tree_sitter_json::LANGUAGE.into(),
include_str!("../queries/json/tags.scm"),
)),
"julia" => Some((
tree_sitter_julia::LANGUAGE.into(),
include_str!("../queries/julia/tags.scm"),
)),
"kotlin" => Some((
tree_sitter_kotlin_ng::LANGUAGE.into(),
include_str!("../queries/kotlin/tags.scm"),
)),
"makefile" => Some((
tree_sitter_make::LANGUAGE.into(),
include_str!("../queries/makefile/tags.scm"),
)),
"markdown" => Some((
tree_sitter_md::LANGUAGE.into(),
include_str!("../queries/markdown/tags.scm"),
)),
"objective-c" => Some((
tree_sitter_objc::LANGUAGE.into(),
include_str!("../queries/objective-c/tags.scm"),
)),
"perl" => Some((
ts_parser_perl::LANGUAGE.into(),
include_str!("../queries/perl/tags.scm"),
)),
"scala" => Some((
tree_sitter_scala::LANGUAGE.into(),
include_str!("../queries/scala/tags.scm"),
)),
"shell" => Some((
tree_sitter_bash::LANGUAGE.into(),
include_str!("../queries/shell/tags.scm"),
)),
"svelte" => Some((
tree_sitter_svelte_ng::LANGUAGE.into(),
include_str!("../queries/svelte/tags.scm"),
)),
"terraform" => Some((
tree_sitter_hcl::LANGUAGE.into(),
include_str!("../queries/terraform/tags.scm"),
)),
"toml" => Some((
tree_sitter_toml_ng::LANGUAGE.into(),
include_str!("../queries/toml/tags.scm"),
)),
"yaml" => Some((
tree_sitter_yaml::LANGUAGE.into(),
include_str!("../queries/yaml/tags.scm"),
)),
"zig" => Some((
tree_sitter_zig::LANGUAGE.into(),
include_str!("../queries/zig/tags.scm"),
)),
_ => None,
}
}
fn queries(lang: &'static str, language: &Language, tags: &str) -> &'static [Query] {
static CACHE: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<&'static str, &'static [Query]>>,
> = std::sync::LazyLock::new(Default::default);
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
cache.entry(lang).or_insert_with(|| {
let compiled: Vec<Query> = [tags, supplement(lang)]
.iter()
.filter(|source| !source.is_empty())
.filter_map(|source| Query::new(language, source).ok())
.collect();
Box::leak(compiled.into_boxed_slice())
})
}
struct Def {
kind: String,
name: String,
start: usize,
end: usize,
start_line: u32,
end_line: u32,
}
struct Ref {
name: String,
kind: &'static str,
at: usize,
line: u32,
hint: Option<String>,
}
pub fn extract(path: &Path, text: &str) -> Result<Option<Extraction>> {
let Some(lang) = language_of(path) else {
return Ok(None);
};
let Some((language, tags)) = grammar(lang) else {
return Ok(None);
};
let mut parser = Parser::new();
parser.set_language(&language)?;
let deadline = std::time::Instant::now() + PARSE_TIMEOUT;
let mut expired = |_: &tree_sitter::ParseState| {
if std::time::Instant::now() >= deadline {
std::ops::ControlFlow::Break(())
} else {
std::ops::ControlFlow::Continue(())
}
};
let options = tree_sitter::ParseOptions::default().progress_callback(&mut expired);
let parsed =
parser.parse_with_options(&mut |at, _| &text.as_bytes()[at..], None, Some(options));
let Some(tree) = parsed else {
return Ok(None);
};
let mut defs: Vec<Def> = Vec::new();
let mut refs: Vec<Ref> = Vec::new();
for query in queries(lang, &language, tags) {
collect(query, &tree, text, &mut defs, &mut refs);
}
defs.sort_by(|a, b| {
(a.start, std::cmp::Reverse(a.end), &a.name).cmp(&(
b.start,
std::cmp::Reverse(b.end),
&b.name,
))
});
defs.dedup_by(|a, b| a.start == b.start && a.end == b.end && a.name == b.name);
let contained: Vec<bool> = defs
.iter()
.map(|inner| {
defs.iter().any(|outer| {
outer.name == inner.name
&& outer.kind == inner.kind
&& (outer.start, outer.end) != (inner.start, inner.end)
&& outer.start <= inner.start
&& outer.end >= inner.end
})
})
.collect();
let mut keep = contained.iter();
defs.retain(|_| !keep.next().copied().unwrap_or(false));
let module = module_name(path);
let mut symbols = vec![Symbol {
kind: "module".to_string(),
name: module.clone(),
qualified: path.to_string_lossy().to_string(),
start_line: 1,
end_line: text.lines().count().max(1) as u32,
}];
let mut edges: Vec<Edge> = Vec::new();
for (i, def) in defs.iter().enumerate() {
let parent = enclosing(&defs, i, def.start).map(|p| defs[p].name.clone());
let qualified = match &parent {
Some(p) => format!("{p}::{}", def.name),
None => format!("{module}::{}", def.name),
};
symbols.push(Symbol {
kind: def.kind.clone(),
name: def.name.clone(),
qualified,
start_line: def.start_line,
end_line: def.end_line,
});
if parent.as_deref().unwrap_or(&module) == def.name {
continue;
}
match parent {
Some(p) => edges.push(Edge {
from: p,
to: def.name.clone(),
kind: "contains".to_string(),
confidence: EXTRACTED.to_string(),
hint: None,
line: Some(def.start_line),
}),
None => edges.push(Edge {
from: module.clone(),
to: def.name.clone(),
kind: "defines".to_string(),
confidence: EXTRACTED.to_string(),
hint: None,
line: Some(def.start_line),
}),
}
}
let imported: Vec<String> = refs
.iter()
.filter(|r| r.kind == "imports")
.flat_map(|r| import_names(&r.name))
.collect();
for reference in &refs {
let from = match enclosing_name(&defs, reference.at) {
Some(name) => name,
None => module.clone(),
};
if from == reference.name {
continue; }
let named_by_file = reference.kind == "imports"
|| imported.contains(&reference.name)
|| reference
.hint
.as_ref()
.is_some_and(|h| imported.contains(h));
let confidence = if named_by_file { EXTRACTED } else { INFERRED };
edges.push(Edge {
from,
to: reference.name.clone(),
kind: reference.kind.to_string(),
confidence: confidence.to_string(),
hint: reference.hint.clone(),
line: Some(reference.line),
});
}
edges.sort_by(|a, b| {
(
&a.from,
&a.to,
&a.kind,
confidence_rank(&a.confidence),
a.hint.is_none(),
&a.hint,
a.line.is_none(),
a.line,
)
.cmp(&(
&b.from,
&b.to,
&b.kind,
confidence_rank(&b.confidence),
b.hint.is_none(),
&b.hint,
b.line.is_none(),
b.line,
))
});
edges.dedup_by(|a, b| (&a.from, &a.to, &a.kind) == (&b.from, &b.to, &b.kind));
Ok(Some(Extraction { symbols, edges }))
}
fn collect(
query: &Query,
tree: &tree_sitter::Tree,
text: &str,
defs: &mut Vec<Def>,
refs: &mut Vec<Ref>,
) {
let names = query.capture_names();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(query, tree.root_node(), text.as_bytes());
while let Some(m) = matches.next() {
let mut span: Option<(&str, tree_sitter::Node)> = None;
let mut name: Option<tree_sitter::Node> = None;
let mut hint: Option<tree_sitter::Node> = None;
let mut references: Vec<(&str, tree_sitter::Node)> = Vec::new();
for capture in m.captures() {
let capture_name = names[capture.index as usize];
if let Some(kind) = capture_name.strip_prefix("definition.") {
span = Some((kind, capture.node));
} else if capture_name == "name" {
name = Some(capture.node);
} else if capture_name == "hint" {
hint = Some(capture.node);
} else if let Some(kind) = capture_name.strip_prefix("reference.") {
references.push((kind, capture.node));
}
}
for (kind, node) in references {
let kind = match kind {
"call" => "calls",
"import" => "imports",
"alias" => "aliases",
_ => "references",
};
let target = if kind == "imports" {
Some(trim_literal(&text[node.byte_range()]).to_string())
} else if kind == "aliases" {
last_segment(&text[node.byte_range()])
} else {
match name {
Some(n) => Some(text[n.byte_range()].to_string()),
None => reference_name(node, text),
}
};
let Some(target) = target.filter(|t| !t.is_empty()) else {
continue;
};
let target = if in_macro(node) {
format!("{target}!")
} else {
target
};
refs.push(Ref {
name: target,
kind,
at: node.start_byte(),
line: node.start_position().row as u32 + 1,
hint: hint.and_then(|h| hint_text(h, text)),
});
}
if let (Some((kind, node)), Some(name)) = (span, name) {
let span = definition_span(node, name);
defs.push(Def {
kind: kind.to_string(),
name: text[name.byte_range()].to_string(),
start: span.start_byte(),
end: span.end_byte(),
start_line: span.start_position().row as u32 + 1,
end_line: span.end_position().row as u32 + 1,
});
}
}
}
fn in_macro(node: tree_sitter::Node) -> bool {
const MACRO_KINDS: [&str; 2] = ["macro_invocation", "macro_expression"];
if MACRO_KINDS.contains(&node.kind()) {
return true;
}
node.parent()
.is_some_and(|parent| MACRO_KINDS.contains(&parent.kind()))
}
fn hint_text(node: tree_sitter::Node, text: &str) -> Option<String> {
const USELESS: [&str; 5] = ["self", "this", "super", "crate", "cls"];
let name = reference_name(node, text)?;
if name.is_empty() || USELESS.contains(&name.as_str()) {
return None;
}
Some(name)
}
fn reference_name(node: tree_sitter::Node, text: &str) -> Option<String> {
if let Some(function) = node.child_by_field_name("function") {
return reference_name(function, text);
}
if let Some(property) = node.child_by_field_name("property") {
return reference_name(property, text);
}
let kind = node.kind();
if kind.ends_with("identifier") || kind == "type_identifier" || kind == "word" {
return Some(text[node.byte_range()].to_string());
}
if let Some(name) = node.child_by_field_name("name") {
return reference_name(name, text);
}
let mut cursor = node.walk();
let last = node
.named_children(&mut cursor)
.filter(|c| c.kind().ends_with("identifier"))
.last();
match last {
Some(child) => Some(text[child.byte_range()].to_string()),
None if node.child_count() == 0 => Some(text[node.byte_range()].to_string()),
None => None,
}
}
fn definition_span<'a>(
captured: tree_sitter::Node<'a>,
name: tree_sitter::Node<'a>,
) -> tree_sitter::Node<'a> {
if captured.start_byte() <= name.start_byte() && captured.end_byte() > name.end_byte() {
return captured;
}
let mut best = captured;
let mut node = name;
while let Some(parent) = node.parent() {
if declaration_shaped(parent.kind()) && parent.end_byte() > best.end_byte() {
best = parent;
}
node = parent;
}
best
}
fn declaration_shaped(kind: &str) -> bool {
const MARKERS: [&str; 10] = [
"declaration",
"definition",
"_item",
"specifier",
"class",
"function",
"method",
"struct",
"interface",
"module",
];
MARKERS.iter().any(|m| kind.contains(m))
}
fn enclosing(defs: &[Def], self_index: usize, at: usize) -> Option<usize> {
let mut best: Option<usize> = None;
for (i, d) in defs.iter().enumerate() {
if i == self_index || d.start > at || d.end <= at {
continue;
}
if best.is_none_or(|b| d.end - d.start < defs[b].end - defs[b].start) {
best = Some(i);
}
}
best
}
fn enclosing_name(defs: &[Def], at: usize) -> Option<String> {
let mut best: Option<&Def> = None;
for d in defs {
if d.start > at || d.end <= at {
continue;
}
if best.is_none_or(|b| d.end - d.start < b.end - b.start) {
best = Some(d);
}
}
best.map(|d| d.name.clone())
}
fn module_name(path: &Path) -> String {
path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("module")
.to_string()
}
fn import_names(raw: &str) -> Vec<String> {
let cleaned = trim_literal(raw);
let mut out = vec![cleaned.to_string()];
if let Some(last) = cleaned
.rsplit(['/', '.', ':'])
.find(|s| !s.is_empty())
.filter(|s| *s != cleaned)
{
out.push(last.to_string());
}
out
}
fn last_segment(raw: &str) -> Option<String> {
let cleaned = trim_literal(raw);
cleaned
.rsplit(['/', '.', ':'])
.find(|s| !s.is_empty())
.map(|s| s.to_string())
.filter(|s| !s.is_empty())
}
fn trim_literal(raw: &str) -> &str {
raw.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '<' || c == '>')
.trim()
}
pub const MAX_NODES: usize = 2000;
pub const DEPENDENCY_KINDS: [&str; 4] = ["calls", "imports", "references", "aliases"];
pub fn dependency_kinds() -> Vec<String> {
DEPENDENCY_KINDS.iter().map(|k| k.to_string()).collect()
}
pub const NAVIGATIONAL_KINDS: [&str; 4] = ["heading", "key", "selector", "element"];
pub fn not_navigational(column: &str) -> String {
let holes = NAVIGATIONAL_KINDS
.iter()
.map(|k| format!("'{k}'"))
.collect::<Vec<_>>()
.join(", ");
format!("{column} NOT IN ({holes})")
}
const DAMPING: f32 = 0.85;
const ROUNDS: usize = 3;
pub fn expand(
personal: &std::collections::HashMap<String, f32>,
mut neighbours: impl FnMut(&str) -> Result<Vec<(String, f32, String)>>,
) -> Result<Vec<(String, f32, String)>> {
if personal.is_empty() {
return Ok(Vec::new());
}
let mut edges: std::collections::HashMap<String, Vec<(String, f32, String)>> =
std::collections::HashMap::new();
let mut tiers: std::collections::HashMap<String, (f32, String)> =
std::collections::HashMap::new();
let mut score = personal.clone();
for _ in 0..ROUNDS {
let mut next: std::collections::HashMap<String, f32> = personal
.iter()
.map(|(name, mass)| (name.clone(), (1.0 - DAMPING) * mass))
.collect();
for (name, mass) in &score {
if *mass <= 0.0 {
continue;
}
if !edges.contains_key(name) {
if edges.len() >= MAX_NODES {
continue;
}
edges.insert(name.clone(), neighbours(name)?);
}
let out = &edges[name];
let total: f32 = out.iter().map(|(_, weight, _)| *weight).sum();
if total <= 0.0 {
continue;
}
for (to, weight, confidence) in out {
*next.entry(to.clone()).or_default() += DAMPING * mass * weight / total;
match tiers.get(to) {
Some((best, _)) if *best >= *weight => {}
_ => {
tiers.insert(to.clone(), (*weight, confidence.clone()));
}
}
}
}
score = next;
}
let mut ranked: Vec<(String, f32, String)> = score
.into_iter()
.filter_map(|(name, mass)| {
let (weight, confidence) = tiers.get(&name)?.clone();
Some((name, mass, (weight, confidence)))
})
.map(|(name, mass, (_, confidence))| (name, mass, confidence))
.collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Ok(ranked)
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Step {
pub from: String,
pub from_path: String,
pub from_line: u32,
pub to: String,
pub to_path: String,
pub to_line: u32,
pub kind: String,
pub confidence: String,
pub definitions: usize,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Chain {
pub steps: Vec<Step>,
pub summary: Summary,
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct Summary {
pub hops: usize,
pub extracted: usize,
pub resolved: usize,
pub inferred: usize,
pub ambiguous: usize,
pub seams: usize,
pub ambiguous_names: Vec<(String, usize)>,
pub hypothesis: bool,
}
impl Summary {
fn of(steps: &[Step]) -> Self {
let mut summary = Summary {
hops: steps.len(),
..Default::default()
};
for step in steps {
match step.confidence.as_str() {
EXTRACTED => summary.extracted += 1,
RESOLVED => summary.resolved += 1,
AMBIGUOUS => summary.ambiguous += 1,
_ => summary.inferred += 1,
}
}
for pair in steps.windows(2) {
let (before, after) = (&pair[0], &pair[1]);
if (&before.to_path, before.to_line) == (&after.from_path, after.from_line) {
continue;
}
summary.seams += 1;
let name = before.to.clone();
if !summary.ambiguous_names.iter().any(|(n, _)| *n == name) {
summary
.ambiguous_names
.push((name, before.definitions.max(2)));
}
}
summary.hypothesis = summary.seams > 0
|| (!steps.is_empty()
&& steps
.iter()
.all(|s| s.confidence == INFERRED || s.confidence == AMBIGUOUS));
summary
}
}
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct Neighbours {
pub callers: Vec<crate::store::EdgeEnd>,
pub callees: Vec<crate::store::EdgeEnd>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub unresolved: Vec<crate::store::Unresolved>,
#[serde(skip_serializing_if = "is_zero")]
pub hidden: usize,
}
fn is_zero(n: &usize) -> bool {
*n == 0
}
pub fn collapse(callees: Vec<crate::store::EdgeEnd>) -> Vec<crate::store::EdgeEnd> {
let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
callees
.into_iter()
.filter(|end| {
if end.confidence != AMBIGUOUS {
return true;
}
seen.insert((end.symbol.name.clone(), end.kind.clone()))
})
.collect()
}
impl Chain {
pub fn render(&self, bold: &str, reset: &str, shorten: &dyn Fn(&str) -> String) -> String {
let mut out = String::new();
let width = self
.steps
.iter()
.map(|s| {
endpoint(&s.from, &shorten(&s.from_path), s.from_line)
.chars()
.count()
})
.max()
.unwrap_or(0);
for (i, step) in self.steps.iter().enumerate() {
let left = endpoint(&step.from, &shorten(&step.from_path), step.from_line);
let right = endpoint(&step.to, &shorten(&step.to_path), step.to_line);
let pad = " ".repeat(width.saturating_sub(left.chars().count()));
out.push_str(&format!(
"{bold}{}{reset} {left}{pad} → {right} {} · {}\n",
i + 1,
step.kind,
step.confidence,
));
if let Some(next) = self.steps.get(i + 1)
&& (&step.to_path, step.to_line) != (&next.from_path, next.from_line)
{
out.push_str(&format!(
" ── seam · {}: {} definitions · continues from {}\n",
step.to,
step.definitions.max(2),
endpoint(&next.from, &shorten(&next.from_path), next.from_line),
));
}
}
out.push_str(&self.trailer());
out.push('\n');
if self.summary.hypothesis {
out.push_str("A hypothesis, not a finding.\n");
}
out
}
pub fn trailer(&self) -> String {
let s = &self.summary;
let mut line = format!(
"{} hop{} · {} extracted · {} resolved · {} inferred · {} ambiguous",
s.hops,
if s.hops == 1 { "" } else { "s" },
s.extracted,
s.resolved,
s.inferred,
s.ambiguous,
);
if s.seams > 0 {
let names = s
.ambiguous_names
.iter()
.map(|(name, count)| format!("{name}: {count} definitions"))
.collect::<Vec<_>>()
.join(", ");
line.push_str(&format!(" · {} through ambiguous names ({names})", s.seams));
}
line
}
}
pub fn verbatim(path: &str) -> String {
path.to_string()
}
pub fn call_site(end: &crate::store::EdgeEnd, shorten: &dyn Fn(&str) -> String) -> String {
let Some(line) = end.line else {
return String::new();
};
match &end.from_path {
Some(path) => format!(" · called at {}:{line}", shorten(path)),
None => format!(" · call at line {line}"),
}
}
fn endpoint(name: &str, path: &str, line: u32) -> String {
if path.is_empty() {
return name.to_string();
}
format!("{name} @ {path}:{line}")
}
pub fn not_connected(from: &str, to: &str, depth: u32, all_edges: bool) -> String {
if all_edges {
format!("{from} and {to} are not connected within {depth} hops by any edge in the store.")
} else {
format!(
"{from} and {to} are not connected within {depth} hops by resolved edges. \
Ambiguous names were not crossed; --all-edges walks them and labels what it finds."
)
}
}
pub fn neighbours(
db: &rusqlite::Connection,
name: &str,
kinds: &[String],
all: bool,
) -> Result<Neighbours> {
let callees = crate::store::edges_out(db, name, kinds)?;
let unresolved = crate::store::unresolved_out(db, name, kinds)?;
Ok(Neighbours {
callers: crate::store::edges_in(db, name, kinds)?,
callees: if all { callees } else { collapse(callees) },
hidden: if all { 0 } else { unresolved.len() },
unresolved: if all { unresolved } else { Vec::new() },
})
}
const EGO_LIMIT: usize = 12;
#[derive(Debug, Clone, serde::Serialize)]
pub struct Hop {
pub via: String,
pub name: String,
pub kind: String,
pub confidence: String,
pub direction: &'static str,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Evidence {
pub name: String,
pub definitions: Vec<crate::store::SymbolRow>,
pub callers: Vec<crate::store::EdgeEnd>,
pub callees: Vec<crate::store::EdgeEnd>,
pub ego: Vec<Hop>,
}
pub fn evidence(
db: &rusqlite::Connection,
name: &str,
kinds: &[String],
limit: usize,
all: bool,
) -> Result<Evidence> {
let definitions = crate::store::symbols_named(db, name, limit)?;
let ring = neighbours(db, name, kinds, all)?;
let mut ego: Vec<Hop> = Vec::new();
let settled: Vec<(&str, &crate::store::EdgeEnd)> = ring
.callers
.iter()
.map(|e| ("caller", e))
.chain(ring.callees.iter().map(|e| ("callee", e)))
.filter(|(_, e)| e.confidence == EXTRACTED || e.confidence == RESOLVED)
.collect();
for (_, first) in &settled {
if ego.len() >= EGO_LIMIT {
break;
}
let out = crate::store::edges_out(db, &first.symbol.name, kinds)?;
let into = crate::store::edges_in(db, &first.symbol.name, kinds)?;
for (direction, end) in out
.into_iter()
.map(|e| ("callee", e))
.chain(into.into_iter().map(|e| ("caller", e)))
{
if ego.len() >= EGO_LIMIT {
break;
}
if end.symbol.name == name
|| ring
.callers
.iter()
.chain(ring.callees.iter())
.any(|e| e.symbol.name == end.symbol.name)
|| ego.iter().any(|h| h.name == end.symbol.name)
{
continue;
}
ego.push(Hop {
via: first.symbol.name.clone(),
name: end.symbol.name,
kind: end.kind,
confidence: end.confidence,
direction,
});
}
}
Ok(Evidence {
name: name.to_string(),
definitions,
callers: ring.callers,
callees: ring.callees,
ego,
})
}
impl Evidence {
pub fn render(&self, bold: &str, reset: &str, shorten: &dyn Fn(&str) -> String) -> String {
let mut out = String::new();
for symbol in &self.definitions {
out.push_str(&format!(
"{bold}{}{reset} {} {}:{}-{}\n",
symbol.name,
symbol.kind,
shorten(&symbol.path),
symbol.start_line,
symbol.end_line,
));
}
if self.definitions.len() > 1 {
out.push_str(&format!(
"{} definitions of this name\n",
self.definitions.len()
));
}
for (heading, ends) in [("callers", &self.callers), ("callees", &self.callees)] {
out.push_str(&format!("\n{bold}{heading}{reset} ({})\n", ends.len()));
if ends.is_empty() {
out.push_str(" none\n");
}
for end in ends.iter() {
if end.confidence == AMBIGUOUS {
out.push_str(&format!(
" {} via {} ({}) · {} definitions{}\n",
end.symbol.name,
end.kind,
end.confidence,
end.definitions,
call_site(end, shorten),
));
continue;
}
out.push_str(&format!(
" {} via {} ({}) {}:{}{}\n",
end.symbol.name,
end.kind,
end.confidence,
shorten(&end.symbol.path),
end.symbol.start_line,
call_site(end, shorten),
));
}
}
if !self.ego.is_empty() {
out.push_str(&format!("\n{bold}two hops out{reset}\n"));
for hop in &self.ego {
out.push_str(&format!(
" {} · {} of {} ({})\n",
hop.name, hop.direction, hop.via, hop.confidence,
));
}
}
out.trim_end().to_string()
}
}
pub fn shortest_path(
db: &rusqlite::Connection,
from: &str,
to: &str,
depth: u32,
all_edges: bool,
) -> Result<Option<Chain>> {
if from == to {
return Ok(Some(Chain {
steps: Vec::new(),
summary: Summary::default(),
}));
}
let mut came_from: std::collections::HashMap<Node, Step> = std::collections::HashMap::new();
let mut seen: std::collections::HashSet<Node> = std::collections::HashSet::new();
let start = Node::any(from);
seen.insert(start.clone());
let mut frontier = vec![start];
let kinds = dependency_kinds();
for _ in 0..depth {
let mut level: Vec<(Node, crate::store::EdgeEnd)> = Vec::new();
for current in &frontier {
for edge in crate::store::edges_out(db, ¤t.name, &kinds)? {
if !all_edges {
if edge.confidence == AMBIGUOUS {
continue;
}
if !current.holds(edge.from_path.as_deref(), edge.from_line) {
continue;
}
}
level.push((current.clone(), edge));
}
}
level.sort_by_key(|(_, edge)| confidence_rank(&edge.confidence));
let mut next = Vec::new();
for (current, edge) in level {
let reached = if all_edges {
Node::any(&edge.symbol.name)
} else {
Node {
name: edge.symbol.name.clone(),
path: edge.symbol.path.clone(),
line: edge.symbol.start_line,
}
};
if !seen.insert(reached.clone()) {
continue;
}
came_from.insert(
reached.clone(),
Step {
from: current.name.clone(),
from_path: edge.from_path.unwrap_or_default(),
from_line: edge.from_line.unwrap_or(0),
to: reached.name.clone(),
to_path: edge.symbol.path,
to_line: edge.symbol.start_line,
kind: edge.kind,
confidence: edge.confidence,
definitions: edge.definitions,
},
);
if reached.name == to {
let steps = unwind(&came_from, from, &reached);
let summary = Summary::of(&steps);
return Ok(Some(Chain { steps, summary }));
}
next.push(reached);
if seen.len() >= MAX_NODES {
return Ok(None);
}
}
if next.is_empty() {
break;
}
frontier = next;
}
Ok(None)
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Node {
name: String,
path: String,
line: u32,
}
impl Node {
fn any(name: &str) -> Self {
Node {
name: name.to_string(),
path: String::new(),
line: 0,
}
}
fn holds(&self, path: Option<&str>, line: Option<u32>) -> bool {
if self.path.is_empty() {
return true;
}
match (path, line) {
(Some(path), Some(line)) => path == self.path && line == self.line,
_ => true,
}
}
}
pub fn scoped(
stores: &[(&str, &crate::Semlith)],
focus: Option<&str>,
prefix: Option<&str>,
limit: usize,
label_stores: bool,
) -> Result<serde_json::Value> {
let mut nodes: Vec<crate::store::SymbolRow> = Vec::new();
let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut edges: Vec<serde_json::Value> = Vec::new();
let mut total = 0usize;
for (label, store) in stores {
let db = store.db();
let picked = match focus {
Some(name) => {
let mut around = crate::store::symbols_named(db, name, 4)?;
for end in crate::store::edges_in(db, name, &[])?
.into_iter()
.chain(crate::store::edges_out(db, name, &[])?)
{
if around.len() >= limit {
break;
}
around.push(end.symbol);
}
around
}
None => crate::store::symbols_scoped(db, prefix, limit)?,
};
total += crate::store::graph_stats(db)?.0 as usize;
for mut symbol in picked {
if nodes.len() >= limit {
break;
}
if index.contains_key(&symbol.name) {
continue;
}
if label_stores {
symbol.store = Some((*label).to_string());
}
index.insert(symbol.name.clone(), nodes.len());
nodes.push(symbol);
}
}
for (from_name, from_index) in &index {
for (_, store) in stores {
for end in crate::store::edges_out(store.db(), from_name, &[])? {
let Some(to_index) = index.get(&end.symbol.name) else {
continue;
};
if from_index == to_index {
continue;
}
edges.push(serde_json::json!({
"from": from_index,
"to": to_index,
"kind": end.kind,
"confidence": end.confidence,
}));
}
}
}
Ok(serde_json::json!({
"nodes": nodes,
"edges": edges,
"total": total,
"shown": nodes.len(),
}))
}
fn unwind(came_from: &std::collections::HashMap<Node, Step>, start: &str, end: &Node) -> Vec<Step> {
let mut chain = Vec::new();
let mut cursor = end.clone();
while cursor.name != start {
let Some(step) = came_from
.get(&cursor)
.or_else(|| came_from.get(&Node::any(&cursor.name)))
else {
break;
};
chain.push(step.clone());
cursor = Node {
name: step.from.clone(),
path: step.from_path.clone(),
line: step.from_line,
};
if cursor.name == start {
break;
}
}
chain.reverse();
chain
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn run(name: &str, src: &str) -> Extraction {
extract(&PathBuf::from(name), src)
.expect("extraction does not fail")
.expect("the extension carries a grammar")
}
fn has_edge(e: &Extraction, from: &str, to: &str, kind: &str) -> bool {
e.edges
.iter()
.any(|x| x.from == from && x.to == to && x.kind == kind)
}
fn confidence_of(e: &Extraction, to: &str, kind: &str) -> String {
e.edges
.iter()
.find(|x| x.to == to && x.kind == kind)
.map(|x| x.confidence.clone())
.unwrap_or_else(|| panic!("no {kind} edge to {to} in {:?}", e.edges))
}
#[test]
fn a_macro_invocation_is_recorded_under_the_name_the_source_writes() {
let e = run(
"a.rs",
"fn go() { let s = format!(\"{}\", 1); println!(\"{s}\"); helper(); }\nfn helper() {}\n",
);
assert!(
!has_edge(&e, "go", "format", "calls"),
"a `format!` is not a call to `fn format`: {:?}",
e.edges
);
assert!(has_edge(&e, "go", "format!", "calls"), "{:?}", e.edges);
assert!(has_edge(&e, "go", "println!", "calls"), "{:?}", e.edges);
assert!(
has_edge(&e, "go", "helper", "calls"),
"an ordinary call is untouched: {:?}",
e.edges
);
}
#[test]
fn a_file_with_no_grammar_yields_nothing_rather_than_failing() {
assert!(
extract(&PathBuf::from("notes.txt"), "the lock is acquired")
.unwrap()
.is_none()
);
}
#[test]
fn rust_carries_every_edge_kind_it_can_express() {
let e = run(
"lock.rs",
"use std::fs::File;\n\
struct Guard;\n\
impl Guard {\n fn open(&self) { File::create(\"x\"); helper(); }\n}\n\
fn helper() {}\n",
);
assert!(e.symbols.iter().any(|s| s.name == "Guard"));
assert!(e.symbols.iter().any(|s| s.name == "open"));
assert!(has_edge(&e, "lock", "helper", "defines"), "{:?}", e.edges);
assert!(has_edge(&e, "open", "helper", "calls"), "{:?}", e.edges);
assert!(
e.edges.iter().any(|x| x.kind == "imports"),
"the supplementary query supplies imports: {:?}",
e.edges
);
}
#[test]
fn an_imported_name_is_extracted_and_a_bare_one_is_inferred() {
let e = run(
"a.rs",
"use std::fs::File;\nfn go() { File::create(\"x\"); helper(); }\nfn helper() {}\n",
);
assert_eq!(confidence_of(&e, "create", "calls"), EXTRACTED);
assert_eq!(confidence_of(&e, "helper", "calls"), INFERRED);
}
fn hint_of(e: &Extraction, to: &str, kind: &str) -> Option<String> {
e.edges
.iter()
.find(|x| x.to == to && x.kind == kind)
.unwrap_or_else(|| panic!("no {kind} edge to {to} in {:?}", e.edges))
.hint
.clone()
}
#[test]
fn a_scoped_rust_call_hints_at_its_module_and_does_not_call_it() {
let e = run("a.rs", "fn go() { store::edges_out(db, name); }\n");
assert!(has_edge(&e, "go", "edges_out", "calls"), "{:?}", e.edges);
assert_eq!(hint_of(&e, "edges_out", "calls").as_deref(), Some("store"));
assert!(
!has_edge(&e, "go", "store", "calls"),
"the module is not called: {:?}",
e.edges
);
}
#[test]
fn a_longer_rust_path_hints_with_the_segment_nearest_the_name() {
let e = run("a.rs", "fn go() { crate::store::edges_out(db); }\n");
assert_eq!(hint_of(&e, "edges_out", "calls").as_deref(), Some("store"));
}
#[test]
fn a_rust_method_call_hints_with_its_receiver() {
let e = run(
"a.rs",
"fn go(&self) { self.index.search(v); self.flush(); }\n",
);
assert_eq!(hint_of(&e, "search", "calls").as_deref(), Some("index"));
assert_eq!(hint_of(&e, "flush", "calls"), None);
}
#[test]
fn python_and_typescript_qualified_calls_carry_the_object_as_a_hint() {
let py = run("m.py", "def go():\n json.loads(raw)\n");
assert_eq!(hint_of(&py, "loads", "calls").as_deref(), Some("json"));
let ts = run("app.ts", "function go(){ store.search(q); }\n");
assert_eq!(hint_of(&ts, "search", "calls").as_deref(), Some("store"));
}
#[test]
fn the_hinted_copy_of_a_doubly_captured_call_is_the_one_kept() {
let e = run("a.rs", "fn go() { store::edges_out(db); }\n");
let calls: Vec<&Edge> = e
.edges
.iter()
.filter(|x| x.to == "edges_out" && x.kind == "calls")
.collect();
assert_eq!(calls.len(), 1, "{:?}", e.edges);
assert_eq!(calls[0].hint.as_deref(), Some("store"));
}
#[test]
fn nesting_produces_contains_rather_than_defines() {
let e = run("m.py", "class Box:\n def open(self):\n pass\n");
assert!(has_edge(&e, "Box", "open", "contains"), "{:?}", e.edges);
assert!(has_edge(&e, "m", "Box", "defines"), "{:?}", e.edges);
}
#[test]
fn typescript_calls_come_from_the_supplement() {
let e = run(
"app.ts",
"import {a} from './m';\nfunction f(){ helper(); obj.go(); }\nfunction helper(){}\n",
);
assert!(has_edge(&e, "f", "helper", "calls"), "{:?}", e.edges);
assert!(has_edge(&e, "f", "go", "calls"), "{:?}", e.edges);
assert!(e.edges.iter().any(|x| x.kind == "imports"));
}
#[test]
fn c_calls_and_includes_come_from_the_supplement() {
let e = run(
"main.c",
"#include <stdio.h>\nint helper(){ return 1; }\nint main(){ return helper(); }\n",
);
assert!(has_edge(&e, "main", "helper", "calls"), "{:?}", e.edges);
assert!(
e.edges
.iter()
.any(|x| x.kind == "imports" && x.to == "stdio.h"),
"{:?}",
e.edges
);
}
#[test]
fn python_go_and_java_each_yield_symbols_and_edges() {
let py = run(
"m.py",
"import os\ndef go():\n helper()\ndef helper():\n pass\n",
);
assert!(has_edge(&py, "go", "helper", "calls"), "{:?}", py.edges);
assert!(py.edges.iter().any(|x| x.kind == "imports" && x.to == "os"));
let go = run(
"m.go",
"package m\nimport \"fmt\"\nfunc helper() {}\nfunc Run() { helper() }\n",
);
assert!(has_edge(&go, "Run", "helper", "calls"), "{:?}", go.edges);
assert!(
go.edges
.iter()
.any(|x| x.kind == "imports" && x.to == "fmt")
);
let java = run(
"A.java",
"import java.util.List;\nclass A { void go(){ helper(); } void helper(){} }\n",
);
assert!(has_edge(&java, "go", "helper", "calls"), "{:?}", java.edges);
assert!(java.edges.iter().any(|x| x.kind == "imports"));
assert!(has_edge(&java, "A", "go", "contains"), "{:?}", java.edges);
}
struct Fixture {
language: &'static str,
file: &'static str,
symbols: &'static [(&'static str, &'static str)],
edges: &'static [(&'static str, &'static str, &'static str)],
}
const FIXTURES: &[Fixture] = &[
Fixture {
language: "rust",
file: "rust/lock.rs",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[
("acquire", "helper", "calls"),
("lock", "std::fs::File", "imports"),
],
},
Fixture {
language: "typescript",
file: "typescript/app.ts",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls"), ("app", "./m", "imports")],
},
Fixture {
language: "python",
file: "python/run.py",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls"), ("run", "os", "imports")],
},
Fixture {
language: "go",
file: "go/serve.go",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls"), ("serve", "fmt", "imports")],
},
Fixture {
language: "java",
file: "java/Main.java",
symbols: &[
("Main", "class"),
("helper", "method"),
("acquire", "method"),
],
edges: &[
("acquire", "helper", "calls"),
("Main", "helper", "contains"),
],
},
Fixture {
language: "c",
file: "c/main.c",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "clojure",
file: "clojure/lock.clj",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "cpp",
file: "cpp/lock.cpp",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "csharp",
file: "csharp/Lock.cs",
symbols: &[
("Lock", "class"),
("Helper", "method"),
("Acquire", "method"),
],
edges: &[("Acquire", "Helper", "calls")],
},
Fixture {
language: "css",
file: "css/lock.css",
symbols: &[("acquire", "selector"), ("lock", "selector")],
edges: &[("lock", "base.css", "imports")],
},
Fixture {
language: "dart",
file: "dart/lock.dart",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "dockerfile",
file: "dockerfile/Dockerfile",
symbols: &[("build", "stage"), ("runtime", "stage")],
edges: &[
("build", "debian", "imports"),
("runtime", "build", "imports"),
],
},
Fixture {
language: "elixir",
file: "elixir/lock.ex",
symbols: &[
("Lock", "module"),
("helper", "function"),
("acquire", "function"),
],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "elm",
file: "elm/Lock.elm",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "erlang",
file: "erlang/lock.erl",
symbols: &[
("lock", "module"),
("helper", "function"),
("acquire", "function"),
],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "fortran",
file: "fortran/lock.f90",
symbols: &[
("lock", "module"),
("helper", "function"),
("acquire", "function"),
],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "graphql",
file: "graphql/lock.graphql",
symbols: &[
("Lock", "type"),
("Holder", "type"),
("Acquire", "operation"),
],
edges: &[("Lock", "holder", "contains")],
},
Fixture {
language: "groovy",
file: "groovy/lock.groovy",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "haskell",
file: "haskell/Lock.hs",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "html",
file: "html/lock.html",
symbols: &[("acquire", "element")],
edges: &[
("lock", "base.css", "imports"),
("lock", "app.js", "imports"),
],
},
Fixture {
language: "javascript",
file: "javascript/app.js",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls"), ("app", "./m", "imports")],
},
Fixture {
language: "json",
file: "json/lock.json",
symbols: &[("name", "key"), ("acquire", "key"), ("timeout", "key")],
edges: &[("acquire", "timeout", "contains")],
},
Fixture {
language: "julia",
file: "julia/lock.jl",
symbols: &[
("Lock", "module"),
("helper", "function"),
("acquire", "function"),
],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "kotlin",
file: "kotlin/Lock.kt",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "lua",
file: "lua/lock.lua",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "makefile",
file: "makefile/Makefile",
symbols: &[("helper", "target"), ("acquire", "target")],
edges: &[
("acquire", "helper", "references"),
("Makefile", "config.mk", "imports"),
],
},
Fixture {
language: "markdown",
file: "markdown/notes.md",
symbols: &[("Lock", "heading"), ("Acquire", "heading")],
edges: &[("Lock", "Acquire", "contains")],
},
Fixture {
language: "nix",
file: "nix/lock.nix",
symbols: &[("helper", "binding"), ("acquire", "binding")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "objective-c",
file: "objective-c/Lock.m",
symbols: &[
("Lock", "class"),
("helper", "method"),
("acquire", "method"),
],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "ocaml",
file: "ocaml/lock.ml",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "perl",
file: "perl/lock.pl",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "php",
file: "php/lock.php",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "powershell",
file: "powershell/Lock.ps1",
symbols: &[("Get-Helper", "function"), ("Get-Acquire", "function")],
edges: &[("Get-Acquire", "Get-Helper", "calls")],
},
Fixture {
language: "proto",
file: "proto/lock.proto",
symbols: &[("Lock", "message"), ("Acquire", "service"), ("Hold", "rpc")],
edges: &[("lock", "base.proto", "imports")],
},
Fixture {
language: "r",
file: "r/lock.r",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "ruby",
file: "ruby/lock.rb",
symbols: &[("helper", "method"), ("acquire", "method")],
edges: &[("lock", "helper", "defines")],
},
Fixture {
language: "scala",
file: "scala/Lock.scala",
symbols: &[
("Lock", "object"),
("helper", "function"),
("acquire", "function"),
],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "shell",
file: "shell/lock.sh",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[
("acquire", "helper", "calls"),
("lock", "./config.sh", "imports"),
],
},
Fixture {
language: "sql",
file: "sql/lock.sql",
symbols: &[("lock_holder", "table"), ("acquire", "view")],
edges: &[("acquire", "lock_holder", "references")],
},
Fixture {
language: "svelte",
file: "svelte/Lock.svelte",
symbols: &[("lock", "element")],
edges: &[("Lock", "lock", "defines")],
},
Fixture {
language: "swift",
file: "swift/Lock.swift",
symbols: &[("helper", "function"), ("acquire", "function")],
edges: &[("acquire", "helper", "calls")],
},
Fixture {
language: "terraform",
file: "terraform/lock.tf",
symbols: &[("region", "block"), ("base", "block"), ("lock", "block")],
edges: &[("lock", "region", "defines")],
},
Fixture {
language: "toml",
file: "toml/lock.toml",
symbols: &[("name", "key"), ("acquire", "table")],
edges: &[("acquire", "timeout", "contains")],
},
Fixture {
language: "vue",
file: "vue/Lock.vue",
symbols: &[("lock", "element")],
edges: &[("Lock", "lock", "defines")],
},
Fixture {
language: "yaml",
file: "yaml/lock.yaml",
symbols: &[("name", "key"), ("acquire", "key"), ("timeout", "key")],
edges: &[("acquire", "timeout", "contains")],
},
Fixture {
language: "zig",
file: "zig/lock.zig",
symbols: &[
("helper", "function"),
("acquire", "function"),
("std", "variable"),
],
edges: &[("acquire", "helper", "calls")],
},
];
fn fixture_root() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/graph")
}
#[test]
#[ignore = "a tool for writing queries, not a gate"]
fn dump_fixture_trees() {
let root = fixture_root();
let mut languages: Vec<_> = crate::filter::LANGUAGES.iter().map(|e| e.name).collect();
languages.sort_unstable();
for name in languages {
let dir = root.join(name);
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries {
let path = entry.unwrap().path();
let text = std::fs::read_to_string(&path).unwrap();
let Some((language, _)) = grammar(name) else {
continue;
};
let mut parser = Parser::new();
parser.set_language(&language).unwrap();
let tree = parser.parse(&text, None).unwrap();
println!(
"===== {name} :: {} =====\n{}",
path.file_name().unwrap().to_string_lossy(),
tree.root_node().to_sexp()
);
}
}
}
#[test]
#[ignore = "a tool for writing queries, not a gate"]
fn dump_fixture_extractions() {
let root = fixture_root();
for fixture in FIXTURES {
let text = std::fs::read_to_string(root.join(fixture.file)).unwrap();
let e = extract(std::path::Path::new(fixture.file), &text)
.unwrap()
.unwrap();
println!("===== {} =====", fixture.language);
for s in &e.symbols {
println!(
" symbol {:14} {:24} {}-{}",
s.kind, s.name, s.start_line, s.end_line
);
}
for x in &e.edges {
println!(
" edge {:14} {} -> {} (line {:?})",
x.kind, x.from, x.to, x.line
);
}
}
}
#[test]
fn every_advertised_language_has_a_fixture() {
for entry in crate::filter::LANGUAGES {
if WITHOUT_GRAMMAR.iter().any(|(name, _)| *name == entry.name) {
continue;
}
assert!(
FIXTURES.iter().any(|f| f.language == entry.name),
"{} has a grammar but no fixture asserting what it extracts",
entry.name
);
}
}
#[test]
fn every_fixture_yields_what_it_declares() {
let root = fixture_root();
let mut wrong: Vec<String> = Vec::new();
for fixture in FIXTURES {
let path = root.join(fixture.file);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("{} is missing: {e}", path.display()));
let extraction = extract(std::path::Path::new(fixture.file), &text)
.unwrap_or_else(|e| panic!("{}: extraction failed: {e}", fixture.language))
.unwrap_or_else(|| {
panic!(
"{}: no extractor reached {} — the extension is not in the table",
fixture.language, fixture.file
)
});
for (name, kind) in fixture.symbols {
if !extraction
.symbols
.iter()
.any(|s| s.name == *name && s.kind == *kind)
{
wrong.push(format!(
"{}: no {kind} named {name}; found {:?}",
fixture.language,
extraction
.symbols
.iter()
.map(|s| (&s.kind, &s.name))
.collect::<Vec<_>>()
));
}
}
for (from, to, kind) in fixture.edges {
if !extraction
.edges
.iter()
.any(|e| e.from == *from && e.to == *to && e.kind == *kind)
{
wrong.push(format!(
"{}: no {kind} edge {from} -> {to}; found {:?}",
fixture.language,
extraction
.edges
.iter()
.map(|e| (&e.from, &e.kind, &e.to))
.collect::<Vec<_>>()
));
}
}
}
assert!(wrong.is_empty(), "{}", wrong.join("\n"));
}
#[test]
fn every_advertised_language_has_a_working_grammar() {
let mut broken: Vec<String> = Vec::new();
for entry in crate::filter::LANGUAGES {
let lang = entry.name;
if let Some((_, why)) = WITHOUT_GRAMMAR.iter().find(|(name, _)| *name == lang) {
assert!(
grammar(lang).is_none(),
"{lang} is listed as having no grammar ({why}) but has one"
);
continue;
}
let Some((language, tags)) = grammar(lang) else {
broken.push(format!("{lang}: advertised but has no grammar"));
continue;
};
let mut parser = Parser::new();
if let Err(e) = parser.set_language(&language) {
broken.push(format!("{lang}: grammar is ABI-incompatible: {e}"));
continue;
}
if let Err(e) = Query::new(&language, tags) {
broken.push(format!("{lang}: tags query does not compile: {e:?}"));
}
let supplement = supplement(lang);
if !supplement.is_empty()
&& let Err(e) = Query::new(&language, supplement)
{
broken.push(format!("{lang}: supplement does not compile: {e:?}"));
}
}
assert!(broken.is_empty(), "{}", broken.join("\n"));
}
#[test]
fn a_file_that_does_not_parse_yields_what_it_could_read() {
let e = run("a.rs", "fn good() {}\nfn broken( {\n");
assert!(e.symbols.iter().any(|s| s.name == "good"));
}
#[test]
fn a_reference_edge_carries_the_line_the_call_was_written_on() {
let e = run(
"a.rs",
"use other::helper;\n\nfn caller() {\n let x = 1;\n let _ = x;\n helper();\n}\n",
);
let call = e
.edges
.iter()
.find(|x| x.kind == "calls" && x.to == "helper")
.unwrap_or_else(|| panic!("no call edge: {:?}", e.edges));
assert_eq!(call.line, Some(6), "{:?}", e.edges);
let import = e
.edges
.iter()
.find(|x| x.kind == "imports")
.unwrap_or_else(|| panic!("no import edge: {:?}", e.edges));
assert_eq!(import.line, Some(1), "{:?}", e.edges);
assert!(e.edges.iter().all(|x| x.line.is_some()), "{:?}", e.edges);
}
#[test]
fn an_alias_becomes_a_symbol_and_an_edge_to_what_it_stands_for() {
let rust = run("a.rs", "pub use store::Semlith as Store;\n");
assert!(
rust.symbols.iter().any(|s| s.name == "Store"),
"{:?}",
rust.symbols
);
assert!(
has_edge(&rust, "Store", "Semlith", "aliases"),
"{:?}",
rust.edges
);
let py = run("a.py", "from x import search as find\n");
assert!(has_edge(&py, "find", "search", "aliases"), "{:?}", py.edges);
let ts = run("a.ts", "import { search as find } from './x';\n");
assert!(has_edge(&ts, "find", "search", "aliases"), "{:?}", ts.edges);
let go = run("a.go", "package m\nimport fp \"path/filepath\"\n");
assert!(has_edge(&go, "fp", "filepath", "aliases"), "{:?}", go.edges);
}
#[test]
fn a_re_export_that_does_not_rename_adds_no_alias_edge() {
let e = run("a.rs", "pub use store::Semlith;\n");
assert!(
!e.edges.iter().any(|x| x.kind == "aliases"),
"{:?}",
e.edges
);
}
#[test]
fn a_symbol_three_seeds_agree_on_outranks_one_a_single_seed_reached() {
let adjacency: std::collections::HashMap<&str, Vec<&str>> = [
("a", vec!["mid_a"]),
("b", vec!["mid_b"]),
("c", vec!["mid_c"]),
("mid_a", vec!["hub"]),
("mid_b", vec!["hub"]),
("mid_c", vec!["hub"]),
("weak", vec!["near"]),
("hub", vec![]),
("near", vec![]),
]
.into_iter()
.collect();
let personal: std::collections::HashMap<String, f32> = [
("a".to_string(), 1.0),
("b".to_string(), 1.0),
("c".to_string(), 1.0),
("weak".to_string(), 0.2),
]
.into_iter()
.collect();
let ranked = expand(&personal, |name| {
Ok(adjacency
.get(name)
.into_iter()
.flatten()
.map(|to| (to.to_string(), 1.0, EXTRACTED.to_string()))
.collect())
})
.unwrap();
let place = |name: &str| ranked.iter().position(|(n, _, _)| n == name);
let hub = place("hub").unwrap_or_else(|| panic!("hub was not reached: {ranked:?}"));
let near = place("near").unwrap_or_else(|| panic!("near was not reached: {ranked:?}"));
assert!(
hub < near,
"two hops from three seeds must outrank one hop from a weak seed: {ranked:?}"
);
assert!(
ranked.iter().all(|(n, _, _)| !personal.contains_key(n)),
"no edge reaches a seed in this fixture: {ranked:?}"
);
}
#[test]
fn a_seed_the_code_points_at_is_still_reached() {
let personal: std::collections::HashMap<String, f32> =
[("knead".to_string(), 1.0), ("autolyse".to_string(), 1.0)]
.into_iter()
.collect();
let ranked = expand(&personal, |name| {
Ok(match name {
"knead" => vec![("autolyse".to_string(), 1.0, EXTRACTED.to_string())],
_ => Vec::new(),
})
})
.unwrap();
assert!(
ranked.iter().any(|(n, _, _)| n == "autolyse"),
"a seed an edge reaches is still a neighbour: {ranked:?}"
);
assert!(
ranked.iter().all(|(n, _, _)| n != "knead"),
"nothing points at knead, so the walk did not reach it: {ranked:?}"
);
}
#[test]
fn the_walk_asks_about_each_name_once() {
let mut asked: Vec<String> = Vec::new();
let personal: std::collections::HashMap<String, f32> =
[("a".to_string(), 1.0)].into_iter().collect();
expand(&personal, |name| {
asked.push(name.to_string());
Ok(match name {
"a" => vec![("b".to_string(), 1.0, EXTRACTED.to_string())],
"b" => vec![("c".to_string(), 1.0, INFERRED.to_string())],
_ => Vec::new(),
})
})
.unwrap();
let mut unique = asked.clone();
unique.sort();
unique.dedup();
assert_eq!(asked.len(), unique.len(), "asked twice: {asked:?}");
}
#[test]
fn a_reached_name_is_labelled_by_the_best_edge_that_reached_it() {
let personal: std::collections::HashMap<String, f32> =
[("weak".to_string(), 1.0), ("strong".to_string(), 1.0)]
.into_iter()
.collect();
let ranked = expand(&personal, |name| {
Ok(match name {
"weak" => vec![("target".to_string(), 0.5, INFERRED.to_string())],
"strong" => vec![("target".to_string(), 1.0, EXTRACTED.to_string())],
_ => Vec::new(),
})
})
.unwrap();
let (_, _, tier) = ranked
.iter()
.find(|(n, _, _)| n == "target")
.expect("target reached");
assert_eq!(tier, EXTRACTED);
}
#[test]
fn recursion_does_not_produce_a_self_edge() {
let e = run("a.rs", "fn loops() { loops(); }\n");
assert!(!has_edge(&e, "loops", "loops", "calls"), "{:?}", e.edges);
}
fn chain() -> rusqlite::Connection {
let db = rusqlite::Connection::open_in_memory().unwrap();
crate::store::prepare_for_tests(&db);
let file = crate::store::insert_file(&db, "a.rs", "h", 1, 0).unwrap();
let mut id = std::collections::HashMap::new();
for name in ["a", "b", "c", "d", "e", "lonely"] {
let symbol = Symbol {
kind: "function".to_string(),
name: name.to_string(),
qualified: name.to_string(),
start_line: 1,
end_line: 2,
};
id.insert(
name,
crate::store::insert_symbol(&db, file, None, &symbol).unwrap(),
);
}
for (from, to) in [("a", "b"), ("b", "c"), ("c", "d"), ("e", "d")] {
crate::store::insert_edge(&db, id[from], to, "calls", EXTRACTED, None, None).unwrap();
}
db
}
#[test]
fn the_evidence_block_carries_the_definition_both_rings_and_no_ambiguity() {
let db = chain();
let found = evidence(&db, "b", &dependency_kinds(), 20, false).unwrap();
assert_eq!(found.name, "b");
assert!(!found.definitions.is_empty(), "{found:?}");
assert!(
found.callers.iter().any(|e| e.symbol.name == "a"),
"{:?}",
found.callers
);
assert!(
found.callees.iter().any(|e| e.symbol.name == "c"),
"{:?}",
found.callees
);
assert!(
found.ego.iter().any(|h| h.name == "d" && h.via == "c"),
"{:?}",
found.ego
);
assert!(
found
.ego
.iter()
.all(|h| h.name != "b" && h.name != "a" && h.name != "c"),
"{:?}",
found.ego
);
assert!(
found.ego.len() <= EGO_LIMIT,
"the ring is context, not an answer: {:?}",
found.ego
);
let text = found.render("", "", &verbatim);
assert!(text.contains("callers"), "{text}");
assert!(text.contains("callees"), "{text}");
assert!(text.contains("two hops out"), "{text}");
}
#[test]
fn neighbours_separates_the_two_directions() {
let db = chain();
let n = neighbours(&db, "d", &[], false).unwrap();
let mut callers: Vec<&str> = n.callers.iter().map(|e| e.symbol.name.as_str()).collect();
callers.sort_unstable();
assert_eq!(callers, ["c", "e"], "both callers of d");
assert!(n.callees.is_empty(), "d calls nothing");
}
#[test]
fn a_cycle_terminates_instead_of_looping() {
let db = chain();
let file = crate::store::insert_file(&db, "b.rs", "h", 1, 0).unwrap();
let symbol = Symbol {
kind: "function".to_string(),
name: "d".to_string(),
qualified: "d".to_string(),
start_line: 1,
end_line: 2,
};
let d = crate::store::insert_symbol(&db, file, None, &symbol).unwrap();
crate::store::insert_edge(&db, d, "a", "calls", EXTRACTED, None, None).unwrap();
let path = shortest_path(&db, "a", "d", 50, false).unwrap();
assert!(path.is_some(), "a still reaches d");
assert!(
path.unwrap().steps.len() <= 4,
"a cycle inflated the shortest path"
);
}
#[test]
fn the_shortest_path_is_the_short_one_and_names_every_edge() {
let db = chain();
let path = shortest_path(&db, "a", "d", 10, false)
.unwrap()
.expect("a reaches d");
let hops: Vec<(&str, &str)> = path
.steps
.iter()
.map(|s| (s.from.as_str(), s.to.as_str()))
.collect();
assert_eq!(hops, [("a", "b"), ("b", "c"), ("c", "d")]);
assert!(path.steps.iter().all(|s| s.kind == "calls"));
assert!(path.steps.iter().all(|s| s.confidence == EXTRACTED));
assert!(path.steps.iter().all(|s| !s.to_path.is_empty()));
assert_eq!(path.summary.extracted, 3);
assert_eq!(path.summary.seams, 0);
assert!(!path.summary.hypothesis, "every hop is extracted");
}
fn forked_name() -> rusqlite::Connection {
let db = rusqlite::Connection::open_in_memory().unwrap();
crate::store::prepare_for_tests(&db);
let define = |db: &rusqlite::Connection, path: &str, name: &str, line: u32| {
let file = crate::store::insert_file(db, path, path, 1, 0).unwrap_or_else(|_| {
db.query_row("SELECT id FROM files WHERE path = ?1", [path], |r| r.get(0))
.unwrap()
});
let symbol = Symbol {
kind: "function".to_string(),
name: name.to_string(),
qualified: name.to_string(),
start_line: line,
end_line: line + 1,
};
crate::store::insert_symbol(db, file, None, &symbol).unwrap()
};
let start = define(&db, "src/start.rs", "start", 10);
define(&db, "src/one.rs", "record", 20);
let second = define(&db, "src/two.rs", "record", 30);
define(&db, "src/finish.rs", "finish", 40);
crate::store::insert_edge(&db, start, "record", "calls", INFERRED, None, None).unwrap();
crate::store::insert_edge(&db, second, "finish", "calls", INFERRED, None, None).unwrap();
db
}
#[test]
fn a_chain_through_an_ambiguous_name_is_not_walked_by_default() {
let db = forked_name();
assert!(
shortest_path(&db, "start", "finish", 6, false)
.unwrap()
.is_none(),
"the only route crosses a name with two definitions"
);
}
#[test]
fn the_same_chain_with_all_edges_is_labelled_rather_than_asserted() {
let db = forked_name();
let chain = shortest_path(&db, "start", "finish", 6, true)
.unwrap()
.expect("all_edges walks the ambiguous name");
assert_eq!(chain.steps.len(), 2);
assert_eq!(chain.steps[0].confidence, AMBIGUOUS);
assert_eq!(chain.steps[0].definitions, 2);
assert_eq!(chain.summary.seams, 1, "{:?}", chain.steps);
assert_eq!(
chain.summary.ambiguous_names,
vec![("record".to_string(), 2)]
);
assert!(chain.summary.hypothesis);
let rendered = chain.render("", "", &verbatim);
assert!(rendered.contains("seam"), "{rendered}");
assert!(rendered.contains("record: 2 definitions"), "{rendered}");
assert!(
rendered.contains("A hypothesis, not a finding."),
"{rendered}"
);
assert!(
rendered.contains("src/start.rs:10"),
"every hop shows both endpoints: {rendered}"
);
}
#[test]
fn an_all_extracted_chain_carries_no_hypothesis_line() {
let db = chain();
let rendered = shortest_path(&db, "a", "d", 10, false)
.unwrap()
.expect("a reaches d")
.render("", "", &verbatim);
assert!(!rendered.contains("hypothesis"), "{rendered}");
assert!(!rendered.contains("seam"), "{rendered}");
assert!(rendered.contains("3 hops"), "{rendered}");
assert!(rendered.contains("3 extracted"), "{rendered}");
}
#[test]
fn the_refusal_names_the_flag_that_widens_the_question() {
let strict = not_connected("a", "b", 6, false);
assert!(strict.contains("by resolved edges"), "{strict}");
assert!(strict.contains("--all-edges"), "{strict}");
let walked = not_connected("a", "b", 6, true);
assert!(walked.contains("any edge in the store"), "{walked}");
assert!(!walked.contains("--all-edges"), "{walked}");
}
#[test]
fn an_unconnected_pair_returns_nothing_rather_than_erroring() {
let db = chain();
assert!(
shortest_path(&db, "a", "lonely", 10, false)
.unwrap()
.is_none()
);
assert!(
shortest_path(&db, "a", "d", 2, false).unwrap().is_none(),
"d is three hops away, so a depth of two does not reach it"
);
}
}