pub mod complexity;
pub mod context;
pub mod dataflow;
pub mod entries;
pub mod file_symbols;
pub(crate) mod flow;
pub mod impact;
pub(crate) mod l10n;
pub mod localize;
pub mod map;
pub mod module_summary;
pub mod reverse;
pub mod smells;
pub mod trace;
pub mod usages;
use serde::Serialize;
use thiserror::Error;
use grapha_core::graph::{Node, NodeKind};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct QueryCandidate {
pub id: String,
pub name: String,
pub kind: NodeKind,
pub file: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Error)]
#[serde(tag = "error", rename_all = "snake_case")]
pub enum QueryResolveError {
#[error("not found")]
NotFound { query: String },
#[error("ambiguous query")]
Ambiguous {
query: String,
candidates: Vec<QueryCandidate>,
},
#[error("{hint}")]
NotFunction { hint: String },
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum MatchTier {
ExactNormalizedName,
NormalizedPrefix,
IdSuffix,
CaseInsensitiveNormalizedExact,
}
pub(crate) fn strip_accessor_prefix(name: &str) -> &str {
name.strip_prefix("getter:")
.or_else(|| name.strip_prefix("setter:"))
.unwrap_or(name)
}
pub(crate) fn normalize_symbol_name(name: &str) -> &str {
let without_accessor = strip_accessor_prefix(name);
without_accessor
.split_once('(')
.map(|(head, _)| head)
.unwrap_or(without_accessor)
}
pub(crate) fn is_swiftui_invalidation_source(node: &Node) -> bool {
node.metadata
.get("swiftui.invalidation_source")
.is_some_and(|value| value == "true")
}
fn kind_preference(kind: NodeKind) -> usize {
match kind {
NodeKind::Function => 0,
NodeKind::Property => 1,
NodeKind::Variant | NodeKind::Field => 2,
NodeKind::Struct
| NodeKind::Enum
| NodeKind::Trait
| NodeKind::Impl
| NodeKind::Module
| NodeKind::Constant
| NodeKind::TypeAlias
| NodeKind::Protocol
| NodeKind::Extension => 3,
NodeKind::View | NodeKind::Branch => 4,
}
}
fn split_file_symbol_query(query: &str) -> Option<(&str, &str)> {
let (file_part, symbol_part) = query.rsplit_once("::")?;
if file_part.is_empty() || symbol_part.is_empty() {
return None;
}
Some((file_part, symbol_part))
}
fn match_tier(node: &Node, query: &str) -> Option<MatchTier> {
let normalized_query = normalize_symbol_name(query);
let normalized_name = normalize_symbol_name(&node.name);
if normalized_name == normalized_query {
Some(MatchTier::ExactNormalizedName)
} else if normalized_name.starts_with(normalized_query) {
Some(MatchTier::NormalizedPrefix)
} else if node.id.ends_with(query) {
Some(MatchTier::IdSuffix)
} else if normalized_name.eq_ignore_ascii_case(normalized_query) {
Some(MatchTier::CaseInsensitiveNormalizedExact)
} else {
None
}
}
fn to_candidate(node: &Node) -> QueryCandidate {
QueryCandidate {
id: node.id.clone(),
name: node.name.clone(),
kind: node.kind,
file: node.file.to_string_lossy().to_string(),
}
}
pub fn ambiguity_hint() -> &'static str {
"Retry with file.swift::symbol or the full symbol id."
}
pub fn resolve_node<'a>(nodes: &'a [Node], query: &str) -> Result<&'a Node, QueryResolveError> {
if let Some(node) = nodes.iter().find(|node| node.id == query) {
return Ok(node);
}
let (candidate_nodes, match_query): (Vec<&Node>, &str) = match split_file_symbol_query(query) {
Some((file_part, symbol_part)) => (
nodes
.iter()
.filter(|node| node.file.to_string_lossy().ends_with(file_part))
.collect(),
symbol_part,
),
None => (nodes.iter().collect(), query),
};
let matches: Vec<_> = candidate_nodes
.into_iter()
.filter_map(|node| {
let tier = match_tier(node, match_query)?;
Some((node, tier, kind_preference(node.kind)))
})
.collect();
if matches.is_empty() {
return Err(QueryResolveError::NotFound {
query: query.to_string(),
});
}
let best_tier = matches
.iter()
.map(|(_, tier, _)| *tier)
.min()
.expect("matches is not empty");
let best_kind = matches
.iter()
.filter(|(_, tier, _)| *tier == best_tier)
.map(|(_, _, kind)| *kind)
.min()
.expect("matches is not empty");
let best_matches: Vec<&Node> = matches
.iter()
.filter(|(_, tier, kind)| *tier == best_tier && *kind == best_kind)
.map(|(node, _, _)| *node)
.collect();
if best_matches.len() == 1 {
return Ok(best_matches[0]);
}
Err(QueryResolveError::Ambiguous {
query: query.to_string(),
candidates: best_matches.into_iter().map(to_candidate).collect(),
})
}
#[derive(Debug, Serialize)]
pub struct ContextResult {
pub symbol: SymbolInfo,
pub callers: Vec<SymbolRef>,
pub callees: Vec<SymbolRef>,
pub reads: Vec<SymbolRef>,
pub read_by: Vec<SymbolRef>,
pub invalidation_sources: Vec<SymbolRef>,
pub contains: Vec<SymbolRef>,
pub contains_tree: Vec<SymbolTreeRef>,
pub contained_by: Vec<SymbolRef>,
pub implementors: Vec<SymbolRef>,
pub implements: Vec<SymbolRef>,
pub type_refs: Vec<SymbolRef>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SymbolInfo {
pub id: String,
pub name: String,
pub kind: NodeKind,
pub file: String,
pub span: [usize; 2],
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SymbolRef {
pub id: String,
pub name: String,
pub kind: NodeKind,
pub file: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SymbolTreeRef {
pub id: String,
pub name: String,
pub kind: NodeKind,
pub file: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub contains: Vec<SymbolTreeRef>,
}
#[cfg(test)]
mod tests {
use super::*;
use grapha_core::graph::{NodeRole, Span, Visibility};
use std::collections::HashMap;
use std::path::PathBuf;
fn make_node(id: &str, name: &str, kind: NodeKind, file: &str) -> Node {
Node {
id: id.into(),
kind,
name: name.into(),
file: PathBuf::from(file),
span: Span {
start: [0, 0],
end: [1, 0],
},
visibility: Visibility::Public,
metadata: HashMap::new(),
role: None::<NodeRole>,
signature: None,
doc_comment: None,
module: None,
snippet: None,
}
}
#[test]
fn bare_send_gift_prefers_functions_over_variants_and_properties() {
let nodes = vec![
make_node(
"variant-id",
"sendGift",
NodeKind::Variant,
"FamilyServiceCore.swift",
),
make_node(
"property-id",
"sendGift",
NodeKind::Property,
"GiftView.swift",
),
make_node(
"function-id",
"sendGift(req:)",
NodeKind::Function,
"GiftServiceCore.swift",
),
];
let resolved = resolve_node(&nodes, "sendGift").unwrap();
assert_eq!(resolved.id, "function-id");
}
#[test]
fn bare_send_gift_returns_ambiguous_when_functions_share_top_rank() {
let nodes = vec![
make_node(
"function-1",
"sendGift(req:)",
NodeKind::Function,
"GiftServiceCore.swift",
),
make_node(
"function-2",
"sendGift(goods:targetId:)",
NodeKind::Function,
"StoreModule.swift",
),
make_node(
"variant-id",
"sendGift",
NodeKind::Variant,
"HeadlineData.swift",
),
];
let err = resolve_node(&nodes, "sendGift").unwrap_err();
match err {
QueryResolveError::Ambiguous { query, candidates } => {
assert_eq!(query, "sendGift");
assert_eq!(candidates.len(), 2);
assert!(
candidates
.iter()
.all(|candidate| candidate.kind == NodeKind::Function)
);
}
other => panic!("expected ambiguity, got {other:?}"),
}
}
#[test]
fn swift_file_symbol_query_matches_against_node_file_suffix() {
let nodes = vec![
make_node(
"s:12ModuleExport15GiftServiceCoreC04sendC03reqy...",
"sendGift(req:)",
NodeKind::Function,
"GiftServiceCore.swift",
),
make_node(
"s:5Store0A6ModuleC8sendGift5goods8targetIdy...",
"sendGift(goods:targetId:)",
NodeKind::Function,
"StoreModule.swift",
),
];
let resolved = resolve_node(&nodes, "GiftServiceCore.swift::sendGift").unwrap();
assert_eq!(resolved.file, PathBuf::from("GiftServiceCore.swift"));
}
#[test]
fn bare_symbol_prefers_real_declarations_over_swiftui_synthetic_nodes() {
let nodes = vec![
make_node(
"ContentView.swift::ContentView::body::view:Row@10:12",
"Row",
NodeKind::View,
"ContentView.swift",
),
make_node(
"ContentView.swift::Row",
"Row",
NodeKind::Struct,
"ContentView.swift",
),
];
let resolved = resolve_node(&nodes, "Row").unwrap();
assert_eq!(resolved.kind, NodeKind::Struct);
assert_eq!(resolved.id, "ContentView.swift::Row");
}
}