Skip to main content

bob/
project.rs

1//! Eclipse project linked folders, as CS-Studio BOY workspaces use them.
2//!
3//! BOY screens name each other through the workspace project: a `.project`
4//! file links folder names to real directories, so `../mks937a/gauge.opi`
5//! from `proj/vacuum/x.opi` means the `mks937a` link of `proj`. A path
6//! through such a project is kept as written (so `..` keeps working across
7//! links), and [`locate`] finds the real file when it is opened.
8//!
9//! DLS modules have no `.project` on disk: the generated launcher
10//! `runcss.sh` beside a module's screens lists the links, and CS-Studio
11//! builds the project from them when it starts. Such a launcher is read (not
12//! run) as a project of its own, in a virtual directory below the script:
13//! `…/opi/opi/runcss.sh/vacuum_4-100` for project `vacuum_4-100`, which
14//! cannot clash with a real file since `runcss.sh` is a file, not a folder.
15//! See [`launcher_path`].
16
17use std::collections::HashMap;
18use std::io::Read;
19use std::path::{Component, Path, PathBuf};
20use std::sync::{LazyLock, Mutex};
21
22/// A project's linked folders: name and real directory.
23type Links = Vec<(String, PathBuf)>;
24
25/// Folder links of each `.project` read so far, by project directory.
26static PROJECTS: LazyLock<Mutex<HashMap<PathBuf, Links>>> = LazyLock::new(Default::default);
27
28/// The DLS CS-Studio launcher that lists a module's links.
29pub const LAUNCHER: &str = "runcss.sh";
30
31/// The projects a launcher builds: name and links.
32type Projects = Vec<(String, Links)>;
33
34/// Projects of each launcher read so far, by script.
35static LAUNCHERS: LazyLock<Mutex<HashMap<PathBuf, Projects>>> = LazyLock::new(Default::default);
36
37/// The real file for `path`: `path` itself when it exists, else the path
38/// through the linked folder of the Eclipse project it lies in, else `path`
39/// with `.` and `..` removed (for the error message).
40pub fn locate(path: &Path) -> PathBuf {
41    locate_within(path, MAX_HOPS)
42}
43
44/// Most links followed from one project into another, so that projects
45/// linking to each other (or to a missing folder of their own) end.
46const MAX_HOPS: usize = 8;
47
48fn locate_within(path: &Path, hops: usize) -> PathBuf {
49    if path.exists() {
50        return path.to_path_buf();
51    }
52    let path = normalize(path);
53    let mut dir = path.parent();
54    while let Some(d) = dir {
55        if let Some(links) = links(d)
56            && let Ok(rest) = path.strip_prefix(d)
57            && let Some(Component::Normal(first)) = rest.components().next()
58            && let Some((_, target)) = links.iter().find(|(name, _)| first == name.as_str())
59        {
60            let below = rest.strip_prefix(first).unwrap_or(Path::new(""));
61            let real = target.join(below);
62            // A link may point into another project.
63            return if real.exists() || hops == 0 {
64                real
65            } else {
66                locate_within(&real, hops - 1)
67            };
68        }
69        dir = d.parent();
70    }
71    path
72}
73
74/// The virtual path of `real` in the project at `project_dir`: the first
75/// linked folder holding it, so its `..` names resolve through the project.
76pub fn virtual_path(project_dir: &Path, real: &Path) -> Option<PathBuf> {
77    let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
78    let targets: Vec<(String, PathBuf)> = links(project_dir)?
79        .into_iter()
80        .map(|(name, target)| (name, canonical(&target)))
81        .collect();
82    let find = |real: &Path| {
83        targets.iter().find_map(|(name, target)| {
84            let rest = real.strip_prefix(target).ok()?;
85            Some(project_dir.join(name).join(rest))
86        })
87    };
88    // A symlinked screen in a linked folder keeps its own name, as in
89    // CS-Studio; one elsewhere (`~/x.opi -> …/opi/x.opi`) goes by where it
90    // points.
91    let dir = match real.parent() {
92        Some(d) if !d.as_os_str().is_empty() => d,
93        _ => Path::new("."),
94    };
95    real.file_name()
96        .and_then(|name| find(&canonical(dir).join(name)))
97        .or_else(|| find(&canonical(real)))
98}
99
100/// The project that `-p DIR` names: `dir` when its `.project` links
101/// folders, else the (first) project of the launcher in `dir` (or `dir`
102/// itself, when that is a `runcss.sh`), else `dir` with a `.project` that
103/// links nothing.
104pub fn project_dir(dir: &Path) -> Option<PathBuf> {
105    let own = links(dir);
106    if own.as_ref().is_some_and(|l| !l.is_empty()) {
107        return Some(dir.to_path_buf());
108    }
109    let script = if dir.file_name().is_some_and(|n| n == LAUNCHER) {
110        dir.to_path_buf()
111    } else {
112        dir.join(LAUNCHER)
113    };
114    match launcher_projects(&script).into_iter().next() {
115        Some((project, _)) => Some(script.join(project)),
116        None => own.map(|_| dir.to_path_buf()),
117    }
118}
119
120/// How far above a screen its launcher may be: in the screen's own folder
121/// (`opi/opi/x.opi`) or up to two above (`opi/opi/common/x.opi`).
122const LAUNCHER_DEPTH: usize = 3;
123
124/// The virtual path of the real BOY screen `real` through the projects of
125/// the launcher in its folder or up to two above, as CS-Studio would open it
126/// from there: `…/opi/opi/x.opi` is
127/// `…/opi/opi/runcss.sh/vacuum_4-100/vacuum/x.opi`. `None` for a path that
128/// is not a real file, one in no launcher's linked folders, and one that a
129/// `.project` nearer than the launcher (or beside it) links: a `.project`
130/// that links nothing, as many DLS modules have, does not count.
131pub fn launcher_path(real: &Path) -> Option<PathBuf> {
132    if !real.is_file() {
133        return None;
134    }
135    let abs = normalize(&std::path::absolute(real).ok()?);
136    for dir in abs.ancestors().skip(1).take(LAUNCHER_DEPTH) {
137        if dir.join(".project").is_file() && virtual_path(dir, real).is_some() {
138            return None;
139        }
140        let script = dir.join(LAUNCHER);
141        if script.is_file() {
142            return launcher_projects(&script)
143                .iter()
144                .find_map(|(project, _)| virtual_path(&script.join(project), real));
145        }
146    }
147    None
148}
149
150/// Whether `dir` is a project: it holds a `.project` (which is not read),
151/// or is one of a launcher's.
152pub fn is_project(dir: &Path) -> bool {
153    match launcher_of(dir) {
154        Some(_) => links(dir).is_some(),
155        None => dir.join(".project").is_file(),
156    }
157}
158
159/// Whether `path` goes through a launcher's project.
160pub fn through_launcher(path: &Path) -> bool {
161    path.ancestors().any(|d| launcher_of(d).is_some())
162}
163
164/// The launcher whose project `dir` would be (`…/runcss.sh/PROJECT`).
165fn launcher_of(dir: &Path) -> Option<&Path> {
166    dir.parent()
167        .filter(|s| s.file_name() == Some(LAUNCHER.as_ref()))
168}
169
170/// Forget the projects read so far, so that changed `.project` and
171/// `runcss.sh` files are read again.
172pub fn forget() {
173    PROJECTS.lock().unwrap().clear();
174    LAUNCHERS.lock().unwrap().clear();
175}
176
177/// The most of a `.project` file that is read.
178const MAX_PROJECT_SIZE: u64 = 1 << 20;
179
180/// The text file `path`, unless it is over [`MAX_PROJECT_SIZE`] bytes or not UTF-8.
181fn read_capped(path: &Path) -> std::io::Result<String> {
182    let mut bytes = Vec::new();
183    std::fs::File::open(path)?
184        .take(MAX_PROJECT_SIZE + 1)
185        .read_to_end(&mut bytes)?;
186    if bytes.len() as u64 > MAX_PROJECT_SIZE {
187        log::warn!("{}: larger than 1 MiB, ignored", path.display());
188        return Err(std::io::Error::other("file too large"));
189    }
190    String::from_utf8(bytes).map_err(std::io::Error::other)
191}
192
193/// Linked folders of `dir`'s project, by name, when `dir` holds a `.project`
194/// or is a project of a launcher (`…/runcss.sh/PROJECT`).
195pub fn links(dir: &Path) -> Option<Links> {
196    if let Some(script) = launcher_of(dir) {
197        let project = dir.file_name()?;
198        return launcher_projects(script)
199            .into_iter()
200            .find(|(name, _)| project == name.as_str())
201            .map(|(_, links)| links);
202    }
203    let mut projects = PROJECTS.lock().unwrap();
204    if let Some(links) = projects.get(dir) {
205        return Some(links.clone());
206    }
207    let text = read_capped(&dir.join(".project")).ok()?;
208    let links = parse(&text, dir);
209    projects.insert(dir.to_path_buf(), links.clone());
210    Some(links)
211}
212
213/// The projects that the launcher `script` builds, as [`parse_launcher`]
214/// finds them; none when it cannot be read.
215fn launcher_projects(script: &Path) -> Projects {
216    let mut launchers = LAUNCHERS.lock().unwrap();
217    if let Some(projects) = launchers.get(script) {
218        return projects.clone();
219    }
220    // Only a file: `x/runcss.sh/p` for a folder `runcss.sh` is a real path.
221    if !script.is_file() {
222        return Vec::new();
223    }
224    let Some(dir) = script.parent() else {
225        return Vec::new();
226    };
227    let projects = read_capped(script)
228        .map(|text| parse_launcher(&text, dir))
229        .unwrap_or_default();
230    launchers.insert(script.to_path_buf(), projects.clone());
231    projects
232}
233
234/// DLS's table of named paths, which `configure-ioc s -p NAME` looks up.
235const REDIRECT_TABLE: &str = "/dls_sw/prod/etc/redirector/redirect_table";
236
237/// The linked folders that the launcher `text`, in `dir`, passes CS-Studio
238/// as `links="REAL=/PROJECT/NAME,…"`, by project, in order. The script is
239/// not run but read by [`Shell`], with `SCRIPT_DIR` as `dir`, `TOP` as
240/// `dir/../../..` unless the script sets it, and `version` from
241/// `$TOP/configure/VERSION` (else `dev`), as the DLS scripts set them.
242fn parse_launcher(text: &str, dir: &Path) -> Projects {
243    parse_launcher_with(text, dir, Path::new(REDIRECT_TABLE))
244}
245
246fn parse_launcher_with(text: &str, dir: &Path, table: &Path) -> Projects {
247    let dir = normalize(&std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf()));
248    let mut shell = Shell {
249        vars: HashMap::from([("SCRIPT_DIR".into(), dir.display().to_string())]),
250        functions: HashMap::new(),
251        table: table.to_path_buf(),
252    };
253    shell.set_top(&dir.join("../../.."));
254    shell.run(text, SOURCE_DEPTH);
255    let mut projects = Projects::new();
256    for entry in shell
257        .vars
258        .get("links")
259        .map_or("", String::as_str)
260        .split(',')
261    {
262        let Some((real, virt)) = entry.trim().split_once('=') else {
263            continue;
264        };
265        let Some((project, name)) = virt.trim_start_matches('/').split_once('/') else {
266            continue;
267        };
268        let name = name.trim_end_matches('/');
269        let real = normalize(Path::new(real));
270        if project.is_empty() || name.is_empty() || name.contains('/') || !real.is_absolute() {
271            continue;
272        }
273        let link = (name.to_string(), real);
274        match projects.iter_mut().find(|(p, _)| p == project) {
275            Some((_, links)) => links.push(link),
276            None => projects.push((project.to_string(), vec![link])),
277        }
278    }
279    projects
280}
281
282/// How deep `source` may nest in a launcher.
283const SOURCE_DEPTH: usize = 4;
284
285/// How deep `$(…)` commands may nest, functions calling functions.
286const CALL_DEPTH: usize = 16;
287
288/// Just enough of the shell to read a launcher without running it: plain
289/// `name=value` lines, `source FILE`, and in `$(…)` the commands `join SEP
290/// ARG…` (as `opi-versions` defines it), `configure-ioc s -p NAME` and the
291/// script's functions whose body is one `echo`. Other commands (and so the
292/// assignments using them) are skipped, as are function bodies.
293struct Shell {
294    vars: HashMap<String, String>,
295    /// Each one-`echo` function's text.
296    functions: HashMap<String, String>,
297    table: PathBuf,
298}
299
300impl Shell {
301    /// Set `TOP`, and `version` from its `configure/VERSION`.
302    fn set_top(&mut self, top: &Path) {
303        let version = std::fs::read_to_string(top.join("configure/VERSION"))
304            .map(|v| v.trim().to_string())
305            .unwrap_or_else(|_| "dev".into());
306        self.vars.insert("TOP".into(), top.display().to_string());
307        self.vars.insert("version".into(), version);
308    }
309
310    fn run(&mut self, text: &str, depth: usize) {
311        let text = text.replace("\\\n", "");
312        let mut lines = text.lines().map(str::trim);
313        while let Some(line) = lines.next() {
314            if let Some(name) = function_name(line) {
315                let mut body = Vec::new();
316                if !line.ends_with('{') && lines.next() != Some("{") {
317                    continue;
318                }
319                for l in lines.by_ref() {
320                    if l == "}" {
321                        break;
322                    }
323                    if !l.is_empty() && !l.starts_with('#') {
324                        body.push(l);
325                    }
326                }
327                if let [echo] = body.as_slice()
328                    && let Some(out) = echo.strip_prefix("echo ")
329                {
330                    self.functions
331                        .insert(name.to_string(), out.trim().to_string());
332                }
333                continue;
334            }
335            let source = line
336                .strip_prefix("source ")
337                .or_else(|| line.strip_prefix(". "));
338            if let Some(file) = source {
339                if depth > 0
340                    && let Some(file) = self.expand(unquote(file.trim()))
341                    && let Ok(text) = std::fs::read_to_string(&file)
342                {
343                    self.run(&text, depth - 1);
344                }
345                continue;
346            }
347            let Some((name, value)) = line.split_once('=') else {
348                continue;
349            };
350            let plain = !name.is_empty()
351                && !name.starts_with(|c: char| c.is_ascii_digit())
352                && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
353            // `SCRIPT_DIR` and `version` keep the values set above, and
354            // backquoted commands are not run.
355            let fixed = ["SCRIPT_DIR", "version"].contains(&name);
356            if !plain || fixed || value.contains('`') {
357                continue;
358            }
359            let value = match value.strip_prefix('\'') {
360                Some(v) => v.strip_suffix('\'').unwrap_or(v).to_string(),
361                None => match self.expand(unquote(value)) {
362                    Some(v) => v,
363                    None => continue,
364                },
365            };
366            if name == "TOP" {
367                self.set_top(&normalize(Path::new(&value)));
368            } else {
369                self.vars.insert(name.to_string(), value);
370            }
371        }
372    }
373
374    /// `text` with `$name`, `${name}` (empty when unset) and `$(command)`
375    /// replaced, as the shell would in double quotes; `None` when a command
376    /// is not one [`Shell::command`] knows.
377    fn expand(&self, text: &str) -> Option<String> {
378        self.expand_with(text, &HashMap::new(), CALL_DEPTH)
379    }
380
381    /// [`Shell::expand`] with the positional `args` (`$1`…) too.
382    fn expand_with(
383        &self,
384        text: &str,
385        args: &HashMap<String, String>,
386        depth: usize,
387    ) -> Option<String> {
388        let word = |c: char| c.is_ascii_alphanumeric() || c == '_';
389        let mut out = String::with_capacity(text.len());
390        let mut rest = text;
391        while let Some(i) = rest.find('$') {
392            out.push_str(&rest[..i]);
393            let after = &rest[i + 1..];
394            if let Some(inner) = after.strip_prefix('(') {
395                let end = closing_paren(inner)?;
396                out.push_str(self.command(&inner[..end], depth)?.trim_end_matches('\n'));
397                rest = &inner[end + 1..];
398                continue;
399            }
400            let (name, next) = match after.strip_prefix('{') {
401                Some(braced) => match braced.split_once('}') {
402                    Some((name, next)) => (name, next),
403                    None => ("", after),
404                },
405                None => {
406                    let end = after.find(|c| !word(c)).unwrap_or(after.len());
407                    (&after[..end], &after[end..])
408                }
409            };
410            if name.is_empty() {
411                out.push('$');
412            } else {
413                let value = args.get(name).or_else(|| self.vars.get(name));
414                out.push_str(value.map_or("", String::as_str));
415            }
416            rest = next;
417        }
418        out.push_str(rest);
419        Some(out)
420    }
421
422    /// The output of the simple command `text`, if it is one we know.
423    fn command(&self, text: &str, depth: usize) -> Option<String> {
424        // A function calling itself must end.
425        let depth = depth.checked_sub(1)?;
426        let words = split_words(text)
427            .into_iter()
428            .map(|w| self.expand_with(unquote(w), &HashMap::new(), depth))
429            .collect::<Option<Vec<_>>>()?;
430        let (name, args) = words.split_first()?;
431        match (name.as_str(), args) {
432            ("join", [sep, rest @ ..]) => Some(rest.join(sep)),
433            ("configure-ioc", [mode, flag, key])
434                if ["s", "show"].contains(&mode.as_str()) && flag == "-p" =>
435            {
436                let table = std::fs::read_to_string(&self.table).ok()?;
437                table.lines().find_map(|l| {
438                    let (k, v) = l.split_once(' ')?;
439                    (k == key).then(|| v.trim().to_string())
440                })
441            }
442            _ => {
443                let echo = self.functions.get(name)?;
444                let args = args
445                    .iter()
446                    .enumerate()
447                    .map(|(i, a)| ((i + 1).to_string(), a.clone()))
448                    .collect();
449                self.expand_with(unquote(echo), &args, depth)
450            }
451        }
452    }
453}
454
455/// The function a line such as `name()`, `name() {` or `function name()`
456/// starts.
457fn function_name(line: &str) -> Option<&str> {
458    let line = line.strip_prefix("function ").unwrap_or(line);
459    let (name, rest) = line.split_once("()")?;
460    let name = name.trim();
461    let ok = !name.is_empty()
462        && name
463            .chars()
464            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
465        && matches!(rest.trim(), "" | "{");
466    ok.then_some(name)
467}
468
469/// `text` without one pair of surrounding double quotes.
470fn unquote(text: &str) -> &str {
471    text.strip_prefix('"')
472        .and_then(|t| t.strip_suffix('"'))
473        .unwrap_or(text)
474}
475
476/// Where the `)` closing a `$(` is in `text`, the text after the `(`.
477fn closing_paren(text: &str) -> Option<usize> {
478    let mut depth = 0;
479    for (i, c) in text.char_indices() {
480        match c {
481            '(' => depth += 1,
482            ')' if depth == 0 => return Some(i),
483            ')' => depth -= 1,
484            _ => {}
485        }
486    }
487    None
488}
489
490/// The words of a command: split at blanks outside `$(…)` and double quotes.
491fn split_words(text: &str) -> Vec<&str> {
492    let mut words = Vec::new();
493    let (mut start, mut depth, mut quoted) = (None, 0, false);
494    for (i, c) in text.char_indices() {
495        match c {
496            '"' => quoted = !quoted,
497            '(' => depth += 1,
498            ')' => depth -= 1,
499            c if c.is_whitespace() && depth == 0 && !quoted => {
500                if let Some(s) = start.take() {
501                    words.push(&text[s..i]);
502                }
503                continue;
504            }
505            _ => {}
506        }
507        start.get_or_insert(i);
508    }
509    if let Some(s) = start {
510        words.push(&text[s..]);
511    }
512    words
513}
514
515/// `<link><name>…</name><location>…</location></link>` entries of the
516/// `.project` in `dir` (or `<locationURI>`), resolved by [`location`].
517fn parse(text: &str, dir: &Path) -> Links {
518    if crate::element::depth(text) > crate::element::MAX_DEPTH {
519        return Vec::new();
520    }
521    let Ok(doc) = roxmltree::Document::parse_with_options(
522        text,
523        roxmltree::ParsingOptions {
524            nodes_limit: 1 << 16,
525            ..Default::default()
526        },
527    ) else {
528        return Vec::new();
529    };
530    doc.descendants()
531        .filter(|n| n.has_tag_name("link"))
532        .filter_map(|link| {
533            let child = |name: &str| {
534                link.children()
535                    .find(|c| c.has_tag_name(name))
536                    .and_then(|c| c.text())
537                    .map(str::trim)
538            };
539            let name = child("name")?;
540            let location = child("location").or_else(|| child("locationURI"))?;
541            Some((name.to_string(), location_path(location, dir)?))
542        })
543        .collect()
544}
545
546/// The directory a link's location (or location URI) names, for the project
547/// in `dir`: an absolute path, a `file:` URI, or one relative to the project
548/// as `PROJECT_LOC/x` or `PARENT-2-PROJECT_LOC/x`. `None` for anything else
549/// (`virtual:`, other variables or a bare relative path), which would
550/// otherwise be taken relative to the working directory.
551fn location_path(location: &str, dir: &Path) -> Option<PathBuf> {
552    let project_relative = |rest: &str| {
553        let (n, rest) = match rest.strip_prefix("PARENT-") {
554            Some(r) => {
555                let (n, r) = r.split_once("-PROJECT_LOC")?;
556                (n.parse::<usize>().ok()?, r)
557            }
558            None => (0, rest.strip_prefix("PROJECT_LOC")?),
559        };
560        let rest = rest.strip_prefix('/').or(rest.is_empty().then_some(rest))?;
561        // `absolute` keeps `..`, so `../proj` needs normalising before `parent`.
562        let mut base = normalize(&std::path::absolute(dir).ok()?);
563        for _ in 0..n {
564            base = base.parent()?.to_path_buf();
565        }
566        Some(base.join(percent_decode(rest)))
567    };
568    let path = if let Some(uri) = location.strip_prefix("file:") {
569        // file:/x, file:///x, or file://localhost/x.
570        let path = match uri.strip_prefix("//") {
571            Some(rest) => &rest[rest.find('/')?..],
572            None => uri,
573        };
574        // Decoded first, as the drive may be escaped (`/C%3A/x`).
575        let path = percent_decode(path);
576        match path.to_str() {
577            Some(p) => PathBuf::from(crate::source::file_url_path(p)),
578            None => PathBuf::from(path),
579        }
580    } else if let Some(path) = project_relative(location) {
581        path
582    } else {
583        PathBuf::from(location)
584    };
585    path.is_absolute().then_some(path)
586}
587
588/// `text` with `%xx` escapes decoded, as in a URI.
589fn percent_decode(text: &str) -> std::ffi::OsString {
590    let bytes = text.as_bytes();
591    let mut out = Vec::with_capacity(bytes.len());
592    let mut i = 0;
593    while i < bytes.len() {
594        let hex = bytes
595            .get(i + 1..i + 3)
596            .filter(|h| bytes[i] == b'%' && h.iter().all(u8::is_ascii_hexdigit))
597            .and_then(|h| u8::from_str_radix(std::str::from_utf8(h).ok()?, 16).ok());
598        match hex {
599            Some(b) => {
600                out.push(b);
601                i += 3;
602            }
603            None => {
604                out.push(bytes[i]);
605                i += 1;
606            }
607        }
608    }
609    #[cfg(unix)]
610    return std::os::unix::ffi::OsStringExt::from_vec(out);
611    // Elsewhere (Windows, and the browser, which has no files anyway) paths
612    // are text.
613    #[cfg(not(unix))]
614    return String::from_utf8_lossy(&out).into_owned().into();
615}
616
617/// `path` with `.` and `..` removed, without looking at the file system.
618pub fn normalize(path: &Path) -> PathBuf {
619    let mut out = PathBuf::new();
620    for c in path.components() {
621        match c {
622            Component::CurDir => {}
623            // `..` above the root is the root; above a relative start it
624            // stays.
625            Component::ParentDir => match out.components().next_back() {
626                Some(Component::Normal(_)) => {
627                    out.pop();
628                }
629                Some(Component::RootDir | Component::Prefix(_)) => {}
630                _ => out.push(".."),
631            },
632            c => out.push(c),
633        }
634    }
635    out
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn tmp(name: &str) -> PathBuf {
643        let dir = std::env::temp_dir().join(format!("bob-project-{name}-{}", std::process::id()));
644        let _ = std::fs::remove_dir_all(&dir);
645        std::fs::create_dir_all(&dir).unwrap();
646        dir
647    }
648
649    #[test]
650    fn finds_files_through_linked_folders() {
651        let real = tmp("real");
652        std::fs::write(real.join("x.opi"), "<display/>").unwrap();
653        let proj = tmp("proj");
654        std::fs::write(
655            proj.join(".project"),
656            format!(
657                "<projectDescription><linkedResources>
658                <link><name>a</name><type>2</type><location>{}</location></link>
659                <link><name>u</name><type>2</type><locationURI>file:{}</locationURI></link>
660                </linkedResources></projectDescription>",
661                real.display(),
662                real.display()
663            ),
664        )
665        .unwrap();
666        let x = real.join("x.opi");
667        assert_eq!(locate(&proj.join("a/x.opi")), x);
668        assert_eq!(locate(&proj.join("b/../a/x.opi")), x);
669        assert_eq!(locate(&proj.join("u/x.opi")), x);
670        assert_eq!(locate(&proj.join("a/./sub/../x.opi")), x);
671        // Existing files and unknown links are left alone.
672        assert_eq!(locate(&x), x);
673        assert_eq!(locate(&proj.join("c/../q.opi")), proj.join("q.opi"));
674        assert_eq!(virtual_path(&proj, &x), Some(proj.join("a/x.opi")));
675        assert_eq!(virtual_path(&proj, Path::new("/elsewhere/y.opi")), None);
676    }
677
678    #[test]
679    fn link_cycles_end() {
680        let proj = tmp("cycle");
681        std::fs::write(
682            proj.join(".project"),
683            format!(
684                "<projectDescription><linkedResources>
685                <link><name>a</name><location>{}</location></link>
686                </linkedResources></projectDescription>",
687                proj.join("a").display()
688            ),
689        )
690        .unwrap();
691        assert_eq!(locate(&proj.join("a/x.opi")), proj.join("a/x.opi"));
692    }
693
694    #[cfg(windows)]
695    #[test]
696    fn windows_link_locations() {
697        let loc = |l: &str| location_path(l, Path::new(r"C:\w\proj"));
698        assert_eq!(loc(r"D:\abs\opi"), Some(PathBuf::from(r"D:\abs\opi")));
699        assert_eq!(
700            loc("file:/D:/a%20b/opi"),
701            Some(PathBuf::from(r"D:\a b\opi"))
702        );
703        assert_eq!(loc("file:///D%3A/opi"), Some(PathBuf::from(r"D:\opi")));
704        assert_eq!(
705            loc("PROJECT_LOC/sub"),
706            Some(PathBuf::from(r"C:\w\proj\sub"))
707        );
708        assert_eq!(loc("/no/drive"), None);
709    }
710
711    #[cfg(unix)]
712    #[test]
713    fn link_locations_resolve_against_the_project() {
714        let dir = Path::new("/w/proj");
715        let loc = |l: &str| location_path(l, dir);
716        assert_eq!(loc("/abs/opi"), Some(PathBuf::from("/abs/opi")));
717        assert_eq!(loc("file:/a%20b/opi"), Some(PathBuf::from("/a b/opi")));
718        assert_eq!(loc("file:///a/opi"), Some(PathBuf::from("/a/opi")));
719        assert_eq!(loc("file://localhost/a"), Some(PathBuf::from("/a")));
720        assert_eq!(loc("PROJECT_LOC/sub"), Some(PathBuf::from("/w/proj/sub")));
721        assert_eq!(
722            loc("PARENT-1-PROJECT_LOC/other%20x"),
723            Some(PathBuf::from("/w/other x"))
724        );
725        // A relative project path with `..` climbs from its normalised form.
726        let up = normalize(&std::path::absolute("..").unwrap());
727        assert_eq!(
728            location_path("PARENT-2-PROJECT_LOC/x", Path::new("../proj")),
729            Some(up.parent().unwrap().join("x"))
730        );
731        for bad in [
732            "virtual:/virtual",
733            "WORKSPACE_LOC/x",
734            "rel/dir",
735            "PARENT-9-PROJECT_LOC/x",
736        ] {
737            assert_eq!(loc(bad), None, "{bad}");
738        }
739    }
740
741    /// A DLS module's launcher, as generated, with the lines that matter.
742    const RUNCSS: &str = r#"#!/bin/bash
743SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
744TOP=${SCRIPT_DIR}/../../..
745CSS_RUN_SCRIPT=$(configure-ioc s -p CSS-gui)
746opifile="$1"
747if [[ -z $opifile || $opifile == "-h" ]]; then
748    exit 1
749fi
750if [ -e "${TOP}/configure/VERSION" ] ; then
751    version="$(cat "${TOP}"/configure/VERSION)"
752else
753    version="dev"
754fi
755project=vac_${version}
756module=vac
757launch_opi=/${project}/${module}/$opifile
758
759links="${SCRIPT_DIR}=${project}/${module},\
760GAUGE=/${project}/gauge/,\
761/nowhere=/${project}/sub/dir,\
762relative/dir=/${project}/rel,\
763OTHER=/elsewhere/other"
764
765$CSS_RUN_SCRIPT -o "${launch_opi}" -s -l "$links" "$@"
766"#;
767
768    /// A module `top` with its screens (and launcher) in `top/vacApp/opi/opi`,
769    /// and a module `gauge` it links to.
770    fn module(name: &str, version: Option<&str>) -> (PathBuf, PathBuf, PathBuf) {
771        let root = tmp(name);
772        let top = root.join("top");
773        let opi = top.join("vacApp/opi/opi");
774        let gauge = root.join("gauge/opi");
775        std::fs::create_dir_all(opi.join("common")).unwrap();
776        std::fs::create_dir_all(&gauge).unwrap();
777        if let Some(v) = version {
778            std::fs::create_dir_all(top.join("configure")).unwrap();
779            std::fs::write(top.join("configure/VERSION"), format!("{v}\n")).unwrap();
780        }
781        let script = RUNCSS
782            .replace("GAUGE", &gauge.display().to_string())
783            .replace("OTHER", &root.display().to_string());
784        std::fs::write(opi.join(LAUNCHER), script).unwrap();
785        for f in [
786            opi.join("x.opi"),
787            opi.join("common/c.opi"),
788            gauge.join("g.opi"),
789        ] {
790            std::fs::write(f, "<display/>").unwrap();
791        }
792        (root, opi, gauge)
793    }
794
795    #[test]
796    fn a_launcher_is_a_project() {
797        let (root, opi, gauge) = module("runcss", Some("1-2"));
798        let proj = opi.join("runcss.sh/vac_1-2");
799        let x = proj.join("vac/x.opi");
800        assert_eq!(launcher_path(&opi.join("x.opi")), Some(x.clone()));
801        assert_eq!(
802            launcher_path(&opi.join("common/c.opi")),
803            Some(proj.join("vac/common/c.opi"))
804        );
805        assert_eq!(launcher_path(&gauge.join("g.opi")), None);
806        assert_eq!(launcher_path(&x), None);
807        // Links between the modules resolve through the virtual project.
808        assert_eq!(locate(&x), opi.join("x.opi"));
809        assert_eq!(
810            locate(&proj.join("vac/../gauge/g.opi")),
811            gauge.join("g.opi")
812        );
813        assert_eq!(locate(&proj.join("vac/common/../x.opi")), opi.join("x.opi"));
814        // Only links of this project with a usable name and real folder.
815        let names: Vec<String> = links(&proj).unwrap().into_iter().map(|l| l.0).collect();
816        assert_eq!(names, ["vac", "gauge"]);
817        assert_eq!(
818            links(&opi.join("runcss.sh/elsewhere")).unwrap()[0],
819            ("other".to_string(), root.clone())
820        );
821        assert_eq!(links(&opi.join("runcss.sh/vac_dev")), None);
822        // `-p` takes the module's folder or its launcher.
823        assert_eq!(project_dir(&opi), Some(proj.clone()));
824        assert_eq!(project_dir(&opi.join(LAUNCHER)), Some(proj.clone()));
825        assert_eq!(project_dir(&gauge), None);
826        assert_eq!(
827            virtual_path(&proj, &gauge.join("g.opi")),
828            Some(proj.join("gauge/g.opi"))
829        );
830        std::fs::remove_dir_all(&root).unwrap();
831    }
832
833    #[cfg(unix)]
834    #[test]
835    fn reads_a_generated_launcher() {
836        // vacuum 4-100's, verbatim.
837        let text = include_str!("testdata/runcss.sh");
838        let dir = Path::new("/nowhere/vacuum/4-100/vacuumApp/opi/opi");
839        let projects = parse_launcher(text, dir);
840        let [(project, links)] = projects.as_slice() else {
841            panic!("{projects:?}")
842        };
843        assert_eq!(project, "vacuum_dev");
844        assert_eq!(links.len(), 14);
845        assert_eq!(links[0], ("vacuum".to_string(), dir.to_path_buf()));
846        let support = Path::new("/dls_sw/prod/R3.14.12.7/support");
847        assert_eq!(
848            links[2],
849            (
850                "mks937a".to_string(),
851                support.join("mks937a/2-90/mks937aApp/opi/opi")
852            )
853        );
854        assert_eq!(links[13].0, "devIocStats");
855    }
856
857    #[cfg(unix)]
858    #[test]
859    fn reads_a_launcher_that_sources_versions_and_calls_functions() {
860        // diagOpi 2-90's, verbatim: its links come from `opi-versions`,
861        // functions, `join` and `configure-ioc`.
862        let top = tmp("diag-runcss");
863        std::fs::create_dir_all(top.join("configure")).unwrap();
864        std::fs::write(top.join("configure/VERSION"), "2-90\n").unwrap();
865        std::fs::write(
866            top.join("opi-versions"),
867            include_str!("testdata/diag-opi-versions"),
868        )
869        .unwrap();
870        let table = top.join("redirect_table");
871        std::fs::write(
872            &table,
873            "CSS-gui /x/css.sh\nDI-EBPM-gui-dir /ioc/Libera/2-05-17\nDI-MBF-gui-dir /ioc/MBF/1\n",
874        )
875        .unwrap();
876        let text = include_str!("testdata/diag-runcss.sh");
877        let projects = parse_launcher_with(text, &top, &table);
878        let [(project, links)] = projects.as_slice() else {
879            panic!("{projects:?}")
880        };
881        assert_eq!(project, "diagOpi_2-90");
882        let support = Path::new("/dls_sw/prod/R3.14.12.3/support");
883        let names: Vec<&str> = links.iter().map(|l| l.0.as_str()).collect();
884        assert_eq!(
885            names,
886            [
887                "diagOpi",
888                "motor",
889                "devIocStats",
890                "enzLoCuM4",
891                "pmacUtil",
892                "TimingTemplates",
893                "4chTimer",
894                "dlsPLC",
895                "Libera",
896                "IsaPBPM",
897                "MBF",
898            ]
899        );
900        assert_eq!(links[0].1, normalize(&top));
901        assert_eq!(
902            links[2].1,
903            support.join("devIocStats/3-1-14dls2-0/devIocStatsApp/opi/opi")
904        );
905        assert_eq!(links[8].1, Path::new("/ioc/Libera/2-05-17/opi"));
906        // As in the shell, a name not in the table leaves its variable empty.
907        assert_eq!(links[9].1, Path::new("/opi"));
908        assert_eq!(links[10].1, Path::new("/ioc/MBF/1/opi"));
909        std::fs::remove_dir_all(&top).unwrap();
910    }
911
912    #[test]
913    fn only_boy_screens_go_through_a_launcher() {
914        let (root, opi, _) = module("runcss-bob", Some("1-2"));
915        let boy = r#"<display typeId="org.csstudio.opibuilder.Display" version="1.0.0"/>"#;
916        std::fs::write(opi.join("x.opi"), boy).unwrap();
917        std::fs::write(opi.join("y.bob"), r#"<display version="2.0.0"/>"#).unwrap();
918        let source = |p: &Path| crate::Display::load(p).unwrap().source;
919        let x = opi.join("runcss.sh/vac_1-2/vac/x.opi");
920        assert_eq!(source(&opi.join("x.opi")), Some(crate::Source::File(x)));
921        let y = opi.join("y.bob");
922        assert_eq!(source(&y), Some(crate::Source::File(y.clone())));
923        // Nor does a `.bob` reached through the launcher's project: it goes
924        // by its real path, so its `..` names its real neighbours.
925        std::fs::create_dir_all(opi.join("sub")).unwrap();
926        let z = opi.join("sub/z.bob");
927        std::fs::write(&z, r#"<display version="2.0.0"/>"#).unwrap();
928        let d = crate::Display::load(&opi.join("runcss.sh/vac_1-2/vac/sub/z.bob")).unwrap();
929        assert_eq!(d.source, Some(crate::Source::File(z)));
930        let crate::Source::File(up) = d.resolve("../y.bob") else {
931            panic!("not a file")
932        };
933        assert_eq!(normalize(&up), y);
934        assert!(up.exists());
935        std::fs::remove_dir_all(&root).unwrap();
936    }
937
938    #[cfg(unix)]
939    #[test]
940    fn symlinked_screens_map_by_name_or_by_target() {
941        let (root, opi, _) = module("runcss-links", Some("1-2"));
942        let proj = opi.join("runcss.sh/vac_1-2");
943        // In a linked folder, a link keeps its own name.
944        std::os::unix::fs::symlink("x.opi", opi.join("y.opi")).unwrap();
945        assert_eq!(
946            launcher_path(&opi.join("y.opi")),
947            Some(proj.join("vac/y.opi"))
948        );
949        // Elsewhere, it goes by where it points.
950        let home = root.join("home");
951        std::fs::create_dir_all(&home).unwrap();
952        std::os::unix::fs::symlink(opi.join("x.opi"), home.join("z.opi")).unwrap();
953        assert_eq!(
954            virtual_path(&proj, &home.join("z.opi")),
955            Some(proj.join("vac/x.opi"))
956        );
957        std::fs::remove_dir_all(&root).unwrap();
958    }
959
960    #[test]
961    fn a_project_file_beside_a_launcher_wins_only_with_links() {
962        let (root, opi, gauge) = module("runcss-empty", Some("1-2"));
963        let proj = opi.join("runcss.sh/vac_1-2");
964        // An empty one, as DLS modules often have.
965        std::fs::write(opi.join(".project"), "<projectDescription/>").unwrap();
966        assert_eq!(
967            launcher_path(&opi.join("x.opi")),
968            Some(proj.join("vac/x.opi"))
969        );
970        assert_eq!(project_dir(&opi), Some(proj));
971        // One linking the file wins.
972        let linking = format!(
973            "<projectDescription><linkedResources>
974            <link><name>me</name><location>{}</location></link>
975            </linkedResources></projectDescription>",
976            opi.display()
977        );
978        std::fs::write(opi.join(".project"), linking).unwrap();
979        forget();
980        assert_eq!(launcher_path(&opi.join("x.opi")), None);
981        assert_eq!(project_dir(&opi), Some(opi.clone()));
982        // An empty `.project` and no launcher is still a project.
983        std::fs::write(gauge.join(".project"), "<projectDescription/>").unwrap();
984        assert_eq!(project_dir(&gauge), Some(gauge.clone()));
985        std::fs::remove_dir_all(&root).unwrap();
986    }
987
988    #[test]
989    fn the_launcher_is_near_the_screen() {
990        let (root, opi, _) = module("runcss-deep", Some("1-2"));
991        let deep = opi.join("a/b/c");
992        std::fs::create_dir_all(&deep).unwrap();
993        std::fs::write(deep.join("d.opi"), "<display/>").unwrap();
994        std::fs::write(opi.join("a/b/e.opi"), "<display/>").unwrap();
995        assert_eq!(launcher_path(&deep.join("d.opi")), None);
996        assert_eq!(
997            launcher_path(&opi.join("a/b/e.opi")),
998            Some(opi.join("runcss.sh/vac_1-2/vac/a/b/e.opi"))
999        );
1000        std::fs::remove_dir_all(&root).unwrap();
1001    }
1002
1003    #[test]
1004    fn forget_reads_launchers_again() {
1005        let (root, opi, _) = module("runcss-forget", Some("1-2"));
1006        assert!(project_dir(&opi).is_some());
1007        let text = std::fs::read_to_string(opi.join(LAUNCHER)).unwrap();
1008        std::fs::write(
1009            opi.join(LAUNCHER),
1010            text.replace("project=vac_", "project=new_"),
1011        )
1012        .unwrap();
1013        // (Not checked before `forget`: other tests forget too.)
1014        forget();
1015        assert_eq!(project_dir(&opi), Some(opi.join("runcss.sh/new_1-2")));
1016        std::fs::remove_dir_all(&root).unwrap();
1017    }
1018
1019    #[test]
1020    fn a_launcher_without_a_version_is_dev() {
1021        let (root, opi, _) = module("runcss-dev", None);
1022        assert_eq!(
1023            launcher_path(&opi.join("x.opi")),
1024            Some(opi.join("runcss.sh/vac_dev/vac/x.opi"))
1025        );
1026        std::fs::remove_dir_all(&root).unwrap();
1027    }
1028
1029    #[test]
1030    fn a_nearer_project_file_wins_over_a_launcher() {
1031        let (root, opi, _) = module("runcss-project", Some("1-2"));
1032        let common = opi.join("common");
1033        // Only if it links the screen.
1034        std::fs::write(common.join(".project"), "<projectDescription/>").unwrap();
1035        assert!(launcher_path(&common.join("c.opi")).is_some());
1036        let linking = format!(
1037            "<projectDescription><linkedResources>
1038            <link><name>me</name><location>{}</location></link>
1039            </linkedResources></projectDescription>",
1040            common.display()
1041        );
1042        std::fs::write(common.join(".project"), linking).unwrap();
1043        forget();
1044        assert_eq!(launcher_path(&common.join("c.opi")), None);
1045        // One further away than the launcher does not.
1046        std::fs::write(root.join(".project"), "<projectDescription/>").unwrap();
1047        assert!(launcher_path(&opi.join("x.opi")).is_some());
1048        std::fs::remove_dir_all(&root).unwrap();
1049    }
1050
1051    #[cfg(unix)]
1052    #[test]
1053    fn a_function_calling_itself_ends() {
1054        let text = "f()\n{\n    echo \"$(f)\"\n}\nx=$(f)\nlinks=\"/a=/p/n\"\n";
1055        let projects = parse_launcher_with(text, Path::new("/nowhere"), Path::new("/no/table"));
1056        assert_eq!(projects.len(), 1);
1057    }
1058
1059    #[test]
1060    fn expands_shell_variables() {
1061        let shell = Shell {
1062            vars: HashMap::from([("a".to_string(), "1".to_string())]),
1063            functions: HashMap::new(),
1064            table: PathBuf::new(),
1065        };
1066        let e = |t: &str| shell.expand(t).unwrap();
1067        assert_eq!(e("x${a}y$a/$b.$"), "x1y1/.$");
1068        assert_eq!(e("${a"), "${a");
1069    }
1070
1071    #[test]
1072    fn normalizes_lexically() {
1073        assert_eq!(
1074            normalize(Path::new("/a/b/../c/./d")),
1075            PathBuf::from("/a/c/d")
1076        );
1077        assert_eq!(normalize(Path::new("../x")), PathBuf::from("../x"));
1078        assert_eq!(normalize(Path::new("../../x")), PathBuf::from("../../x"));
1079        assert_eq!(normalize(Path::new("a/../../x")), PathBuf::from("../x"));
1080        assert_eq!(normalize(Path::new("/../x")), PathBuf::from("/x"));
1081    }
1082
1083    #[test]
1084    fn oversized_project_files_are_ignored_not_cached_empty() {
1085        let dir = std::env::temp_dir().join(format!("bobview-bigproj-{}", std::process::id()));
1086        std::fs::create_dir_all(&dir).unwrap();
1087        let link = format!(
1088            "<link><name>a</name><location>{}</location></link>",
1089            std::env::temp_dir().display()
1090        );
1091        let pad = " ".repeat(MAX_PROJECT_SIZE as usize);
1092        std::fs::write(dir.join(".project"), format!("<p>{pad}{link}</p>")).unwrap();
1093        assert!(links(&dir).is_none());
1094        std::fs::write(dir.join(".project"), format!("<p>{link}</p>")).unwrap();
1095        assert_eq!(links(&dir).map(|l| l.len()), Some(1));
1096        std::fs::remove_dir_all(&dir).ok();
1097    }
1098}