Skip to main content

bob/
lib.rs

1//! Parser for Phoebus Display Builder `.bob` files, and for CS-Studio BOY
2//! `.opi` files, which are translated into the same form (see [`opi`]).
3//!
4//! A display is kept close to the XML: each [`Widget`] holds its property
5//! elements as they were written, and typed accessors read them with the
6//! Phoebus defaults. Macros are expanded later, by whoever renders the
7//! display, because their values depend on where a widget sits.
8
9mod element;
10mod group_link;
11mod macros;
12mod menu_mux;
13mod model;
14mod opi;
15mod opi_file_script;
16pub mod project;
17mod source;
18mod trend;
19
20use std::path::{Path, PathBuf};
21
22pub use element::{Element, MAX_DEPTH as MAX_XML_DEPTH, depth as xml_depth};
23pub use group_link::{GroupLink, LinkSets, group_device, group_frame};
24pub use macros::{MAX_LOAD_BYTES, Macros, expand, rearm_warning, spend, warn_spent};
25pub use menu_mux::{MAX_SETS as MAX_MUX_SETS, MenuMux, MuxSet};
26pub use model::{Action, Color, Font, FontStyle, Rule, RuleExpr, named_color};
27pub use source::Source;
28pub use trend::{databrowser_pvs, strip_tool_pvs};
29
30/// The largest display file read from disk, as for those fetched by URL.
31const MAX_FILE_SIZE: u64 = 16 << 20;
32
33/// The most XML nodes in a display, so a small file can't expand to a huge tree.
34const MAX_NODES: u32 = 1 << 20;
35
36/// A parsed `.bob` (or translated `.opi`) display.
37#[derive(Clone, Debug)]
38pub struct Display {
39    pub name: String,
40    pub width: f64,
41    pub height: f64,
42    pub background: Color,
43    pub macros: Macros,
44    pub widgets: Vec<Widget>,
45    /// The file or URL it was read from, used to resolve relative paths.
46    pub source: Option<Source>,
47    /// Translated from a BOY `.opi` file. BOY clipped widgets to the
48    /// declared size, where Phoebus grows the display to show them.
49    pub legacy: bool,
50    /// The size of the file's XML, in bytes.
51    pub size: usize,
52}
53
54/// One widget: its type, property elements and child widgets.
55#[derive(Clone, Debug)]
56pub struct Widget {
57    pub kind: String,
58    pub props: Element,
59    pub children: Vec<Widget>,
60}
61
62#[derive(Debug)]
63pub enum Error {
64    Io(PathBuf, std::io::Error),
65    Xml(roxmltree::Error),
66    NotADisplay(String),
67    /// Elements nested more deeply than [`MAX_XML_DEPTH`].
68    TooDeep,
69    /// A URL that could not be fetched, and why.
70    Fetch(String, String),
71}
72
73impl std::fmt::Display for Error {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        match self {
76            Error::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
77            Error::Xml(e) => write!(f, "invalid XML: {e}"),
78            Error::TooDeep => write!(f, "display nests elements more than {} deep", MAX_XML_DEPTH),
79            Error::NotADisplay(root) => write!(f, "expected a <display>, found <{root}>"),
80            Error::Fetch(url, why) => write!(f, "cannot fetch {url}: {why}"),
81        }
82    }
83}
84
85impl std::error::Error for Error {}
86
87impl Display {
88    /// Read the display file at `path`, which may lie in an Eclipse
89    /// project's linked folder (see [`project`]). A real BOY screen beside a
90    /// DLS launcher is taken through the launcher's project (see
91    /// [`project::launcher_path`]), so the files it names do too; any other
92    /// display reached through a launcher's project goes by its real path.
93    pub fn load(path: &Path) -> Result<Self, Error> {
94        let real = project::locate(path);
95        use std::io::Read;
96        let io = |e| Error::Io(path.into(), e);
97        let file = std::fs::File::open(&real).map_err(io)?;
98        let mut text = String::new();
99        file.take(MAX_FILE_SIZE + 1)
100            .read_to_string(&mut text)
101            .map_err(io)?;
102        if text.len() as u64 > MAX_FILE_SIZE {
103            let why = format!("larger than {} MiB", MAX_FILE_SIZE >> 20);
104            return Err(io(std::io::Error::other(why)));
105        }
106        let mut display = Self::parse(&text)?;
107        // A launcher's project is for BOY screens: a `.bob` reached through
108        // one names its files from where it really is.
109        let path = if display.legacy {
110            project::launcher_path(path).unwrap_or_else(|| path.to_path_buf())
111        } else if project::through_launcher(path) {
112            real
113        } else {
114            path.to_path_buf()
115        };
116        display.source = Some(Source::File(path));
117        Ok(display)
118    }
119
120    pub fn parse(xml: &str) -> Result<Self, Error> {
121        // Before parsing: roxmltree's own parser recurses once per level.
122        if xml_depth(xml) > MAX_XML_DEPTH {
123            return Err(Error::TooDeep);
124        }
125        let options = roxmltree::ParsingOptions {
126            nodes_limit: MAX_NODES,
127            ..Default::default()
128        };
129        let doc = roxmltree::Document::parse_with_options(xml, options).map_err(Error::Xml)?;
130        let mut root = Element::from_node(doc.root_element());
131        if root.name != "display" {
132            return Err(Error::NotADisplay(root.name));
133        }
134        let legacy = opi::is_opi(&root);
135        if legacy {
136            root = opi::translate(root);
137        }
138        let (props, widgets) = split_widgets(root);
139        let as_widget = Widget {
140            kind: "display".into(),
141            props,
142            children: Vec::new(),
143        };
144        Ok(Self {
145            name: as_widget.text("name").unwrap_or("").to_string(),
146            width: as_widget.f64("width").unwrap_or(800.0),
147            height: as_widget.f64("height").unwrap_or(600.0),
148            background: as_widget
149                .color("background_color")
150                .unwrap_or(named_color("Background")),
151            macros: as_widget.macros(),
152            widgets,
153            source: None,
154            legacy,
155            size: xml.len(),
156        })
157    }
158
159    /// Resolve a file named by this display (e.g. in an action) against its location.
160    pub fn resolve(&self, file: &str) -> Source {
161        match &self.source {
162            Some(s) => s.resolve(file),
163            None => Source::parse(file),
164        }
165    }
166
167    /// Every widget, depth first.
168    pub fn all_widgets(&self) -> Vec<&Widget> {
169        fn walk<'a>(ws: &'a [Widget], out: &mut Vec<&'a Widget>) {
170            for w in ws {
171                out.push(w);
172                walk(&w.children, out);
173            }
174        }
175        let mut out = Vec::new();
176        walk(&self.widgets, &mut out);
177        out
178    }
179}
180
181/// Separate `<widget>` children from the property elements of `el`.
182///
183/// A property given more than once keeps its last value, as Phoebus reads it
184/// (pvi writes a placeholder `<items>` list followed by the real one).
185fn split_widgets(mut el: Element) -> (Element, Vec<Widget>) {
186    let mut widgets = Vec::new();
187    let mut props: Vec<Element> = Vec::new();
188    // Index by name: a linear search per child is quadratic in the property count.
189    let mut index: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
190    for child in el.children.drain(..) {
191        if child.name == "widget" {
192            widgets.push(Widget::from_element(child));
193        } else if let Some(&i) = index.get(&child.name) {
194            props[i] = child;
195        } else {
196            index.insert(child.name.clone(), props.len());
197            props.push(child);
198        }
199    }
200    el.children = props;
201    (el, widgets)
202}
203
204impl Widget {
205    pub fn from_element(el: Element) -> Self {
206        let kind = el.attr("type").unwrap_or("unknown").to_string();
207        let (props, children) = split_widgets(el);
208        Self {
209            kind,
210            props,
211            children,
212        }
213    }
214
215    /// The property element called `name`.
216    pub fn get(&self, name: &str) -> Option<&Element> {
217        self.props.child(name)
218    }
219
220    /// The text of a simple property.
221    pub fn text(&self, name: &str) -> Option<&str> {
222        self.get(name).map(|e| e.text.as_str())
223    }
224
225    pub fn f64(&self, name: &str) -> Option<f64> {
226        self.text(name).and_then(|t| t.trim().parse().ok())
227    }
228
229    pub fn i64(&self, name: &str) -> Option<i64> {
230        self.f64(name).map(|v| v as i64)
231    }
232
233    /// A boolean property, parsed as Phoebus's `BooleanWidgetProperty`
234    /// does; anything else, such as a rule's output expression giving `5`,
235    /// is invalid and the widget uses its default.
236    pub fn bool(&self, name: &str) -> Option<bool> {
237        self.text(name).and_then(parse_bool)
238    }
239
240    pub fn color(&self, name: &str) -> Option<Color> {
241        self.get(name)?.child("color").map(Color::from_element)
242    }
243
244    pub fn font(&self, name: &str) -> Option<Font> {
245        self.get(name)?.child("font").map(Font::from_element)
246    }
247
248    pub fn name(&self) -> &str {
249        self.text("name").unwrap_or("")
250    }
251
252    /// The widget's own `<macros>`.
253    pub fn macros(&self) -> Macros {
254        self.get("macros")
255            .map(Macros::from_element)
256            .unwrap_or_default()
257    }
258
259    /// `<items><item>…</item></items>`, as used by combo and choice widgets.
260    pub fn items(&self) -> Vec<String> {
261        self.list("items", "item")
262    }
263
264    /// Texts of `<outer><inner>…</inner>…</outer>`.
265    pub fn list(&self, outer: &str, inner: &str) -> Vec<String> {
266        self.get(outer)
267            .map(|e| {
268                e.children
269                    .iter()
270                    .filter(|c| c.name == inner)
271                    .map(|c| c.text.clone())
272                    .collect()
273            })
274            .unwrap_or_default()
275    }
276
277    /// Points of a polyline or polygon, relative to the widget.
278    pub fn points(&self) -> Vec<(f64, f64)> {
279        let Some(points) = self.get("points") else {
280            return Vec::new();
281        };
282        points
283            .children
284            .iter()
285            .filter(|p| p.name == "point")
286            .filter_map(|p| Some((p.attr("x")?.parse().ok()?, p.attr("y")?.parse().ok()?)))
287            .collect()
288    }
289
290    pub fn actions(&self) -> Vec<Action> {
291        self.get("actions")
292            .map(|a| {
293                a.children
294                    .iter()
295                    .filter(|c| c.name == "action")
296                    .map(Action::from_element)
297                    .collect()
298            })
299            .unwrap_or_default()
300    }
301
302    pub fn rules(&self) -> Vec<Rule> {
303        self.get("rules")
304            .map(|r| {
305                r.children
306                    .iter()
307                    .filter(|c| c.name == "rule")
308                    .map(Rule::from_element)
309                    .collect()
310            })
311            .unwrap_or_default()
312    }
313
314    pub fn has_scripts(&self) -> bool {
315        self.get("scripts").is_some_and(|s| !s.children.is_empty())
316    }
317}
318
319/// `true`, `yes` or `1`, and `false`, `no` or `0`, as Phoebus's
320/// `BooleanWidgetProperty.parseExpandedSpecification`.
321pub fn parse_bool(text: &str) -> Option<bool> {
322    match text.trim() {
323        t if t.eq_ignore_ascii_case("true") || t.eq_ignore_ascii_case("yes") || t == "1" => {
324            Some(true)
325        }
326        t if t.eq_ignore_ascii_case("false") || t.eq_ignore_ascii_case("no") || t == "0" => {
327            Some(false)
328        }
329        _ => None,
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    #[test]
338    fn booleans_parse_as_phoebus_does() {
339        for (text, want) in [
340            ("true", Some(true)),
341            (" TRUE ", Some(true)),
342            ("Yes", Some(true)),
343            ("1", Some(true)),
344            ("false", Some(false)),
345            ("No", Some(false)),
346            ("0", Some(false)),
347            ("5", None),
348            ("1.0", None),
349            ("", None),
350        ] {
351            assert_eq!(parse_bool(text), want, "{text:?}");
352        }
353    }
354
355    #[test]
356    fn deeply_nested_xml_is_an_error_not_a_stack_overflow() {
357        // A small stack makes unbounded recursion fail this test.
358        std::thread::Builder::new()
359            .stack_size(2 * 1024 * 1024)
360            .spawn(|| {
361                let n = 50_000;
362                let xml = format!(
363                    "<display>{}{}</display>",
364                    "<widget type=\"group\">".repeat(n),
365                    "</widget>".repeat(n)
366                );
367                assert!(matches!(Display::parse(&xml), Err(Error::TooDeep)));
368                // Nesting well inside the limit still parses.
369                let n = 40; // ok on a 2 MiB stack
370                let xml = format!(
371                    "<display>{}{}</display>",
372                    "<widget type=\"group\">".repeat(n),
373                    "</widget>".repeat(n)
374                );
375                assert_eq!(Display::parse(&xml).unwrap().all_widgets().len(), n);
376            })
377            .unwrap()
378            .join()
379            .unwrap();
380    }
381
382    #[test]
383    fn many_properties_keep_the_last_value_quickly() {
384        let n = 100_000;
385        let mut xml = String::from("<display>");
386        for i in 0..n {
387            xml.push_str(&format!("<p{}>a</p{}>", i % 1000, i % 1000));
388        }
389        xml.push_str("<p0>last</p0></display>");
390        let d = Display::parse(&xml).unwrap();
391        assert!(d.widgets.is_empty());
392    }
393
394    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
395<display version="2.0.0">
396  <name>Test</name>
397  <width>400</width>
398  <macros><P>BL01T</P></macros>
399  <widget type="group" version="3.0.0">
400    <name>G</name>
401    <macros><R>:DET:</R></macros>
402    <x>10</x>
403    <widget type="textupdate" version="2.0.0">
404      <name>TU</name>
405      <pv_name>$(P)$(R)Acquire_RBV</pv_name>
406      <foreground_color><color name="Text" red="1" green="2" blue="3"></color></foreground_color>
407      <font><font name="Default Bold" family="Liberation Sans" style="BOLD" size="14.0"></font></font>
408    </widget>
409  </widget>
410  <widget type="polyline" version="2.0.0">
411    <points><point x="0.0" y="1.0"></point><point x="5.0" y="6.0"></point></points>
412  </widget>
413</display>"#;
414
415    #[test]
416    fn parses_tree_and_properties() {
417        let d = Display::parse(SAMPLE).unwrap();
418        assert_eq!(d.name, "Test");
419        assert_eq!(d.width, 400.0);
420        assert_eq!(d.height, 600.0);
421        assert_eq!(d.macros.get("P"), Some("BL01T"));
422        let g = &d.widgets[0];
423        assert_eq!(g.kind, "group");
424        assert_eq!(g.f64("x"), Some(10.0));
425        assert_eq!(g.macros().get("R"), Some(":DET:"));
426        let tu = &g.children[0];
427        assert_eq!(tu.text("pv_name"), Some("$(P)$(R)Acquire_RBV"));
428        assert_eq!(tu.color("foreground_color").unwrap().rgba(), [1, 2, 3, 255]);
429        let f = tu.font("font").unwrap();
430        assert_eq!((f.size, f.style), (14.0, FontStyle::Bold));
431        assert_eq!(d.widgets[1].points(), vec![(0.0, 1.0), (5.0, 6.0)]);
432        assert_eq!(d.all_widgets().len(), 3);
433    }
434
435    #[test]
436    fn rejects_other_roots() {
437        assert!(matches!(Display::parse("<x/>"), Err(Error::NotADisplay(_))));
438    }
439
440    #[test]
441    fn repeated_property_keeps_the_last() {
442        let d = Display::parse(
443            r#"<display version="2.0.0"><widget type="combo">
444              <items><item>item 0</item><item>Item 1</item><item>Item 2</item></items>
445              <x>1</x>
446              <items><item>Disable</item><item>Enable</item></items>
447            </widget></display>"#,
448        )
449        .unwrap();
450        let w = &d.widgets[0];
451        assert_eq!(w.items(), vec!["Disable", "Enable"]);
452        assert_eq!(w.f64("x"), Some(1.0));
453    }
454}