1use std::path::{Path, PathBuf};
4
5use crate::api;
6use crate::reference::Reference;
7use crate::routes::{Effect, Link, Route};
8use crate::workspace::Workspace;
9
10pub 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 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 pub links: Vec<Link>,
74}
75
76pub 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
97pub 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
167fn 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}