Skip to main content

isml_lsp/
routes.rs

1//! SFRA route override chains: a `replace` to the left, or a missing `superModule` extend, silently discards handlers.
2
3use 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    /// Adds middleware to every request for the route.
15    Use,
16    /// Adds middleware after what the cartridges to the right defined.
17    Append,
18    /// Adds middleware before it.
19    Prepend,
20    /// Discards it and defines the route anew.
21    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    /// Zero-based.
58    pub line: u32,
59}
60
61#[derive(Debug, Clone)]
62pub struct ControllerFile {
63    pub cartridge: String,
64    /// The file stem.
65    pub controller: String,
66    pub path: PathBuf,
67    /// False when the file never calls `server.extend(module.superModule)`,
68    /// which cuts the chain: nothing to its right is loaded at all.
69    pub extends: bool,
70    pub declarations: Vec<Declaration>,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Effect {
75    Runs,
76    /// Reached by no request: replaced, or in a file the chain never loads.
77    Shadowed,
78    /// In a cartridge this site's path does not contain.
79    OutOfPath,
80    /// The checkout records no cartridge path, so the order is not knowable.
81    Unknown,
82}
83
84#[derive(Debug, Clone)]
85pub struct Link {
86    pub cartridge: String,
87    pub verb: Verb,
88    pub path: PathBuf,
89    /// Zero-based.
90    pub line: u32,
91    pub effect: Effect,
92}
93
94/// Unindexed: the set is small and the questions few.
95#[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    /// Leftmost cartridge first, each link judged by whether a request reaches it.
130    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            // A file runs top to bottom, so a `replace` only discards the
166            // cartridges to its right — not an `append` further down its own
167            // file, which attaches to the route the `replace` just installed.
168            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/// A route, as SFRA names it: `Account-Show`.
184#[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
208/// Whether a controller chains to the one to its right; a file that does not cuts the chain.
209/// Covers `server.extend(module.superModule)` and the more common `var page = module.superModule; server.extend(page);`.
210pub fn extends_super_module(source: &str) -> bool {
211    source.contains("server.extend(") && source.contains("module.superModule")
212}
213
214/// Scans the whole source, not line by line: the route often sits on the line below the call.
215pub 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}