Skip to main content

cgraph/export/
mod.rs

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
14/// Serializes all known relations reachable from an anchor.
15///
16/// Local node numbers come from semantic sorting instead of process-global
17/// `NodeId` allocation order, so repeated exports of the same graph state are
18/// byte-for-byte stable. Control characters are escaped so a symbol cannot
19/// break the deliberately simple one-record-per-line presentation.
20pub 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
95/// Creates a new export file without ever opening an existing target for
96/// truncation. `create_new` makes the non-overwrite guarantee atomic with
97/// respect to another process creating the same path concurrently.
98pub 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}