Skip to main content

bynk_lsp/
architecture_request.rs

1//! #851: `bynk/architectureModel` — the architecture-map custom LSP request.
2//!
3//! The third custom request in this server (after #846's `bynk/sequenceModel`
4//! and #847's `bynk/documentationModel`), and the same on-demand posture: no
5//! `workspace/*/refresh` nudge exists for a custom method and none is needed
6//! — the client re-issues the request each time "Bynk: Show Architecture Map"
7//! fires.
8//!
9//! Unlike both siblings, this request is **project-scoped**, not file-scoped:
10//! the params carry a `textDocument` only to resolve which project's
11//! committed round to read (the same `committed_analysis` gate every pull-
12//! based request uses), never to restrict the result to that one file. The
13//! wire model reflects that: every node/member/edge carries its own `uri` —
14//! not just a `range` against the request's document, the convention
15//! `sequence`/`documentation` can get away with because they're single-file.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use bynk_ide::architecture::{self, ArchModel, CapabilityOrigin, NodeKind};
21
22/// The `bynk/architectureModel` request payload — a bare text-document
23/// identifier used only to resolve the owning project (Decision B: the
24/// active file's nearest `bynk.toml`), never to scope the result to that file.
25///
26/// `rename_all = "camelCase"` is load-bearing — see `sequence_request`'s and
27/// `documentation_request`'s own params docs for why (the client sends
28/// `textDocument`; this shipped as a missing-field bug once already, in #846,
29/// before #847 caught and fixed it).
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31#[serde(rename_all = "camelCase")]
32pub struct ArchitectureModelParams {
33    pub text_document: tower_lsp::lsp_types::TextDocumentIdentifier,
34}
35
36/// Build the project-wide architecture model from the round's own retained
37/// tables. A thin wrapper around [`architecture::architecture_model`] so the
38/// `lib.rs` handler stays a `committed_analysis` guard chain, like its two
39/// siblings.
40pub fn architecture_model_for(
41    unit_sources: &HashMap<String, Vec<PathBuf>>,
42    snapshots: &HashMap<PathBuf, String>,
43    sequence_info: &HashMap<String, bynk_ide::ContextSequenceInfo>,
44) -> ArchModel {
45    architecture::architecture_model(unit_sources, snapshots, sequence_info)
46}
47
48// -- Wire shape: a plain serde mirror of `bynk_ide::architecture::ArchModel`.
49// -- Each `Located` lowers to a `{uri, range}` pair computed against *its
50// -- own* file's committed snapshot — never the request document's, since a
51// -- node's members may span several files of a multi-file unit, and two
52// -- nodes almost always live in different files entirely.
53
54#[derive(Debug, Clone, serde::Serialize)]
55pub struct WireLoc {
56    pub uri: tower_lsp::lsp_types::Url,
57    pub range: tower_lsp::lsp_types::Range,
58}
59
60#[derive(Debug, Clone, serde::Serialize)]
61pub struct WireArchModel {
62    pub nodes: Vec<WireArchNode>,
63    pub edges: Vec<WireArchEdge>,
64}
65
66#[derive(Debug, Clone, serde::Serialize)]
67pub struct WireArchNode {
68    pub name: String,
69    pub kind: &'static str,
70    pub loc: WireLoc,
71    pub capabilities: Vec<WireArchCapability>,
72    pub providers: Vec<WireArchProvider>,
73    pub services: Vec<WireArchService>,
74    pub agents: Vec<WireArchAgent>,
75}
76
77#[derive(Debug, Clone, serde::Serialize)]
78pub struct WireArchCapability {
79    pub name: String,
80    pub local: bool,
81    /// The providing unit's qualified name, for a consumed (non-local)
82    /// capability — `"bynk"` for the toolchain's built-in surface. `null` for
83    /// a locally-declared capability.
84    pub from: Option<String>,
85    pub loc: WireLoc,
86}
87
88#[derive(Debug, Clone, serde::Serialize)]
89pub struct WireArchProvider {
90    pub capability: String,
91    #[serde(rename = "providerName")]
92    pub provider_name: String,
93    pub external: bool,
94    pub loc: WireLoc,
95}
96
97#[derive(Debug, Clone, serde::Serialize)]
98pub struct WireArchService {
99    pub name: String,
100    #[serde(rename = "handlerCount")]
101    pub handler_count: usize,
102    pub loc: WireLoc,
103}
104
105#[derive(Debug, Clone, serde::Serialize)]
106pub struct WireArchAgent {
107    pub name: String,
108    #[serde(rename = "handlerCount")]
109    pub handler_count: usize,
110    pub loc: WireLoc,
111}
112
113#[derive(Debug, Clone, serde::Serialize)]
114pub struct WireArchEdge {
115    pub from: String,
116    pub to: String,
117    /// Selected capability labels (braced `consumes` form); empty for a
118    /// whole-unit consumes.
119    pub capabilities: Vec<String>,
120    pub loc: WireLoc,
121}
122
123fn kind_str(k: NodeKind) -> &'static str {
124    match k {
125        NodeKind::Context => "Context",
126        NodeKind::Adapter => "Adapter",
127    }
128}
129
130/// Lower a `Located` to a `{uri, range}` pair. `None` only on the defensive
131/// paths — a missing snapshot for `loc.file` (every `Located` here was built
132/// from a file `architecture_model` itself read out of `snapshots`, so this
133/// should not happen) or a path that cannot form a `file://` URI.
134fn wire_loc(
135    project_root: &Path,
136    snapshots: &HashMap<PathBuf, String>,
137    loc: &architecture::Located,
138) -> Option<WireLoc> {
139    let text = snapshots.get(&loc.file)?;
140    let uri = tower_lsp::lsp_types::Url::from_file_path(project_root.join(&loc.file)).ok()?;
141    Some(WireLoc {
142        uri,
143        range: crate::position::span_to_range(text, loc.span),
144    })
145}
146
147/// Lower the whole model. A node (or member, or edge) whose own location
148/// can't be resolved (see `wire_loc` above) is dropped rather than sent with
149/// a bogus location — click-to-code must never open the wrong file.
150pub fn to_wire(
151    model: &ArchModel,
152    project_root: &Path,
153    snapshots: &HashMap<PathBuf, String>,
154) -> WireArchModel {
155    WireArchModel {
156        nodes: model
157            .nodes
158            .iter()
159            .filter_map(|n| {
160                Some(WireArchNode {
161                    name: n.name.clone(),
162                    kind: kind_str(n.kind),
163                    loc: wire_loc(project_root, snapshots, &n.loc)?,
164                    capabilities: n
165                        .capabilities
166                        .iter()
167                        .filter_map(|c| {
168                            let (local, from) = match &c.origin {
169                                CapabilityOrigin::Local => (true, None),
170                                CapabilityOrigin::Consumed { from } => (false, Some(from.clone())),
171                            };
172                            Some(WireArchCapability {
173                                name: c.name.clone(),
174                                local,
175                                from,
176                                loc: wire_loc(project_root, snapshots, &c.loc)?,
177                            })
178                        })
179                        .collect(),
180                    providers: n
181                        .providers
182                        .iter()
183                        .filter_map(|p| {
184                            Some(WireArchProvider {
185                                capability: p.capability.clone(),
186                                provider_name: p.provider_name.clone(),
187                                external: p.external,
188                                loc: wire_loc(project_root, snapshots, &p.loc)?,
189                            })
190                        })
191                        .collect(),
192                    services: n
193                        .services
194                        .iter()
195                        .filter_map(|s| {
196                            Some(WireArchService {
197                                name: s.name.clone(),
198                                handler_count: s.handler_count,
199                                loc: wire_loc(project_root, snapshots, &s.loc)?,
200                            })
201                        })
202                        .collect(),
203                    agents: n
204                        .agents
205                        .iter()
206                        .filter_map(|a| {
207                            Some(WireArchAgent {
208                                name: a.name.clone(),
209                                handler_count: a.handler_count,
210                                loc: wire_loc(project_root, snapshots, &a.loc)?,
211                            })
212                        })
213                        .collect(),
214                })
215            })
216            .collect(),
217        edges: model
218            .edges
219            .iter()
220            .filter_map(|e| {
221                Some(WireArchEdge {
222                    from: e.from.clone(),
223                    to: e.to.clone(),
224                    capabilities: e.capabilities.clone(),
225                    loc: wire_loc(project_root, snapshots, &e.loc)?,
226                })
227            })
228            .collect(),
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use std::fs;
236
237    fn setup_project(test_name: &str, files: &[(&str, &str)]) -> PathBuf {
238        let root = std::env::temp_dir().join(format!(
239            "bynk-lsp-architecture-request-test-{test_name}-{}",
240            std::process::id()
241        ));
242        let _ = fs::remove_dir_all(&root);
243        fs::create_dir_all(&root).expect("create test root");
244        for (rel, contents) in files {
245            let p = root.join(rel);
246            if let Some(parent) = p.parent() {
247                fs::create_dir_all(parent).expect("create parent");
248            }
249            fs::write(&p, contents).expect("write file");
250        }
251        root
252    }
253
254    const PROVIDER_SRC: &str = r#"context platformtime
255
256exports capability { Clock }
257
258capability Clock {
259  fn now() -> Effect[Int]
260}
261
262provides Clock = SystemClock {
263  fn now() -> Effect[Int] {
264    0
265  }
266}
267"#;
268    const CONSUMER_SRC: &str = r#"context ops.jobs
269
270consumes platformtime { Clock }
271
272service run {
273  on call() -> Effect[Int] given Clock {
274    let now <- Clock.now()
275    now
276  }
277}
278"#;
279
280    #[test]
281    fn params_deserialize_from_camel_case_wire_json() {
282        let json = serde_json::json!({
283            "textDocument": { "uri": "file:///a/b.bynk" }
284        });
285        let params: ArchitectureModelParams =
286            serde_json::from_value(json).expect("camelCase textDocument must deserialize");
287        assert_eq!(params.text_document.uri.as_str(), "file:///a/b.bynk");
288    }
289
290    /// Regression (the exact bug the advisor flagged): a node's own file
291    /// differs from the *other* node's file, and each member's `uri` must
292    /// point at the file it was actually declared in — not the request
293    /// document, not the first file the model happened to visit.
294    #[test]
295    fn each_node_uri_points_at_its_own_declaring_file_not_the_others() {
296        let root = setup_project(
297            "twofile",
298            &[
299                ("platformtime.bynk", PROVIDER_SRC),
300                ("jobs.bynk", CONSUMER_SRC),
301            ],
302        );
303        let diag = bynk_ide::diagnose_project(
304            &root,
305            &bynk_testkit::read_project_sources(&bynk_ide::AnalysisRoots::SingleTree(root.clone())),
306        );
307        let snapshots: HashMap<PathBuf, String> = diag
308            .files
309            .iter()
310            .map(|f| (f.source_path.clone(), f.text.clone()))
311            .collect();
312        let model = architecture_model_for(&diag.unit_sources, &snapshots, &diag.sequence_info);
313        let wire = to_wire(&model, &root, &snapshots);
314
315        assert_eq!(wire.nodes.len(), 2);
316        let jobs = wire.nodes.iter().find(|n| n.name == "ops.jobs").unwrap();
317        let platformtime = wire
318            .nodes
319            .iter()
320            .find(|n| n.name == "platformtime")
321            .unwrap();
322
323        assert!(
324            jobs.loc.uri.as_str().ends_with("jobs.bynk"),
325            "got {}",
326            jobs.loc.uri
327        );
328        assert!(
329            platformtime.loc.uri.as_str().ends_with("platformtime.bynk"),
330            "got {}",
331            platformtime.loc.uri
332        );
333        assert_ne!(jobs.loc.uri, platformtime.loc.uri);
334
335        // The consumed capability entry lives in the *consuming* file
336        // (`jobs.bynk`'s `consumes` clause), even though the capability
337        // itself is declared over in `platformtime.bynk`.
338        assert_eq!(jobs.capabilities.len(), 1);
339        assert!(jobs.capabilities[0].loc.uri.as_str().ends_with("jobs.bynk"));
340        assert_eq!(jobs.capabilities[0].from.as_deref(), Some("platformtime"));
341
342        assert_eq!(wire.edges.len(), 1);
343        assert_eq!(wire.edges[0].from, "ops.jobs");
344        assert_eq!(wire.edges[0].to, "platformtime");
345        assert!(wire.edges[0].loc.uri.as_str().ends_with("jobs.bynk"));
346    }
347}