1#![doc = include_str!("README.md")]
2
3use std::{collections::HashMap, fmt::Write as _, fs::OpenOptions, io::Write as _, path::Path};
4
5use anyhow::{Context, Result, bail};
6
7use crate::{
8 state::graph::RelationGraph,
9 state::{HierarchyKind, SourceLocation},
10};
11
12const FORMAT_HEADER: &str = "cgraph graph · text v1";
13
14pub fn render_text(graph: &RelationGraph) -> String {
21 let known = graph.known_graph();
22 let mut node_ids = known.nodes;
23 node_ids.sort_by(|left, right| {
24 let left = graph.node(*left).expect("known graph nodes exist");
25 let right = graph.node(*right).expect("known graph nodes exist");
26 node_sort_key(left).cmp(&node_sort_key(right))
27 });
28 let local_ids = node_ids
29 .iter()
30 .enumerate()
31 .map(|(index, node_id)| (*node_id, index + 1))
32 .collect::<HashMap<_, _>>();
33
34 let mut output = String::new();
35 writeln!(output, "{FORMAT_HEADER}\n").expect("writing to String cannot fail");
36 writeln!(output, "Nodes ({})", node_ids.len()).expect("writing to String cannot fail");
37 for node_id in &node_ids {
38 let node = graph.node(*node_id).expect("known graph nodes exist");
39 let kind = match node.kind {
40 HierarchyKind::Call => "call",
41 HierarchyKind::Type => "type",
42 };
43 let anchor = if graph.is_anchor(*node_id) {
44 " [anchor]"
45 } else {
46 ""
47 };
48 writeln!(
49 output,
50 " [{}] {} {}{}",
51 local_ids[node_id],
52 kind,
53 inline_text(&node.symbol),
54 anchor,
55 )
56 .expect("writing to String cannot fail");
57 writeln!(output, " {}", display_location(node.location.as_ref()))
58 .expect("writing to String cannot fail");
59 }
60
61 let mut edges = known
62 .edges
63 .into_iter()
64 .map(|edge| {
65 (
66 local_ids[&edge.source],
67 edge.source,
68 local_ids[&edge.target],
69 edge.target,
70 )
71 })
72 .collect::<Vec<_>>();
73 edges.sort_unstable_by_key(|(source, _, target, _)| (*source, *target));
74 writeln!(output, "\nRelations ({})", edges.len()).expect("writing to String cannot fail");
75 for (source, source_id, target, target_id) in edges {
76 let source_name = &graph
77 .node(source_id)
78 .expect("known graph nodes exist")
79 .symbol;
80 let target_name = &graph
81 .node(target_id)
82 .expect("known graph nodes exist")
83 .symbol;
84 writeln!(
85 output,
86 " [{source}] {} → [{target}] {}",
87 inline_text(source_name),
88 inline_text(target_name)
89 )
90 .expect("writing to String cannot fail");
91 }
92 output
93}
94
95pub fn write_text(graph: &RelationGraph, path: &Path) -> Result<()> {
99 if path.as_os_str().is_empty() {
100 bail!("destination path is empty");
101 }
102 let mut file = OpenOptions::new()
103 .write(true)
104 .create_new(true)
105 .open(path)
106 .with_context(|| format!("failed to create export {}", path.display()))?;
107 file.write_all(render_text(graph).as_bytes())
108 .and_then(|()| file.flush())
109 .with_context(|| format!("failed to write export {}", path.display()))
110}
111
112fn node_sort_key(
113 node: &crate::state::graph::GraphNode,
114) -> (u8, Option<&str>, Option<u32>, Option<u32>, &str, u64) {
115 let kind = match node.kind {
116 HierarchyKind::Call => 0,
117 HierarchyKind::Type => 1,
118 };
119 (
120 kind,
121 node.location.as_ref().map(|location| location.uri.as_str()),
122 node.location.as_ref().and_then(|location| location.line),
123 node.location
124 .as_ref()
125 .and_then(|location| location.character),
126 node.symbol.as_str(),
127 node.id.0,
128 )
129}
130
131fn inline_text(value: &str) -> String {
132 if value.is_empty() {
133 return "<unnamed>".to_owned();
134 }
135 value
136 .replace('\\', "\\\\")
137 .replace('\n', "\\n")
138 .replace('\r', "\\r")
139 .replace('\t', "\\t")
140}
141
142fn display_location(location: Option<&SourceLocation>) -> String {
143 let Some(location) = location else {
144 return "location unknown".to_owned();
145 };
146 let mut display = inline_text(&location.uri);
147 if let Some(line) = location.line {
148 write!(display, ":{}", line.saturating_add(1)).expect("writing to String cannot fail");
149 if let Some(character) = location.character {
150 write!(display, ":{}", character.saturating_add(1))
151 .expect("writing to String cannot fail");
152 }
153 } else if let Some(character) = location.character {
154 write!(display, " · character {}", character.saturating_add(1))
155 .expect("writing to String cannot fail");
156 }
157 display
158}
159
160#[cfg(test)]
161mod tests {
162 use std::{fs, path::PathBuf, time::SystemTime};
163
164 use super::{render_text, write_text};
165 use crate::state::{
166 HierarchyDirection, HierarchyKind, SourceLocation, SymbolIdentity, graph::RelationGraph,
167 };
168
169 #[test]
170 fn renders_stable_nodes_shared_edges_and_cycles() {
171 let mut graph = RelationGraph::default();
172 let root = graph.pin_symbol(identity("root", HierarchyKind::Call, 1));
173 let left = graph
174 .replace_branch_neighbors(
175 root,
176 HierarchyDirection::Outgoing,
177 vec![
178 identity("right", HierarchyKind::Call, 3),
179 identity("left", HierarchyKind::Call, 2),
180 ],
181 )
182 .unwrap();
183 let shared = graph
184 .replace_branch_neighbors(
185 left[0],
186 HierarchyDirection::Outgoing,
187 vec![identity("shared", HierarchyKind::Call, 4)],
188 )
189 .unwrap()[0];
190 graph.replace_branch_neighbors(
191 left[1],
192 HierarchyDirection::Outgoing,
193 vec![identity("shared", HierarchyKind::Call, 4)],
194 );
195 graph.replace_branch_neighbors(
196 shared,
197 HierarchyDirection::Outgoing,
198 vec![identity("root", HierarchyKind::Call, 1)],
199 );
200 graph.pin_symbol(identity("TypeA", HierarchyKind::Type, 10));
201
202 let first = render_text(&graph);
203 let second = render_text(&graph);
204 assert_eq!(first, second);
205 assert!(first.starts_with("cgraph graph · text v1\n\nNodes (5)\n"));
206 assert_eq!(
207 first.lines().filter(|line| line.contains("shared")).count(),
208 4
209 );
210 assert!(first.contains("] call root [anchor]"));
211 assert!(first.contains("] type TypeA [anchor]"));
212 assert!(first.contains("Relations (5)"));
213 assert_eq!(first.matches(" → ").count(), 5);
214 assert!(first.contains("file:///workspace/src/main.rs:2:1"));
215 }
216
217 #[test]
218 fn creates_new_files_and_never_truncates_existing_targets() {
219 let workspace = temporary_workspace("write");
220 let target = workspace.join("graph.txt");
221 let mut graph = RelationGraph::default();
222 graph.pin_symbol(identity("root", HierarchyKind::Call, 1));
223
224 write_text(&graph, &target).unwrap();
225 let written = fs::read_to_string(&target).unwrap();
226 assert_eq!(written, render_text(&graph));
227
228 fs::write(&target, "keep me\n").unwrap();
229 let error = write_text(&graph, &target).unwrap_err();
230 assert!(error.to_string().contains("failed to create export"));
231 assert_eq!(fs::read_to_string(&target).unwrap(), "keep me\n");
232 fs::remove_dir_all(workspace).unwrap();
233 }
234
235 #[test]
236 fn rejects_empty_destination_paths() {
237 let graph = RelationGraph::default();
238 let error = write_text(&graph, PathBuf::new().as_path()).unwrap_err();
239 assert_eq!(error.to_string(), "destination path is empty");
240 }
241
242 fn identity(symbol: &str, kind: HierarchyKind, line: u32) -> SymbolIdentity {
243 SymbolIdentity {
244 symbol: symbol.to_owned(),
245 kind,
246 location: Some(SourceLocation {
247 uri: "file:///workspace/src/main.rs".to_owned(),
248 line: Some(line),
249 character: Some(0),
250 }),
251 }
252 }
253
254 fn temporary_workspace(name: &str) -> PathBuf {
255 let unique = SystemTime::now()
256 .duration_since(SystemTime::UNIX_EPOCH)
257 .unwrap()
258 .as_nanos();
259 let path = std::env::temp_dir().join(format!("cgraph-export-{name}-{unique}"));
260 fs::create_dir(&path).unwrap();
261 path
262 }
263}