struct graph

This commit is contained in:
2026-09-07 15:52:46 -04:00
parent 30ed3351b5
commit 93521c53fb
15 changed files with 1061 additions and 30 deletions

View File

@@ -21,7 +21,7 @@ pub struct GraphEdge {
pub to: String,
pub label: String,
}
fn contains(range: &Range, p: Position) -> bool {
pub(super) fn contains(range: &Range, p: Position) -> bool {
(range.start.line, range.start.character) <= (p.line, p.character)
&& (p.line, p.character) < (range.end.line, range.end.character)
}
@@ -72,7 +72,7 @@ fn following(symbols: &[Value], p: Position) -> Option<&Value> {
)
})
}
fn excerpt(text: &str, range: &Range) -> String {
pub(super) fn excerpt(text: &str, range: &Range) -> String {
text.lines()
.enumerate()
.filter(|(i, _)| *i >= range.start.line as usize && *i <= range.end.line as usize)
@@ -111,7 +111,7 @@ fn concepts<'a>(node: &'a Value, out: &mut Vec<&'a Value>) {
concepts(child, out);
}
}
fn expression(
pub(super) fn expression(
node: &Value,
path: &str,
text: &str,

View File

@@ -3,6 +3,7 @@
mod constraints;
mod graph;
mod relationships;
mod types;
pub use graph::ConceptGraph;
pub use relationships::GraphOptions;
mod transport;

View File

@@ -79,7 +79,12 @@ impl Clangd {
..location.clone()
})
}
fn graph_neighbors(&self, target: &Location, up: bool) -> Result<ConceptGraph> {
fn graph_neighbors(
&self,
target: &Location,
up: bool,
ast_cache: &mut HashMap<String, Value>,
) -> Result<ConceptGraph> {
let uri = self.document(&target.path)?;
let ast = self.connection.request("textDocument/ast", json!({"textDocument":{"uri":uri},"range":{"start":target.range.start,"end":target.range.start}}))?;
if ast["kind"] == "Concept" && ast["role"] == "declaration" {
@@ -93,6 +98,10 @@ impl Clangd {
});
return Ok(graph);
}
let ast = self.declaration_ast(target, ast, ast_cache)?;
if super::types::is_type(&ast) {
return self.type_structure(target, &ast, up);
}
let items = self.connection.request(
"textDocument/prepareCallHierarchy",
json!({"textDocument":{"uri":uri},"position":target.range.start}),
@@ -100,7 +109,7 @@ impl Clangd {
let item = items
.as_array()
.and_then(|a| a.first())
.ok_or("Select a concept or function name to view its graph.")?;
.ok_or("Select a concept, function, class, or struct name to view its graph.")?;
let root = self.location(
item["uri"].as_str().ok_or("Missing function URI")?,
&item["selectionRange"],
@@ -152,6 +161,9 @@ impl Clangd {
label: "called by".into(),
});
}
if up {
self.function_constraints(target, &ast, &mut graph)?;
}
Ok(graph)
}
pub(super) fn relationship_graph(
@@ -185,7 +197,9 @@ impl Clangd {
let mut visited = HashSet::new();
let mut symbols = HashMap::<(String, u32, u32), Location>::new();
let mut edge_keys = HashSet::new();
let mut structures = HashMap::new();
let mut requests = 0;
let mut ast_cache = HashMap::new();
while let Some((location, parent, depth, up)) = queue.pop_front() {
if depth >= options.depth || !visited.insert((key(&location), up)) {
continue;
@@ -195,7 +209,7 @@ impl Clangd {
break;
}
requests += 1;
let local = match self.graph_neighbors(&location, up) {
let local = match self.graph_neighbors(&location, up, &mut ast_cache) {
Ok(local) => local,
Err(error) if depth == 0 => return Err(error),
Err(error) => {
@@ -205,6 +219,10 @@ impl Clangd {
continue;
}
};
let local_is_type = local
.warnings
.iter()
.any(|w| w.starts_with("Type graphs show"));
if graph.nodes.is_empty() {
let mut root = local.nodes[0].clone();
root.location = target.clone();
@@ -222,16 +240,18 @@ impl Clangd {
if start.elapsed().as_secs() >= 30 || graph.nodes.len() >= 150 {
graph
.warnings
.push("Graph reached its 150-node limit.".into());
.push("Graph reached its 150-node or 30-second budget.".into());
break;
}
let symbol_node = matches!(node.kind.as_str(), "concept" | "function" | "usage")
&& node.label
!= format!(
"{}:{}",
node.location.path,
node.location.range.start.line + 1
);
let symbol_node = matches!(
node.kind.as_str(),
"concept" | "function" | "type" | "field" | "usage"
) && node.label
!= format!(
"{}:{}",
node.location.path,
node.location.range.start.line + 1
);
let old_id = node.id.clone();
if symbol_node {
let symbol_key = key(&node.location);
@@ -255,6 +275,12 @@ impl Clangd {
let ast = self.connection.request("textDocument/ast", json!({"textDocument":{"uri":uri},"range":{"start":node.location.range.start,"end":node.location.range.start}}))?;
node.kind = match ast["kind"].as_str() {
Some("Concept") => "concept",
Some(
"CXXRecord"
| "ClassTemplate"
| "ClassTemplateSpecialization"
| "Record",
) => "type",
Some(
"Function" | "FunctionTemplate" | "CXXMethod" | "CXXConstructor"
| "CXXDestructor",
@@ -265,10 +291,16 @@ impl Clangd {
}
}
let node_key = key(&node.location);
let structure_key = (
node_key.clone(),
node.location.range.end.line,
node.location.range.end.character,
node.kind.clone(),
);
let id = if symbol_node {
known.get(&node_key).cloned()
} else {
None
structures.get(&structure_key).cloned()
};
let id = id.unwrap_or_else(|| format!("g{}", graph.nodes.len()));
mapping.insert(old_id, id.clone());
@@ -277,10 +309,19 @@ impl Clangd {
if symbol_node {
known.insert(node_key, id.clone());
}
if !symbol_node {
structures.insert(structure_key, id.clone());
}
graph.nodes.push(node.clone());
}
if symbol_node && matches!(node.kind.as_str(), "concept" | "function") {
queue.push_back((node.location, id, depth + 1, up));
if symbol_node && matches!(node.kind.as_str(), "concept" | "function" | "type") {
// A type's members expand towards their dependencies/callees,
// even though membership is drawn below the containing type.
let direction = if local_is_type { true } else { up };
queue.push_back((node.location.clone(), id.clone(), depth + 1, direction));
if node.kind == "type" {
queue.push_back((node.location, id, depth + 1, false));
}
}
}
for edge in local.edges {

View File

@@ -903,3 +903,221 @@ fn function_graph_depth_namespaces_and_cycles() {
);
server.stop();
}
#[test]
#[ignore = "requires clangd"]
fn type_graph_constraints_and_members() {
let temp = tempdir().unwrap();
let root = temp.path();
let build = root.join("build");
fs::create_dir_all(build.join("meson-info")).unwrap();
fs::write(build.join("meson-info/intro-projectinfo.json"), "{}").unwrap();
fs::write(
root.join("types.hpp"),
include_str!("../../../../examples/types/types.hpp"),
)
.unwrap();
fs::write(
root.join("main.cpp"),
include_str!("../../../../examples/types/main.cpp"),
)
.unwrap();
fs::write(build.join("compile_commands.json"),serde_json::to_vec(&json!([{"directory":root,"file":root.join("main.cpp"),"arguments":["clang++","-std=c++23","-c",root.join("main.cpp")]}])).unwrap()).unwrap();
let server = Clangd::start(crate::open(root, Some(&build), false).unwrap()).unwrap();
let source = include_str!("../../../../examples/types/types.hpp");
let position = |needle: &str| {
let (line, text) = source
.lines()
.enumerate()
.find(|(_, l)| l.contains(needle))
.unwrap();
Position {
line: line as u32,
character: (text.find(needle).unwrap() + 1) as u32,
}
};
let pos = position("Box :");
let graph = server
.graph(
"types.hpp",
pos,
&GraphOptions {
depth: 1,
excluded_namespaces: vec![],
},
)
.unwrap();
for label in [
"demo::Base",
"demo::Helper",
"demo::Box::value",
"demo::Box::helper",
"demo::Box::set",
"demo::Box::run",
"demo::Addable",
"demo::Sized",
"std::copyable",
"std::constructible_from",
"std::integral",
"std::convertible_to",
] {
assert!(
graph.nodes.iter().any(|n| n.label == label),
"Missing {label}: {graph:?}"
);
}
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "requires" && n.detail.contains("u.size()"))
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "parameter" && n.label == "argument x")
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "template-parameter" && n.label == "template T")
);
let default_graph = server
.graph("types.hpp", pos, &GraphOptions::default())
.unwrap();
assert!(
!default_graph
.nodes
.iter()
.any(|n| n.label.starts_with("std::"))
);
let node = |label: &str| graph.nodes.iter().find(|n| n.label == label).unwrap();
let edge = |from: &str, to: &str, label: &str| {
graph
.edges
.iter()
.any(|e| e.from == from && e.to == to && e.label == label)
};
assert!(edge(&node("demo::Base").id, "selected", "base of"));
assert!(edge(
&node("demo::Helper").id,
&node("demo::Box::helper").id,
"type of"
));
assert!(edge(
&node("template T").id,
&node("demo::Box::value").id,
"type of"
));
assert!(edge(
&node("std::integral").id,
&node("argument n").id,
"constrains"
));
let u = graph
.nodes
.iter()
.find(|n| {
n.label == "template U" && edge(&n.id, &node("demo::Box::set").id, "template parameter")
})
.unwrap();
let argument = graph
.nodes
.iter()
.find(|n| n.label == "argument u" && edge(&n.id, &node("demo::Box::set").id, "argument"))
.unwrap();
assert!(edge(&node("demo::Sized").id, &u.id, "constrains"));
assert!(edge(&u.id, &argument.id, "type of"));
let calls = server
.graph(
"types.hpp",
pos,
&GraphOptions {
depth: 2,
..Default::default()
},
)
.unwrap();
let work = calls
.nodes
.iter()
.find(|n| n.label == "demo::Helper::work")
.unwrap();
let set = calls
.nodes
.iter()
.find(|n| n.label == "demo::Box::set")
.unwrap();
assert!(
calls
.edges
.iter()
.any(|e| e.from == work.id && e.to == set.id && e.label == "called by")
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "operator" && n.label == "||")
);
assert!(
graph
.nodes
.iter()
.any(|n| n.kind == "type" && n.label.starts_with("std::vector")),
"{graph:?}"
);
let method = server
.graph(
"types.hpp",
position("set("),
&GraphOptions {
depth: 1,
excluded_namespaces: vec![],
},
)
.unwrap();
assert!(method.title.ends_with("::set"), "{method:?}");
assert!(
method.nodes.iter().any(|n| n.label == "demo::Sized"),
"{method:?}"
);
let constructor = server
.graph(
"types.hpp",
position("Box(Addable"),
&GraphOptions {
depth: 1,
excluded_namespaces: vec![],
},
)
.unwrap();
assert!(
constructor
.nodes
.iter()
.any(|n| n.label == "std::constructible_from"),
"{constructor:?}"
);
let alias = server
.graph("types.hpp", position("IntBox ="), &GraphOptions::default())
.unwrap();
assert!(
alias.title == "demo::Box"
|| alias
.nodes
.iter()
.any(|n| n.kind == "type" && n.label == "demo::Box"),
"{alias:?}"
);
let enumeration = server
.graph("types.hpp", position("State {"), &GraphOptions::default())
.unwrap();
assert!(
enumeration.nodes.iter().any(|n| n.label == "idle"),
"{enumeration:?}"
);
server.stop();
}

View File

@@ -0,0 +1,460 @@
//! Type structure and signature constraints come from clangd's AST and symbols.
use super::graph::{ConceptGraph, GraphEdge, GraphNode, contains, excerpt, expression};
use super::*;
use std::collections::HashMap;
fn children(node: &Value) -> impl Iterator<Item = &Value> {
node["children"].as_array().into_iter().flatten()
}
pub(super) fn is_record(node: &Value) -> bool {
matches!(
node["kind"].as_str(),
Some(
"CXXRecord"
| "Record"
| "ClassTemplate"
| "ClassTemplateSpecialization"
| "ClassTemplatePartialSpecialization"
)
) && node["role"] == "declaration"
}
pub(super) fn is_type(node: &Value) -> bool {
is_record(node)
|| node["role"] == "declaration"
&& matches!(
node["kind"].as_str(),
Some("Enum" | "TypeAlias" | "Typedef" | "TypeAliasTemplate")
)
}
fn is_function(node: &Value) -> bool {
matches!(
node["kind"].as_str(),
Some("Function" | "CXXMethod" | "CXXConstructor" | "CXXDestructor" | "CXXConversion")
) && node["role"] == "declaration"
}
fn range(node: &Value) -> Option<Range> {
serde_json::from_value(node["range"].clone()).ok()
}
fn declaration<'a>(symbols: &'a [Value], node: &Value) -> Option<&'a Value> {
let r = range(node)?;
let body_start = children(node)
.find(|c| c["kind"] == "Compound")
.and_then(range)
.map(|r| (r.start.line, r.start.character));
for s in symbols {
if let Some(found) = s["children"].as_array().and_then(|c| declaration(c, node)) {
return Some(found);
}
if let Ok(selection) = serde_json::from_value::<Range>(s["selectionRange"].clone())
&& contains(&r, selection.start)
&& body_start.is_none_or(|p| (selection.start.line, selection.start.character) < p)
&& (s["name"] == node["detail"]
|| (is_function(node) || node["kind"] == "FunctionTemplate")
&& matches!(s["kind"].as_u64(), Some(6 | 9 | 12)))
{
return Some(s);
}
}
None
}
struct Builder<'a> {
path: &'a str,
text: &'a str,
symbols: &'a [Value],
graph: ConceptGraph,
templates: HashMap<String, String>,
}
impl Builder<'_> {
fn add(
&mut self,
node: &Value,
kind: &str,
label: String,
owner: &str,
relation: &str,
above: bool,
) -> Option<String> {
if self.graph.nodes.len() >= 120 {
return None;
}
let r = range(node)?;
let mut location = declaration(self.symbols, node)
.and_then(|s| serde_json::from_value(s["selectionRange"].clone()).ok())
.unwrap_or_else(|| r.clone());
if kind == "type" {
for qualifier in children(node).filter(|c| c["role"] == "specifier") {
if let Some(r) = range(qualifier) {
location.start = r.end;
}
}
}
let id = format!("type{}", self.graph.nodes.len());
self.graph.nodes.push(GraphNode {
id: id.clone(),
label,
kind: kind.into(),
detail: format!(
"{}:{}\n{}",
self.path,
r.start.line + 1,
excerpt(self.text, &r)
),
location: Location {
path: self.path.into(),
range: location,
label: String::new(),
},
});
self.graph.edges.push(GraphEdge {
from: if above { id.clone() } else { owner.into() },
to: if above { owner.into() } else { id.clone() },
label: relation.into(),
});
Some(id)
}
fn constraint(&mut self, node: &Value, owner: &str) {
let old = self.graph.edges.len();
expression(node, self.path, self.text, &mut self.graph, owner, 0);
for edge in &mut self.graph.edges[old..] {
if edge.to == owner {
edge.label = "constrains".into();
}
}
}
fn signature(&mut self, node: &Value, owner: &str) {
// Only signature children: bodies, default arguments, and constructor
// initializers must not be mistaken for associated constraints.
let function = if is_function(node) {
Some(node)
} else {
children(node).find(|c| is_function(c))
};
let mut templates = self.templates.clone();
let mut params = Vec::new();
if let Some(function) = function {
for proto in children(function).filter(|c| c["kind"] == "FunctionProto") {
for param in children(proto).filter(|c| c["kind"] == "ParmVar") {
let label = format!(
"argument {}",
param["detail"].as_str().unwrap_or("(unnamed)")
);
if let Some(id) = self.add(param, "parameter", label, owner, "argument", true) {
params.push((param, id));
}
}
}
}
for child in children(node) {
if matches!(
child["kind"].as_str(),
Some("TemplateTypeParm" | "NonTypeTemplateParm" | "TemplateTemplateParm")
) && child["role"] == "declaration"
{
// Abbreviated function templates synthesize an `x:auto` template
// parameter; attach its concept to the actual argument x.
let name = child["detail"].as_str().unwrap_or("(unnamed)");
let argument = name
.strip_suffix(":auto")
.and_then(|name| params.iter().find(|(p, _)| p["detail"] == name))
.or_else(|| {
range(child).and_then(|r| {
params
.iter()
.find(|(p, _)| range(p).is_some_and(|pr| contains(&pr, r.start)))
})
});
let id = if let Some((_, id)) = argument {
Some(id.clone())
} else {
self.add(
child,
"template-parameter",
format!("template {name}"),
owner,
"template parameter",
true,
)
};
if let Some(id) = id {
templates.insert(name.to_owned(), id.clone());
for concept in children(child)
.filter(|c| c["kind"] == "Concept" && c["role"] == "reference")
{
self.constraint(concept, &id);
}
}
}
if child["role"] == "expression" {
self.constraint(child, owner);
}
}
if let Some(function) = function
&& !std::ptr::eq(function, node)
{
for child in children(function).filter(|c| c["role"] == "expression") {
self.constraint(child, owner);
}
}
if matches!(
node["kind"].as_str(),
Some("ClassTemplate" | "TypeAliasTemplate" | "ClassTemplatePartialSpecialization")
) {
self.templates = templates.clone();
}
// Some clangd versions expose constrained auto under the parameter type.
for (param, id) in params {
self.parameter_types(param, &id, &templates);
let mut stack: Vec<_> = children(param).filter(|c| c["role"] == "type").collect();
while let Some(n) = stack.pop() {
if n["kind"] == "Concept" && n["role"] == "reference" {
self.constraint(n, &id);
} else {
stack.extend(
children(n).filter(|c| c["role"] == "type" || c["role"] == "reference"),
);
}
}
}
}
fn parameter_types(&mut self, node: &Value, owner: &str, templates: &HashMap<String, String>) {
let mut stack: Vec<_> = children(node).filter(|n| n["role"] == "type").collect();
let mut seen = HashSet::new();
let mut visited = 0;
while let Some(n) = stack.pop() {
visited += 1;
if visited > 1000 {
break;
}
if n["kind"] == "TemplateTypeParm"
&& let Some(id) = n["detail"].as_str().and_then(|name| templates.get(name))
&& id != owner
&& seen.insert(id.clone())
{
self.graph.edges.push(GraphEdge {
from: id.clone(),
to: owner.into(),
label: "type of".into(),
});
}
stack.extend(
children(n).filter(|n| n["role"] == "type" || n["role"] == "template argument"),
);
}
}
fn composed_types(&mut self, node: &Value, owner: &str, relation: &str) {
self.parameter_types(node, owner, &self.templates.clone());
let mut stack: Vec<_> = children(node)
.filter(|n| n["role"] == "type" || n["role"] == "base")
.collect();
let mut visited = 0;
while let Some(n) = stack.pop() {
visited += 1;
if visited > 300 || self.graph.nodes.len() >= 120 {
break;
}
if matches!(
n["kind"].as_str(),
Some("Record" | "TemplateSpecialization" | "Typedef" | "Enum")
) && n["role"] == "type"
{
if let Some(r) = range(n) {
self.add(n, "type", excerpt(self.text, &r), owner, relation, true);
}
} else if n["kind"] == "Builtin" {
if let Some(r) = range(n) {
self.add(
n,
"primitive",
excerpt(self.text, &r),
owner,
relation,
true,
);
}
} else {
stack.extend(children(n).filter(|n| n["role"] == "type" || n["role"] == "base"));
}
}
}
}
impl Clangd {
pub(super) fn declaration_ast(
&self,
target: &Location,
point_ast: Value,
cache: &mut HashMap<String, Value>,
) -> Result<Value> {
// Fetch the file AST only for declarations whose template envelope may
// have been omitted by the point query. Included headers are not expanded.
if !is_type(&point_ast) && !is_function(&point_ast) {
return Ok(point_ast);
}
let uri = self.document(&target.path)?;
if !cache.contains_key(&target.path) {
let full = self
.connection
.request("textDocument/ast", json!({"textDocument":{"uri":uri}}))?;
cache.insert(target.path.clone(), full);
}
let full = &cache[&target.path];
let mut stack = vec![full];
let mut count = 0;
while let Some(n) = stack.pop() {
count += 1;
if count > 100000 {
return Err("Declaration AST exceeds the 100,000-node inspection limit.".into());
}
if let Some(r) = range(n)
&& !contains(&r, target.range.start)
{
continue;
}
if matches!(
n["kind"].as_str(),
Some("ClassTemplate" | "FunctionTemplate" | "TypeAliasTemplate")
) && children(n)
.any(|c| (is_function(c) || is_type(c)) && c["range"] == point_ast["range"])
{
return Ok(n.clone());
}
stack.extend(children(n));
}
Ok(point_ast)
}
pub(super) fn type_structure(
&self,
target: &Location,
ast: &Value,
up: bool,
) -> Result<ConceptGraph> {
let text = self.read_target(&target.path)?.content;
let symbols = self
.query(&target.path, target.range.start, Query::Symbols)?
.symbols;
let mut b = Builder {
path: &target.path,
templates: HashMap::new(),
text: &text,
symbols: &symbols,
graph: ConceptGraph {
title: target.label.clone(),
nodes: vec![GraphNode {
id: "selected".into(),
label: target.label.clone(),
kind: "selected".into(),
detail: range(ast).map(|r| excerpt(&text, &r)).unwrap_or_default(),
location: target.clone(),
}],
edges: vec![],
warnings: vec![],
},
};
let record = if ast["kind"] == "ClassTemplate" {
children(ast).find(|c| is_record(c))
} else {
Some(ast)
};
b.signature(ast, "selected");
if up {
if matches!(ast["kind"].as_str(), Some("TypeAlias" | "Typedef" | "Enum")) {
b.composed_types(ast, "selected", "underlying type");
}
if ast["kind"] == "TypeAliasTemplate" {
for alias in children(ast).filter(|c| c["kind"] == "TypeAlias") {
b.composed_types(alias, "selected", "underlying type");
}
}
if let Some(record) = record {
for base in children(record).filter(|c| c["role"] == "base") {
b.composed_types(base, "selected", "base of");
}
}
} else if let Some(record) = record {
if children(record)
.filter(|c| c["role"] == "declaration")
.count()
> 80
{
b.graph
.warnings
.push("Only the first 80 member declarations were considered.".into());
}
for member in children(record)
.filter(|c| c["role"] == "declaration")
.take(80)
{
let function = if is_function(member) {
Some(member)
} else if member["kind"] == "FunctionTemplate" {
children(member).find(|c| is_function(c))
} else {
None
};
let (kind, relation) = if function.is_some_and(|f| f["kind"] == "CXXConstructor") {
("function", "constructor")
} else if function.is_some() {
("function", "member")
} else if is_record(member) || member["kind"] == "Enum" {
("type", "nested type")
} else if matches!(member["kind"].as_str(), Some("Field" | "Var")) {
("field", "member")
} else if matches!(
member["kind"].as_str(),
Some("TypeAlias" | "Typedef" | "TypeAliasTemplate")
) {
("type", "member alias")
} else if member["kind"] == "EnumConstant" {
("enumerator", "enumerator")
} else {
continue;
};
let symbol = declaration(&symbols, function.unwrap_or(member));
let label = symbol
.and_then(|s| s["name"].as_str())
.or(member["detail"].as_str())
.unwrap_or("constructor")
.to_owned();
if let Some(id) = b.add(
function.unwrap_or(member),
kind,
label,
"selected",
relation,
false,
) {
if function.is_some() {
b.signature(member, &id);
} else {
b.composed_types(member, &id, "type of");
}
}
}
}
if b.graph.nodes.len() >= 100 {
b.graph.warnings.push("Type structure reached its node budget; some members or constraints may be omitted.".into());
}
b.graph.warnings.push("Type graphs show declared bases, members and signature constraints, not every template instantiation or inherited member. Set depth 2 to expand member calls; dependent calls may remain unresolved.".into());
Ok(b.graph)
}
pub(super) fn function_constraints(
&self,
target: &Location,
ast: &Value,
graph: &mut ConceptGraph,
) -> Result<()> {
let text = self.read_target(&target.path)?.content;
let symbols = self
.query(&target.path, target.range.start, Query::Symbols)?
.symbols;
let mut b = Builder {
path: &target.path,
templates: HashMap::new(),
text: &text,
symbols: &symbols,
graph: graph.clone(),
};
b.signature(ast, "selected");
*graph = b.graph;
Ok(())
}
}