Skip to main content

hpr_io/ork/
document.rs

1//! The design document: the XML tree inside a `.ork`, kept whole.
2//!
3//! OpenRocket publishes no schema for `.ork` (there is no XSD), and every version has added tags.
4//! So the document is read into a plain tree of elements and text, with nothing thrown away and
5//! nothing interpreted: later milestones walk it into [`hpr_design`] types, and whatever they do
6//! not understand is still here to be written back out. Reading and writing are exact inverses:
7//! [`Document::to_xml`] followed by [`Document::parse`] returns the same document.
8
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13use super::error::OrkError;
14use super::warning::{Imported, Warning, WarningKind};
15
16/// The `.ork` schema versions this reader knows, as `1.0` up to and including `1.MAX_KNOWN_MINOR`.
17///
18/// 1.9 is OpenRocket 23.09 and 1.10 is 24.12; 1.11 is documented for 26.xx and adds embedded
19/// `.rse` thrust curves, CSV lookup tables, a gravity model and `preview.png`
20/// (`docs/VALIDATION.md`). A newer file is read anyway, with a warning.
21pub const MAX_KNOWN_MINOR: u32 = 11;
22
23/// How deeply elements may nest before the document is refused.
24///
25/// An ordinary `.ork` design nests 11 deep, and the deepest of the 76 in the reference corpus
26/// (OpenRocket's own parallel-booster example) reaches 17, so 64 leaves room to spare. The limit
27/// is here because both the XML parser underneath and this module's own reader descend the tree:
28/// a file written to nest far enough exhausts the stack, which is a crash where [Loft lesson L56:
29/// malformed input must give an error rather than crash][lessons] asks for an error. On a debug
30/// test build with a 2 MiB stack, `roxmltree` read 120 levels and died on 130, so the depth is
31/// counted before the text is handed to it.
32///
33/// [lessons]: https://github.com/nrdptel/hpr-sim/blob/main/docs/research/loft-lessons.md
34pub const MAX_DEPTH: usize = 64;
35
36/// A `.ork` schema version, as written in the root element's `version` attribute.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
38pub struct SchemaVersion {
39    /// The major version. Every `.ork` ever written has 1 here.
40    pub major: u32,
41    /// The minor version: 0 to 11 so far.
42    pub minor: u32,
43}
44
45impl SchemaVersion {
46    /// Reads `major.minor`, the only form the attribute takes.
47    pub fn parse(text: &str) -> Result<Self, OrkError> {
48        let version = || OrkError::Version {
49            text: text.to_owned(),
50        };
51        let (major, minor) = text.split_once('.').ok_or_else(version)?;
52        let number = |field: &str| {
53            if field.is_empty() || !field.bytes().all(|byte| byte.is_ascii_digit()) {
54                return Err(version());
55            }
56            field.parse::<u32>().map_err(|_| version())
57        };
58        Ok(Self {
59            major: number(major)?,
60            minor: number(minor)?,
61        })
62    }
63
64    /// Whether this version is one the format documentation describes.
65    pub fn is_known(self) -> bool {
66        self.major == 1 && self.minor <= MAX_KNOWN_MINOR
67    }
68}
69
70impl fmt::Display for SchemaVersion {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        write!(f, "{}.{}", self.major, self.minor)
73    }
74}
75
76/// A design document: its schema version, the program that wrote it, and the whole XML tree.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Document {
79    /// The root element's `version` attribute.
80    pub version: SchemaVersion,
81    /// The root element's `creator` attribute, for example `OpenRocket 24.12`. Absent in files
82    /// some other program wrote.
83    pub creator: Option<String>,
84    /// The `<openrocket>` element, with every attribute and child it was written with.
85    pub root: Element,
86}
87
88/// An XML element: its name, its attributes in the order they were written, and its children.
89#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
90#[serde(deny_unknown_fields)]
91pub struct Element {
92    /// The element's name, such as `nosecone`.
93    pub name: String,
94    /// The attributes, in document order, as name and value.
95    pub attributes: Vec<(String, String)>,
96    /// The children, in document order.
97    pub children: Vec<Node>,
98}
99
100/// A child of an [`Element`]: another element, or text.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
102#[serde(rename_all = "snake_case", tag = "kind")]
103#[serde(deny_unknown_fields)]
104pub enum Node {
105    /// A child element.
106    Element(Element),
107    /// Text. Character and entity references are already resolved.
108    Text {
109        /// The text as it stands.
110        text: String,
111    },
112}
113
114impl Element {
115    /// The value of the attribute called `name`, if it has one.
116    pub fn attribute(&self, name: &str) -> Option<&str> {
117        super::reads::note_attribute(self, name);
118        self.attributes
119            .iter()
120            .find(|(key, _)| key == name)
121            .map(|(_, value)| value.as_str())
122    }
123
124    /// The child elements called `name`, in document order.
125    pub fn children_named<'a>(&'a self, name: &str) -> impl Iterator<Item = &'a Element> {
126        let name = name.to_owned();
127        self.elements().filter(move |child| child.name == name)
128    }
129
130    /// The first child element called `name`.
131    pub fn child<'a>(&'a self, name: &str) -> Option<&'a Element> {
132        self.elements().find(|child| child.name == name)
133    }
134
135    /// Every child element, in document order.
136    pub fn elements(&self) -> impl Iterator<Item = &Element> {
137        self.children.iter().filter_map(|child| match child {
138            Node::Element(element) => Some(element),
139            Node::Text { .. } => None,
140        })
141    }
142
143    /// The element's text: its text children joined, or the empty string when it has none.
144    ///
145    /// `.ork` values sit in leaf elements (`<length>0.61</length>`), so this is how a value is
146    /// read. It is returned as written; the caller trims and parses it.
147    pub fn text(&self) -> String {
148        let mut text = String::new();
149        for child in &self.children {
150            if let Node::Text { text: part } = child {
151                text.push_str(part);
152            }
153        }
154        text
155    }
156
157    fn has_element_child(&self) -> bool {
158        self.elements().next().is_some()
159    }
160
161    fn has_text_child(&self) -> bool {
162        self.children
163            .iter()
164            .any(|child| matches!(child, Node::Text { .. }))
165    }
166}
167
168impl Document {
169    /// Reads a design document from XML text.
170    ///
171    /// Fails when the text is not well-formed XML, when its root is not `<openrocket>`, or when
172    /// the root carries no readable `version`. Everything else is a warning: a schema version
173    /// newer than [`MAX_KNOWN_MINOR`], a missing `creator`, or a comment (which is dropped).
174    ///
175    /// Blank text between child elements (the indentation OpenRocket writes) is not kept, so
176    /// that writing the document back out is free to lay it out again. Text an element holds
177    /// beside child elements *is* kept: OpenRocket writes a simulation `<warning>` that way.
178    pub fn parse(text: &str) -> Result<Imported<Self>, OrkError> {
179        let deepest = deepest_nesting(text);
180        if deepest > MAX_DEPTH {
181            return Err(OrkError::TooDeep {
182                limit: MAX_DEPTH,
183                depth: deepest,
184            });
185        }
186        let parsed = roxmltree::Document::parse(text).map_err(|error| OrkError::Xml {
187            reason: error.to_string(),
188        })?;
189        let root = parsed.root_element();
190        if root.tag_name().name() != "openrocket" {
191            return Err(OrkError::NotOpenRocket {
192                root: root.tag_name().name().to_owned(),
193            });
194        }
195        let version = SchemaVersion::parse(root.attribute("version").unwrap_or_default())?;
196        let creator = root.attribute("creator").map(str::to_owned);
197
198        let mut warnings = Vec::new();
199        if !version.is_known() {
200            warnings.push(Warning::new(
201                "openrocket",
202                WarningKind::Unusual,
203                if version.major == 1 {
204                    format!(
205                        "schema version {version} is newer than 1.{MAX_KNOWN_MINOR}, the newest \
206                         this reader knows; it was read as far as it is understood"
207                    )
208                } else {
209                    format!(
210                        "schema version {version} is not one this reader knows (1.0 to \
211                         1.{MAX_KNOWN_MINOR}); it was read as far as it is understood"
212                    )
213                },
214            ));
215        }
216        if creator.is_none() {
217            warnings.push(Warning::new(
218                "openrocket",
219                WarningKind::Unusual,
220                "the root element has no `creator` attribute, so the program that wrote this \
221                 design is unknown",
222            ));
223        }
224        if root.tag_name().namespace().is_some()
225            || parsed
226                .descendants()
227                .any(|node| node.namespaces().next().is_some())
228        {
229            warnings.push(Warning::new(
230                "openrocket",
231                WarningKind::Dropped,
232                "this document declares XML namespaces; prefixes and declarations are dropped, \
233                 and two attributes that differ only by prefix become one. No `.ork` OpenRocket \
234                 writes uses them",
235            ));
236        }
237        let root = read_element(root, "", &mut warnings);
238        Ok(Imported {
239            value: Self {
240                version,
241                creator,
242                root,
243            },
244            warnings,
245        })
246    }
247
248    /// Writes the document back out as XML.
249    ///
250    /// The result is written in one fixed style rather than byte-identical to the file it was
251    /// read from: two-space indentation, attributes in the order they were read, and `<tag/>` for
252    /// an empty element. Reading it again gives an equal [`Document`], which is what makes the
253    /// tree lossless.
254    ///
255    /// [`version`](Self::version) and [`creator`](Self::creator) are written onto the root, so
256    /// changing either changes the file. Everything else comes from [`root`](Self::root).
257    ///
258    /// This is defined for a document that came from [`parse`](Self::parse). `Element`'s fields
259    /// are public, and a tree built by hand can hold a name that is not an XML name, or nest
260    /// deeper than [`MAX_DEPTH`]. Writing either gives XML that will not read back.
261    pub fn to_xml(&self) -> String {
262        let mut root = self.root.clone();
263        set_attribute(&mut root, "version", Some(self.version.to_string()));
264        set_attribute(&mut root, "creator", self.creator.clone());
265        let mut out = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
266        write_element(&root, 0, &mut out);
267        out.push('\n');
268        out
269    }
270}
271
272/// Reads one element and its children, recording `at` as the path for any warning.
273fn read_element(
274    node: roxmltree::Node<'_, '_>,
275    parent: &str,
276    warnings: &mut Vec<Warning>,
277) -> Element {
278    let name = node.tag_name().name().to_owned();
279    let at = if parent.is_empty() {
280        name.clone()
281    } else {
282        format!("{parent}/{name}")
283    };
284    let attributes = node
285        .attributes()
286        .map(|attribute| (attribute.name().to_owned(), attribute.value().to_owned()))
287        .collect();
288
289    let keeps_blank_text = !node.children().any(|child| child.is_element());
290    let mut children = Vec::new();
291    for child in node.children() {
292        if child.is_element() {
293            children.push(Node::Element(read_element(child, &at, warnings)));
294        } else if child.is_text() {
295            let text = child.text().unwrap_or_default();
296            // A comment, a processing instruction or a CDATA section splits a run of text in two.
297            // Joining them here is what keeps writing and reading again exact: the writer has
298            // nowhere to put the split, since it drops the comment that made it.
299            if let Some(Node::Text { text: last }) = children.last_mut()
300                && (keeps_blank_text || !text.trim().is_empty())
301            {
302                last.push_str(text);
303                continue;
304            }
305            if keeps_blank_text {
306                children.push(Node::Text {
307                    text: text.to_owned(),
308                });
309            } else if !text.trim().is_empty() {
310                // An element with both child elements and text of its own. OpenRocket writes one:
311                // a simulation's `<warning>` prints its own message after its fields. Both are
312                // kept, and the blank text that only lays the file out is not.
313                children.push(Node::Text {
314                    text: text.to_owned(),
315                });
316            }
317        } else if child.is_comment() {
318            warnings.push(Warning::new(
319                at.clone(),
320                WarningKind::Dropped,
321                "an XML comment was dropped; `.ork` carries no meaning in comments",
322            ));
323        } else if child.is_pi() {
324            warnings.push(Warning::new(
325                at.clone(),
326                WarningKind::Dropped,
327                "an XML processing instruction was dropped",
328            ));
329        }
330    }
331    Element {
332        name,
333        attributes,
334        children,
335    }
336}
337
338/// Counts how deeply elements nest in `text`, without building a tree.
339///
340/// This is a scan, not a parser: it walks the text, skips comments, CDATA sections and processing
341/// instructions, tracks quotes so that a `>` inside an attribute value does not end a tag, and
342/// counts start tags against end tags. It can only ever return more nesting than a parser will
343/// find (XML forbids a raw `<` inside an attribute value, so every element start it sees is one),
344/// which is what makes it safe to use as a guard.
345pub(crate) fn deepest_nesting(text: &str) -> usize {
346    let bytes = text.as_bytes();
347    let mut index = 0;
348    let mut depth = 0usize;
349    let mut deepest = 0usize;
350    while index < bytes.len() {
351        if bytes[index] != b'<' {
352            index += 1;
353            continue;
354        }
355        let rest = &bytes[index..];
356        if let Some(skip) = skipped(rest, b"<!--", b"-->")
357            .or_else(|| skipped(rest, b"<![CDATA[", b"]]>"))
358            .or_else(|| skipped(rest, b"<?", b"?>"))
359        {
360            index += skip;
361            continue;
362        }
363        let closing = rest.starts_with(b"</");
364        // `<!DOCTYPE …>` and the like: not an element, so it changes no depth.
365        let declaration = rest.starts_with(b"<!");
366
367        let mut cursor = index + 1;
368        let mut quote: Option<u8> = None;
369        let mut last = 0u8;
370        while cursor < bytes.len() {
371            let byte = bytes[cursor];
372            match quote {
373                Some(open) if byte == open => quote = None,
374                Some(_) => {}
375                None if byte == b'"' || byte == b'\'' => quote = Some(byte),
376                None if byte == b'>' => break,
377                None => {}
378            }
379            if !byte.is_ascii_whitespace() {
380                last = byte;
381            }
382            cursor += 1;
383        }
384        if !declaration {
385            if closing {
386                depth = depth.saturating_sub(1);
387            } else {
388                depth += 1;
389                deepest = deepest.max(depth);
390                if last == b'/' {
391                    depth -= 1;
392                }
393            }
394        }
395        index = cursor + 1;
396    }
397    deepest
398}
399
400/// When `rest` starts with `opening`, the length up to and including the next `closing`, or to the
401/// end of the text when there is none.
402fn skipped(rest: &[u8], opening: &[u8], closing: &[u8]) -> Option<usize> {
403    if !rest.starts_with(opening) {
404        return None;
405    }
406    let from = opening.len();
407    let found = rest[from..]
408        .windows(closing.len())
409        .position(|window| window == closing)
410        .map(|at| from + at + closing.len());
411    Some(found.unwrap_or(rest.len()))
412}
413
414/// Sets, replaces or removes one attribute of an element, keeping the order of the rest.
415fn set_attribute(element: &mut Element, name: &str, value: Option<String>) {
416    let at = element.attributes.iter().position(|(key, _)| key == name);
417    match (at, value) {
418        (Some(at), Some(value)) => element.attributes[at].1 = value,
419        (Some(at), None) => {
420            element.attributes.remove(at);
421        }
422        (None, Some(value)) => element.attributes.push((name.to_owned(), value)),
423        (None, None) => {}
424    }
425}
426
427/// Writes an element, indented by `depth`, laying out child elements one per line.
428fn write_element(element: &Element, depth: usize, out: &mut String) {
429    let mixed = element.has_element_child() && element.has_text_child();
430    if mixed {
431        // Adding whitespace around text would change the text when the document is read again,
432        // so an element that mixes the two is written with no layout at all.
433        write_compact(element, out);
434        return;
435    }
436    for _ in 0..depth {
437        out.push_str("  ");
438    }
439    write_open_tag(element, out);
440    if element.children.is_empty() {
441        out.truncate(out.len() - 1);
442        out.push_str("/>");
443        return;
444    }
445    if element.has_element_child() {
446        for child in &element.children {
447            if let Node::Element(child) = child {
448                out.push('\n');
449                write_element(child, depth + 1, out);
450            }
451        }
452        out.push('\n');
453        for _ in 0..depth {
454            out.push_str("  ");
455        }
456    } else {
457        out.push_str(&escape_text(&element.text()));
458    }
459    out.push_str("</");
460    out.push_str(&element.name);
461    out.push('>');
462}
463
464/// Writes an element with no added whitespace, used for mixed content.
465fn write_compact(element: &Element, out: &mut String) {
466    write_open_tag(element, out);
467    if element.children.is_empty() {
468        out.truncate(out.len() - 1);
469        out.push_str("/>");
470        return;
471    }
472    for child in &element.children {
473        match child {
474            Node::Element(child) => write_compact(child, out),
475            Node::Text { text } => out.push_str(&escape_text(text)),
476        }
477    }
478    out.push_str("</");
479    out.push_str(&element.name);
480    out.push('>');
481}
482
483fn write_open_tag(element: &Element, out: &mut String) {
484    out.push('<');
485    out.push_str(&element.name);
486    for (name, value) in &element.attributes {
487        out.push(' ');
488        out.push_str(name);
489        out.push_str("=\"");
490        out.push_str(&escape_attribute(value));
491        out.push('"');
492    }
493    out.push('>');
494}
495
496/// Escapes text. A carriage return becomes a character reference: XML parsers turn a literal one
497/// into a line feed, which would change the text when the document is read again.
498fn escape_text(text: &str) -> String {
499    let mut out = String::with_capacity(text.len());
500    for character in text.chars() {
501        match character {
502            '&' => out.push_str("&amp;"),
503            '<' => out.push_str("&lt;"),
504            '>' => out.push_str("&gt;"),
505            '\r' => out.push_str("&#13;"),
506            other => out.push(other),
507        }
508    }
509    out
510}
511
512/// Escapes an attribute value. Every whitespace character but the space becomes a character
513/// reference, because a parser turns literal ones into spaces.
514fn escape_attribute(value: &str) -> String {
515    let mut out = String::with_capacity(value.len());
516    for character in value.chars() {
517        match character {
518            '&' => out.push_str("&amp;"),
519            '<' => out.push_str("&lt;"),
520            '>' => out.push_str("&gt;"),
521            '"' => out.push_str("&quot;"),
522            '\t' => out.push_str("&#9;"),
523            '\n' => out.push_str("&#10;"),
524            '\r' => out.push_str("&#13;"),
525            other => out.push(other),
526        }
527    }
528    out
529}