From 93521c53fbb10c71cee5701d984c3f052f0d7c60 Mon Sep 17 00:00:00 2001 From: Emily Boudreaux Date: Mon, 7 Sep 2026 15:52:46 -0400 Subject: [PATCH] struct graph --- crates/cex-core/src/analysis/graph.rs | 6 +- crates/cex-core/src/analysis/mod.rs | 1 + crates/cex-core/src/analysis/relationships.rs | 69 ++- crates/cex-core/src/analysis/tests.rs | 218 +++++++++ crates/cex-core/src/analysis/types.rs | 460 ++++++++++++++++++ examples/types/CMakeLists.txt | 5 + examples/types/main.cpp | 2 + examples/types/meson.build | 2 + examples/types/types.hpp | 25 + frontend/graph.ts | 15 +- frontend/main.ts | 69 ++- frontend/resize.ts | 46 ++ frontend/style.css | 37 ++ src-tauri/src/main.rs | 22 + tests/browser.spec.ts | 114 ++++- 15 files changed, 1061 insertions(+), 30 deletions(-) create mode 100644 crates/cex-core/src/analysis/types.rs create mode 100644 examples/types/CMakeLists.txt create mode 100644 examples/types/main.cpp create mode 100644 examples/types/meson.build create mode 100644 examples/types/types.hpp create mode 100644 frontend/resize.ts diff --git a/crates/cex-core/src/analysis/graph.rs b/crates/cex-core/src/analysis/graph.rs index 9e8f720..b6b0003 100644 --- a/crates/cex-core/src/analysis/graph.rs +++ b/crates/cex-core/src/analysis/graph.rs @@ -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, diff --git a/crates/cex-core/src/analysis/mod.rs b/crates/cex-core/src/analysis/mod.rs index e4393f7..a1589b6 100644 --- a/crates/cex-core/src/analysis/mod.rs +++ b/crates/cex-core/src/analysis/mod.rs @@ -3,6 +3,7 @@ mod constraints; mod graph; mod relationships; +mod types; pub use graph::ConceptGraph; pub use relationships::GraphOptions; mod transport; diff --git a/crates/cex-core/src/analysis/relationships.rs b/crates/cex-core/src/analysis/relationships.rs index de56c4e..29db48a 100644 --- a/crates/cex-core/src/analysis/relationships.rs +++ b/crates/cex-core/src/analysis/relationships.rs @@ -79,7 +79,12 @@ impl Clangd { ..location.clone() }) } - fn graph_neighbors(&self, target: &Location, up: bool) -> Result { + fn graph_neighbors( + &self, + target: &Location, + up: bool, + ast_cache: &mut HashMap, + ) -> Result { 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 { diff --git a/crates/cex-core/src/analysis/tests.rs b/crates/cex-core/src/analysis/tests.rs index c3c4cfe..dd1872b 100644 --- a/crates/cex-core/src/analysis/tests.rs +++ b/crates/cex-core/src/analysis/tests.rs @@ -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(); +} diff --git a/crates/cex-core/src/analysis/types.rs b/crates/cex-core/src/analysis/types.rs new file mode 100644 index 0000000..85efbb8 --- /dev/null +++ b/crates/cex-core/src/analysis/types.rs @@ -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 { + 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 { + 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::(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, +} +impl Builder<'_> { + fn add( + &mut self, + node: &Value, + kind: &str, + label: String, + owner: &str, + relation: &str, + above: bool, + ) -> Option { + 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) { + 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, + ) -> Result { + // 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 { + 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(()) + } +} diff --git a/examples/types/CMakeLists.txt b/examples/types/CMakeLists.txt new file mode 100644 index 0000000..6a9dc2d --- /dev/null +++ b/examples/types/CMakeLists.txt @@ -0,0 +1,5 @@ +cmake_minimum_required(VERSION 3.20) +project(cex_type_demo LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 23) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +add_executable(type-demo main.cpp) diff --git a/examples/types/main.cpp b/examples/types/main.cpp new file mode 100644 index 0000000..189c783 --- /dev/null +++ b/examples/types/main.cpp @@ -0,0 +1,2 @@ +#include "types.hpp" +int main() { demo::Box box(1); box.run(2); } diff --git a/examples/types/meson.build b/examples/types/meson.build new file mode 100644 index 0000000..312104c --- /dev/null +++ b/examples/types/meson.build @@ -0,0 +1,2 @@ +project('cex-type-demo', 'cpp', default_options: ['cpp_std=c++23']) +executable('type-demo', 'main.cpp') diff --git a/examples/types/types.hpp b/examples/types/types.hpp new file mode 100644 index 0000000..860a8db --- /dev/null +++ b/examples/types/types.hpp @@ -0,0 +1,25 @@ +#pragma once +#include +#include +namespace demo { +template concept Addable = requires(T a) { a + a; }; +template concept Sized = requires(T a) { a.size(); }; +struct Base { int id; }; +struct Helper { int work() { return 1; } }; +template + requires std::copyable && (Addable || Sized) +struct Box : Base { + T value; + std::vector entries; + Helper helper; + using value_type = T; + Box(Addable auto x) requires std::constructible_from : value(x) { helper.work(); } + template requires requires(U u) { u.size(); } + void set(const U& u) requires Addable { helper.work(); } + void run(std::integral auto n) { helper.work(); } + template requires std::convertible_to + void assign(U u) { value = u; } +}; +using IntBox = Box; +enum class State { idle, ready }; +} diff --git a/frontend/graph.ts b/frontend/graph.ts index d88771b..d0cf159 100644 --- a/frontend/graph.ts +++ b/frontend/graph.ts @@ -22,7 +22,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option

Independent of LLM context. Higher depths may take longer.

-

Select a concept or function name in the editor.

+

Select a concept, function, class, or struct name in the editor.

100%
Coverage and limits
`; pane.append(panel); @@ -107,7 +107,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option const p = node.location.range.start; open.onclick = () => void options.open(node.location.path, { path: node.location.path, line:p.line+1, column:p.character+1,end_column:p.character+1 },true); $('graph-detail').append(pre,open); - if (node.kind === 'concept' || node.kind === 'selected' || node.kind === 'function') { const focus = document.createElement('button'); focus.textContent = node.kind === 'concept' ? 'Focus this concept' : 'Focus this symbol'; focus.onclick = () => void refresh(node.location); $('graph-detail').append(focus); } + if (node.kind === 'concept' || node.kind === 'selected' || node.kind === 'function' || node.kind === 'type') { const focus = document.createElement('button'); focus.textContent = node.kind === 'concept' ? 'Focus this concept' : 'Focus this symbol'; focus.onclick = () => void refresh(node.location); $('graph-detail').append(focus); } } function render(value: Graph) { const layout = new dagre.graphlib.Graph().setGraph({ rankdir:'TB', nodesep:32, ranksep:54, marginx:24, marginy:24 }).setDefaultEdgeLabel(() => ({})); @@ -130,7 +130,7 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option const label=svg('text',{x:'12',y:'38'}); const text=node.label.replace(/\s+/g,' '); const lines=text.match(/.{1,32}(?:\s|$)|.{1,32}/g) || ['']; lines.slice(0,3).forEach((line,i) => {const span=svg('tspan',{x:'12',dy:i?'17':'0'});span.textContent=line.trim()+(i===2&&lines.length>3?'…':'');label.append(span);});group.append(label); - group.onclick=() => select(node); group.ondblclick=() => { if (['concept','function','selected'].includes(node.kind)) void refresh(node.location); };group.onkeydown=event => { + group.onclick=() => select(node); group.ondblclick=() => { if (['concept','function','type','selected'].includes(node.kind)) void refresh(node.location); };group.onkeydown=event => { if(event.key==='Enter'||event.key===' '){event.preventDefault();select(node);} if (event.key.startsWith('Arrow')) { const direction = event.key, here = layoutPositions.get(node.id)!; @@ -152,12 +152,13 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option const rendered = drawing; requestAnimationFrame(() => { if (drawing === rendered) { fit(true); centerSymbol(); } }); } + function building(value: boolean) { $('graph-progress').hidden = !value; canvas.setAttribute('aria-busy', String(value)); $('graph-refresh').disabled = value; } async function refresh(location?: Location, record = true) { const workspace=options.workspace(); if(!workspace) return; const path=location?.path || options.path(), p=editor.getPosition(); if(!path||!p) return; const position=location?.range.start || {line:p.lineNumber-1,character:p.column-1}; const operation=++ticket, epoch=options.epoch(); show('graph'); - $('graph-status').textContent='Building relationships with clangd…'; $('graph-refresh').disabled=true; + $('graph-status').textContent='Building relationships with clangd…'; building(true); graph=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren(); try { await options.ready(); if(operation!==ticket||epoch!==options.epoch())return; @@ -165,13 +166,13 @@ export function installGraph(editor: monaco.editor.IStandaloneCodeEditor, option if(operation!==ticket||epoch!==options.epoch())return; if (record && center) { back.push(center); if (back.length > 30) back.shift(); forward.length=0; } graph=result; center=graph.nodes.find(n => n.id === 'selected')?.location; historyControls(); render(graph);$('graph-title').textContent=graph.title; - $('graph-status').textContent='Dependencies / callees ↑ · uses / callers ↓. Select for details; double-click a symbol to refocus. Drag to pan, Ctrl/⌘+scroll to zoom.'; + $('graph-status').textContent='Dependencies / constraints ↑ · members / uses / callers ↓. Select for details; double-click a symbol to refocus. Drag to pan, Ctrl/⌘+scroll to zoom.'; } catch(error) {if(operation===ticket)$('graph-status').textContent=String(error);} - finally {if(operation===ticket)$('graph-refresh').disabled=false;} + finally {if(operation===ticket)building(false);} } $('graph-back').onclick = () => { const previous = back.pop(); if (previous) {if (center) forward.push(center); historyControls(); void refresh(previous, false);} }; $('graph-forward').onclick = () => { const next = forward.pop(); if (next) {if (center) back.push(center); historyControls(); void refresh(next, false);} }; $('graph-refresh').onclick=()=>void refresh();$('nav-graph').onclick=()=>void refresh(); editor.addAction({id:'cex.graph',label:'Show Symbol Graph',contextMenuGroupId:'navigation',contextMenuOrder:3,run:()=>refresh()}); - return { reset(){++ticket;center=undefined;back.length=0;forward.length=0;historyControls();graphOptions=structuredClone(options.workspace()?.settings?.graph ?? {depth:1,excluded_namespaces:['std']});settingsUI();$('graph-options-status').textContent='';toggle.disabled=false;$('nav-graph').disabled=true;graph=null;drawing=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren();$('graph-title').textContent='Symbol graph';$('graph-status').textContent='Select a concept or function name in the editor.';$('graph-refresh').disabled=false;collapse();}, fileOpened(){toggle.disabled=false;$('nav-graph').disabled=editor.getModel()?.getLanguageId()!=='cpp';} }; + return { reset(){++ticket;building(false);center=undefined;back.length=0;forward.length=0;historyControls();graphOptions=structuredClone(options.workspace()?.settings?.graph ?? {depth:1,excluded_namespaces:['std']});settingsUI();$('graph-options-status').textContent='';toggle.disabled=false;$('nav-graph').disabled=true;graph=null;drawing=null; $('graph-canvas').replaceChildren();$('graph-detail').replaceChildren();$('graph-warnings').replaceChildren();$('graph-title').textContent='Symbol graph';$('graph-status').textContent='Select a concept, function, class, or struct name in the editor.';$('graph-refresh').disabled=false;collapse();}, fileOpened(){toggle.disabled=false;$('nav-graph').disabled=editor.getModel()?.getLanguageId()!=='cpp';} }; } diff --git a/frontend/main.ts b/frontend/main.ts index 0a9c4db..c0f9226 100644 --- a/frontend/main.ts +++ b/frontend/main.ts @@ -6,6 +6,7 @@ import 'monaco-editor/esm/vs/editor/contrib/folding/browser/folding'; import 'monaco-editor/esm/vs/editor/contrib/find/browser/findController'; import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import './style.css'; +import { installSidebarResize } from './resize'; import { installTheme } from './theme'; import { installNavigation } from './navigation'; import { installGraph } from './graph'; @@ -21,7 +22,7 @@ type Hit = { path: string; line: number; column: number; end_column: number; pre type SearchResults = { hits: Hit[]; truncated: boolean; cancelled: boolean; skipped_files: number }; const $ = (id: string) => document.getElementById(id) as T; document.querySelector('#app')!.innerHTML = ` -
cexCODE EXPLORER
No project open
+
File
Open Recent
Loading…
cexCODE EXPLORER
No project open
{ cex }

Open Recent

Pick up where you left off, or explore a new project.

Recent projects

Loading recent projects…

History is stored in ~/.cex. Project settings stay with each project.