1use std::collections::BTreeSet;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::cartridgepath::CartridgePath;
8use crate::workspace::Cartridge;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum Verb {
12 Get,
13 Post,
14 Use,
16 Append,
18 Prepend,
20 Replace,
22}
23
24impl Verb {
25 fn parse(name: &str) -> Option<Verb> {
26 match name {
27 "get" => Some(Verb::Get),
28 "post" => Some(Verb::Post),
29 "use" => Some(Verb::Use),
30 "append" => Some(Verb::Append),
31 "prepend" => Some(Verb::Prepend),
32 "replace" => Some(Verb::Replace),
33 _ => None,
34 }
35 }
36
37 pub fn label(self) -> &'static str {
38 match self {
39 Verb::Get => "get",
40 Verb::Post => "post",
41 Verb::Use => "use",
42 Verb::Append => "append",
43 Verb::Prepend => "prepend",
44 Verb::Replace => "replace",
45 }
46 }
47
48 fn discards_the_rest(self) -> bool {
49 matches!(self, Verb::Replace)
50 }
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Declaration {
55 pub route: String,
56 pub verb: Verb,
57 pub line: u32,
59}
60
61#[derive(Debug, Clone)]
62pub struct ControllerFile {
63 pub cartridge: String,
64 pub controller: String,
66 pub path: PathBuf,
67 pub extends: bool,
70 pub declarations: Vec<Declaration>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Effect {
75 Runs,
76 Shadowed,
78 OutOfPath,
80 Unknown,
82}
83
84#[derive(Debug, Clone)]
85pub struct Link {
86 pub cartridge: String,
87 pub verb: Verb,
88 pub path: PathBuf,
89 pub line: u32,
91 pub effect: Effect,
92}
93
94#[derive(Debug, Default)]
96pub struct Controllers {
97 files: Vec<ControllerFile>,
98}
99
100impl Controllers {
101 pub fn scan(cartridges: &[Cartridge]) -> Controllers {
102 let mut files = Vec::new();
103 for cartridge in cartridges {
104 let directory = cartridge.cartridge_dir().join("controllers");
105 let Ok(entries) = fs::read_dir(&directory) else {
106 continue;
107 };
108 for entry in entries.flatten() {
109 let path = entry.path();
110 if path.extension().is_none_or(|extension| extension != "js") {
111 continue;
112 }
113 if let Some(file) = read_controller(&path, &cartridge.name) {
114 files.push(file);
115 }
116 }
117 }
118 Controllers { files }
119 }
120
121 pub fn routes_of(&self, controller: &str) -> BTreeSet<&str> {
122 self.files
123 .iter()
124 .filter(|file| file.controller == controller)
125 .flat_map(|file| file.declarations.iter().map(|entry| entry.route.as_str()))
126 .collect()
127 }
128
129 pub fn chain(&self, route: &Route, path: Option<&CartridgePath>) -> Vec<Link> {
131 let mut ranked: Vec<(Option<usize>, &ControllerFile)> = self
132 .files
133 .iter()
134 .filter(|file| file.controller == route.controller)
135 .filter(|file| file.declares(&route.name))
136 .map(|file| (path.and_then(|path| path.rank(&file.cartridge)), file))
137 .collect();
138 ranked.sort_by_key(|(rank, file)| (rank.unwrap_or(usize::MAX), file.cartridge.clone()));
139
140 let mut links = Vec::new();
141 let mut loaded = true;
142 let mut settled = false;
143 for (rank, file) in ranked {
144 let mut replaced_here = false;
145 for declaration in file
146 .declarations
147 .iter()
148 .filter(|entry| entry.route == route.name)
149 {
150 let effect = match rank {
151 None if path.is_none() => Effect::Unknown,
152 None => Effect::OutOfPath,
153 Some(_) if !loaded || settled => Effect::Shadowed,
154 Some(_) => Effect::Runs,
155 };
156 links.push(Link {
157 cartridge: file.cartridge.clone(),
158 verb: declaration.verb,
159 path: file.path.clone(),
160 line: declaration.line,
161 effect,
162 });
163 replaced_here |= effect == Effect::Runs && declaration.verb.discards_the_rest();
164 }
165 settled |= replaced_here;
169 if rank.is_some() && !file.extends {
170 loaded = false;
171 }
172 }
173 links
174 }
175}
176
177impl ControllerFile {
178 fn declares(&self, route: &str) -> bool {
179 self.declarations.iter().any(|entry| entry.route == route)
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct Route {
186 pub controller: String,
187 pub name: String,
188}
189
190impl Route {
191 pub fn endpoint(&self) -> String {
192 format!("{}-{}", self.controller, self.name)
193 }
194}
195
196fn read_controller(path: &Path, cartridge: &str) -> Option<ControllerFile> {
197 let source = fs::read_to_string(path).ok()?;
198 let controller = path.file_stem()?.to_str()?.to_string();
199 Some(ControllerFile {
200 cartridge: cartridge.to_string(),
201 controller,
202 path: path.to_path_buf(),
203 extends: extends_super_module(&source),
204 declarations: declarations(&source),
205 })
206}
207
208pub fn extends_super_module(source: &str) -> bool {
211 source.contains("server.extend(") && source.contains("module.superModule")
212}
213
214pub fn declarations(source: &str) -> Vec<Declaration> {
216 const CALL: &str = "server.";
217 let mut found = Vec::new();
218 let mut cursor = 0;
219 while let Some(offset) = source[cursor..].find(CALL) {
220 let start = cursor + offset;
221 cursor = start + CALL.len();
222 if is_commented_out(source, start) {
223 continue;
224 }
225 let rest = &source[cursor..];
226 let Some(open) = rest.find('(') else {
227 continue;
228 };
229 let Some(verb) = Verb::parse(rest[..open].trim()) else {
230 continue;
231 };
232 let Some(route) = first_literal(&rest[open + 1..]) else {
233 continue;
234 };
235 found.push(Declaration {
236 route,
237 verb,
238 line: source[..start].matches('\n').count() as u32,
239 });
240 }
241 found
242}
243
244fn is_commented_out(source: &str, offset: usize) -> bool {
245 let line_start = source[..offset].rfind('\n').map_or(0, |index| index + 1);
246 let head = source[line_start..offset].trim_start();
247 head.starts_with("//") || head.starts_with('*')
248}
249
250fn first_literal(text: &str) -> Option<String> {
251 let trimmed = text.trim_start();
252 let quote = trimmed.chars().next()?;
253 if quote != '\'' && quote != '"' {
254 return None;
255 }
256 let body = &trimmed[quote.len_utf8()..];
257 let end = body.find(quote)?;
258 let route = &body[..end];
259 route
260 .chars()
261 .all(|c| c.is_alphanumeric() || c == '_')
262 .then(|| route.to_string())
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 const BASE: &str = r#"
270var server = require('server');
271server.get('Show', cache.applyDefaultCache, function (req, res, next) {});
272server.post('Login', function (req, res, next) {});
273"#;
274
275 const BRAND: &str = r#"
276var server = require('server');
277server.extend(module.superModule);
278// server.get('Ignored', function () {});
279server.append('Show', function (req, res, next) {});
280"#;
281
282 fn controller(cartridge: &str, source: &str, extends: bool) -> ControllerFile {
283 ControllerFile {
284 cartridge: cartridge.to_string(),
285 controller: "Account".to_string(),
286 path: PathBuf::from(format!("{cartridge}/Account.js")),
287 extends,
288 declarations: declarations(source),
289 }
290 }
291
292 fn path_of(order: &[&str]) -> CartridgePath {
293 CartridgePath {
294 label: "storefront".into(),
295 order: order.iter().map(|name| name.to_string()).collect(),
296 }
297 }
298
299 fn route() -> Route {
300 Route {
301 controller: "Account".into(),
302 name: "Show".into(),
303 }
304 }
305
306 #[test]
307 fn reads_the_verb_and_the_route_off_a_declaration() {
308 let found = declarations(BASE);
309 assert_eq!(found.len(), 2);
310 assert_eq!(found[0].route, "Show");
311 assert_eq!(found[0].verb, Verb::Get);
312 assert_eq!(found[1].verb, Verb::Post);
313 }
314
315 #[test]
316 fn reads_a_declaration_whose_route_is_on_the_next_line() {
317 let source =
318 "server.get(\n 'Show',\n server.middleware.https,\n function () {}\n);";
319 let found = declarations(source);
320 assert_eq!(found.len(), 1);
321 assert_eq!(found[0].route, "Show");
322 assert_eq!(found[0].verb, Verb::Get);
323 assert_eq!(found[0].line, 0);
324 }
325
326 #[test]
327 fn accepts_both_ways_of_extending_the_super_module() {
328 assert!(extends_super_module("server.extend(module.superModule);"));
329 assert!(extends_super_module(
330 "var page = module.superModule;\nserver.extend(page);"
331 ));
332 assert!(!extends_super_module("server.get('Show', function () {});"));
333 }
334
335 #[test]
336 fn leaves_a_commented_out_declaration_alone() {
337 let found = declarations(BRAND);
338 assert_eq!(found.len(), 1);
339 assert_eq!(found[0].verb, Verb::Append);
340 }
341
342 #[test]
343 fn orders_the_chain_by_the_cartridge_path() {
344 let controllers = Controllers {
345 files: vec![
346 controller("app_storefront_base", BASE, false),
347 controller("app_brand", BRAND, true),
348 ],
349 };
350 let chain = controllers.chain(
351 &route(),
352 Some(&path_of(&["app_brand", "app_storefront_base"])),
353 );
354 assert_eq!(chain.len(), 2);
355 assert_eq!(chain[0].cartridge, "app_brand");
356 assert_eq!(chain[0].verb, Verb::Append);
357 assert_eq!(chain[1].cartridge, "app_storefront_base");
358 assert!(chain.iter().all(|link| link.effect == Effect::Runs));
359 }
360
361 #[test]
362 fn marks_what_a_replace_to_the_left_discards() {
363 let replacing =
364 "server.extend(module.superModule);\nserver.replace('Show', function () {});";
365 let controllers = Controllers {
366 files: vec![
367 controller("app_storefront_base", BASE, false),
368 controller("app_brand", BRAND, true),
369 controller("plugin_overrides", replacing, true),
370 ],
371 };
372 let chain = controllers.chain(
373 &route(),
374 Some(&path_of(&[
375 "plugin_overrides",
376 "app_brand",
377 "app_storefront_base",
378 ])),
379 );
380 assert_eq!(chain[0].effect, Effect::Runs);
381 assert_eq!(chain[0].verb, Verb::Replace);
382 assert_eq!(chain[1].effect, Effect::Shadowed);
383 assert_eq!(chain[2].effect, Effect::Shadowed);
384 }
385
386 #[test]
387 fn keeps_an_append_that_follows_a_replace_in_the_same_file() {
388 let both = "server.extend(module.superModule);\n\
389 server.replace('Show', function () {});\n\
390 server.append('Show', function () {});";
391 let controllers = Controllers {
392 files: vec![
393 controller("app_storefront_base", BASE, false),
394 controller("app_brand", both, true),
395 ],
396 };
397 let chain = controllers.chain(
398 &route(),
399 Some(&path_of(&["app_brand", "app_storefront_base"])),
400 );
401 assert_eq!(chain[0].verb, Verb::Replace);
402 assert_eq!(chain[0].effect, Effect::Runs);
403 assert_eq!(chain[1].verb, Verb::Append);
404 assert_eq!(chain[1].effect, Effect::Runs);
405 assert_eq!(chain[2].cartridge, "app_storefront_base");
406 assert_eq!(chain[2].effect, Effect::Shadowed);
407 }
408
409 #[test]
410 fn cuts_the_chain_where_a_controller_does_not_extend_its_super_module() {
411 let standalone = "server.get('Show', function () {});";
412 let controllers = Controllers {
413 files: vec![
414 controller("app_storefront_base", BASE, false),
415 controller("app_brand", standalone, false),
416 ],
417 };
418 let chain = controllers.chain(
419 &route(),
420 Some(&path_of(&["app_brand", "app_storefront_base"])),
421 );
422 assert_eq!(chain[0].effect, Effect::Runs);
423 assert_eq!(chain[1].effect, Effect::Shadowed);
424 }
425
426 #[test]
427 fn keeps_a_cartridge_the_site_does_not_use_out_of_the_running() {
428 let controllers = Controllers {
429 files: vec![controller("app_other_brand", BRAND, true)],
430 };
431 let chain = controllers.chain(&route(), Some(&path_of(&["app_brand"])));
432 assert_eq!(chain[0].effect, Effect::OutOfPath);
433 }
434}