Skip to main content

isml_lsp/
hover.rs

1//! Route hover: who declares it, in what order, and which one a request reaches.
2
3use std::path::{Path, PathBuf};
4
5use crate::api;
6use crate::reference::Reference;
7use crate::routes::{Effect, Link, Route};
8use crate::workspace::Workspace;
9
10/// A bare route name in `server.<verb>` belongs to the controller being edited.
11pub fn route_of(reference: &Reference, file: &Path) -> Option<Route> {
12    let Reference::Route { controller, name } = reference else {
13        return None;
14    };
15    let controller = match controller {
16        Some(named) => named.clone(),
17        None => file.file_stem()?.to_str()?.to_string(),
18    };
19    Some(Route {
20        controller,
21        name: name.clone(),
22    })
23}
24
25pub fn member_markdown(line: &str, column: usize, text: &str) -> Option<String> {
26    let (class, name) = api::member_at(line, column, text)?;
27    let (member, kind) = api::api().class(&class)?.member(&name)?;
28
29    // The signature carries its own `static`, so the class goes on its own
30    // line rather than in front of a modifier.
31    let shape = match member.shape.is_empty() {
32        true => name.clone(),
33        false => member.shape.clone(),
34    };
35    let mut out = format!("```js\n{class}\n{shape}\n```\n");
36    if !member.description.is_empty() {
37        out.push_str(&format!("\n{}\n", member.description));
38    }
39    out.push_str(&format!("\n`{}`\n", kind_label(kind)));
40    Some(out)
41}
42
43pub fn module_markdown(reference: &Reference) -> Option<String> {
44    let Reference::Module(path) = reference else {
45        return None;
46    };
47    let class = api::api().by_module(path)?;
48    let qualified = path.trim_matches('/').replace('/', ".");
49    let mut out = format!("**{qualified}**\n");
50    if !class.description.is_empty() {
51        out.push_str(&format!("\n{}\n", class.description));
52    }
53    out.push_str(&format!(
54        "\n{} methods, {} properties, {} constants\n",
55        class.methods.len(),
56        class.properties.len(),
57        class.constants.len()
58    ));
59    Some(out)
60}
61
62fn kind_label(kind: api::MemberKind) -> &'static str {
63    match kind {
64        api::MemberKind::Method => "method",
65        api::MemberKind::Property => "property",
66        api::MemberKind::Constant => "constant",
67    }
68}
69
70pub struct Chain {
71    pub label: Option<String>,
72    /// Leftmost cartridge first.
73    pub links: Vec<Link>,
74}
75
76/// One chain per recorded cartridge path; a single unordered one when none is recorded.
77pub fn chains(route: &Route, workspace: &Workspace) -> Vec<Chain> {
78    let controllers = workspace.controllers();
79    if workspace.paths.is_empty() {
80        let links = controllers.chain(route, None);
81        return match links.is_empty() {
82            true => Vec::new(),
83            false => vec![Chain { label: None, links }],
84        };
85    }
86    workspace
87        .paths
88        .iter()
89        .map(|path| Chain {
90            label: Some(path.label.clone()),
91            links: controllers.chain(route, Some(path)),
92        })
93        .filter(|chain| !chain.links.is_empty())
94        .collect()
95}
96
97/// Every declaration, most relevant first, for `Go to Definition`.
98pub fn targets(chains: &[Chain]) -> Vec<(PathBuf, u32)> {
99    let mut targets: Vec<(PathBuf, u32)> = Vec::new();
100    for effect in [
101        Effect::Runs,
102        Effect::Unknown,
103        Effect::Shadowed,
104        Effect::OutOfPath,
105    ] {
106        for chain in chains {
107            for link in chain.links.iter().filter(|link| link.effect == effect) {
108                let target = (link.path.clone(), link.line);
109                if !targets.contains(&target) {
110                    targets.push(target);
111                }
112            }
113        }
114    }
115    targets
116}
117
118pub fn markdown(route: &Route, chains: &[Chain], current: &Path) -> Option<String> {
119    if chains.is_empty() {
120        return None;
121    }
122    let mut out = format!("**{}**\n", route.endpoint());
123
124    for chain in chains {
125        if let Some(label) = &chain.label {
126            out.push_str(&format!("\n`{label}`\n"));
127        }
128        out.push_str("\n| | Cartridge | | |\n| --- | --- | --- | --- |\n");
129        for (index, link) in chain.links.iter().enumerate() {
130            let here = match link.path == current {
131                true => " ← this file",
132                false => "",
133            };
134            out.push_str(&format!(
135                "| {} | `{}` | {} | {}{} |\n",
136                index + 1,
137                link.cartridge,
138                link.verb.label(),
139                note(link.effect),
140                here
141            ));
142        }
143        if let Some(warning) = warning(&chain.links) {
144            out.push_str(&format!("\n{warning}\n"));
145        }
146    }
147
148    if chains.iter().all(|chain| chain.label.is_none()) {
149        out.push_str(
150            "\nOrder unknown: nothing here records a cartridge path. Set `cartridge_path` in \
151             this server's initialization options, add a `cartridge` array to `dw.json`, or \
152             check a site archive in.\n",
153        );
154    }
155    Some(out)
156}
157
158fn note(effect: Effect) -> &'static str {
159    match effect {
160        Effect::Runs => "runs",
161        Effect::Shadowed => "**never reached**",
162        Effect::OutOfPath => "not in this path",
163        Effect::Unknown => "",
164    }
165}
166
167/// The sentence worth reading when something in the chain is dead.
168fn warning(links: &[Link]) -> Option<String> {
169    let shadowing = links
170        .iter()
171        .take_while(|link| link.effect == Effect::Runs)
172        .last()?;
173    let shadowed = links.iter().find(|link| link.effect == Effect::Shadowed)?;
174    Some(format!(
175        "`{}` {}s this route, so `{}` never runs.",
176        shadowing.cartridge,
177        shadowing.verb.label(),
178        shadowed.cartridge
179    ))
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use crate::routes::Verb;
186
187    fn link(cartridge: &str, verb: Verb, effect: Effect) -> Link {
188        Link {
189            cartridge: cartridge.into(),
190            verb,
191            path: PathBuf::from(format!("{cartridge}/Account.js")),
192            line: 3,
193            effect,
194        }
195    }
196
197    fn route() -> Route {
198        Route {
199            controller: "Account".into(),
200            name: "Show".into(),
201        }
202    }
203
204    #[test]
205    fn names_the_endpoint_and_every_link() {
206        let chains = vec![Chain {
207            label: Some("storefront".into()),
208            links: vec![
209                link("app_brand", Verb::Append, Effect::Runs),
210                link("app_storefront_base", Verb::Get, Effect::Runs),
211            ],
212        }];
213        let text = markdown(&route(), &chains, Path::new("nowhere")).unwrap();
214        assert!(text.contains("**Account-Show**"));
215        assert!(text.contains("`storefront`"));
216        assert!(text.contains("app_storefront_base"));
217        assert!(!text.contains("never reached"));
218    }
219
220    #[test]
221    fn says_out_loud_which_cartridge_kills_which() {
222        let chains = vec![Chain {
223            label: Some("storefront".into()),
224            links: vec![
225                link("plugin_overrides", Verb::Replace, Effect::Runs),
226                link("app_brand", Verb::Append, Effect::Shadowed),
227            ],
228        }];
229        let text = markdown(&route(), &chains, Path::new("nowhere")).unwrap();
230        assert!(text.contains("`plugin_overrides` replaces this route, so `app_brand` never runs."));
231    }
232
233    #[test]
234    fn marks_the_file_being_read() {
235        let chains = vec![Chain {
236            label: None,
237            links: vec![link("app_brand", Verb::Append, Effect::Unknown)],
238        }];
239        let here = PathBuf::from("app_brand/Account.js");
240        let text = markdown(&route(), &chains, &here).unwrap();
241        assert!(text.contains("← this file"));
242        assert!(text.contains("nothing here records a cartridge path"));
243    }
244
245    #[test]
246    fn puts_what_runs_before_what_does_not_when_jumping() {
247        let chains = vec![Chain {
248            label: Some("storefront".into()),
249            links: vec![
250                link("a", Verb::Append, Effect::Shadowed),
251                link("b", Verb::Get, Effect::Runs),
252            ],
253        }];
254        let targets = targets(&chains);
255        assert_eq!(targets[0].0, PathBuf::from("b/Account.js"));
256        assert_eq!(targets[1].0, PathBuf::from("a/Account.js"));
257    }
258}