Skip to main content

outram_foam_basic_lib/io/
dict.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 OUTRAM PARK contributors
3// Derived from OpenFOAM (www.openfoam.com)
4// Copyright (C) 2004-2023 OpenFOAM Foundation
5// Copyright (C) 2016-2023 OpenCFD Ltd.
6//
7// This file is part of OUTRAM PARK.
8//
9// OUTRAM PARK is free software: you can redistribute it and/or modify it
10// under the terms of the GNU General Public License as published by the
11// Free Software Foundation, either version 3 of the License, or (at your
12// option) any later version.
13//
14// OUTRAM PARK is distributed in the hope that it will be useful, but
15// WITHOUT ANY WARRANTY; without even the implied warranty of
16// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
17// General Public License for more details.
18//
19// You should have received a copy of the GNU General Public License along
20// with OUTRAM PARK.  If not, see <https://www.gnu.org/licenses/>.
21
22//! The `FoamFile` ASCII **dictionary** format: tokeniser, AST, parser, writer.
23//!
24//! An OpenFOAM dictionary file is a banner comment, an optional `FoamFile`
25//! header block, and a body of `keyword value ;` / `keyword { subdict }`
26//! entries. This module models that as:
27//!
28//! - [`FoamHeader`] — the `FoamFile { … }` block (an ordered flat
29//!   keyword→raw-value map; values are kept verbatim so `version 2.0`
30//!   round-trips as `2.0`, not `2`).
31//! - [`FoamDict`] — an **ordered** keyword→[`FoamEntry`] map (ordered so
32//!   writes preserve the input order and round-trip).
33//! - [`FoamEntry`] — the value bound to one keyword: a scalar, word, quoted
34//!   string, bare token sequence, parenthesised list, dimensioned value, or a
35//!   nested sub-dictionary.
36//! - [`FoamValue`] — a leaf inside a list / token sequence.
37//! - [`Dimensioned`] — a `[0 2 -2 0 0 0 0] value` dimensioned quantity.
38//!
39//! ## Grammar notes
40//!
41//! The tokeniser records whether each token was **glued** to the previous one
42//! (no intervening whitespace). This disambiguates a function-style word such
43//! as `div(phi,U)` or `grad(U)` (parentheses glued to a word → part of the
44//! word) from a genuine list `(0 0 1)` (parenthesis preceded by whitespace →
45//! a [`FoamValue::List`]). It also lets `4(1 4 13 10)` count-prefixed lists
46//! parse cleanly.
47
48use std::fmt::Write as _;
49use std::path::Path;
50
51use super::IoError;
52
53// ───────────────────────────────────────────────────────────────────────────
54//  Tokeniser
55// ───────────────────────────────────────────────────────────────────────────
56
57/// One lexical token from an OpenFOAM dictionary.
58#[derive(Debug, Clone, PartialEq)]
59pub(crate) struct Token {
60    /// The token text. For a quoted string this is the inner content (quotes
61    /// stripped) and `quoted` is set.
62    pub text: String,
63    /// True if no whitespace separated this token from the previous one.
64    pub glued: bool,
65    /// True if this token came from a `"…"` quoted string literal.
66    pub quoted: bool,
67}
68
69/// Split dictionary `text` into [`Token`]s, stripping `//` line comments and
70/// `/* … */` block comments. The delimiters `( ) { } ; [ ]` are always their
71/// own single-character tokens.
72pub(crate) fn tokenize(text: &str) -> Vec<Token> {
73    let bytes = text.as_bytes();
74    let n = bytes.len();
75    let mut i = 0;
76    let mut tokens: Vec<Token> = Vec::new();
77    // `pending_ws` is true when whitespace (or a comment) has been seen since
78    // the last emitted token — i.e. the next token is NOT glued.
79    let mut pending_ws = true;
80    let mut word = String::new();
81    let mut word_glued = true;
82
83    let is_delim = |c: u8| matches!(c, b'(' | b')' | b'{' | b'}' | b';' | b'[' | b']');
84
85    macro_rules! flush_word {
86        () => {
87            if !word.is_empty() {
88                tokens.push(Token {
89                    text: std::mem::take(&mut word),
90                    glued: word_glued,
91                    quoted: false,
92                });
93            }
94        };
95    }
96
97    while i < n {
98        let c = bytes[i];
99        if i + 1 < n && c == b'/' && bytes[i + 1] == b'/' {
100            flush_word!();
101            while i < n && bytes[i] != b'\n' {
102                i += 1;
103            }
104            pending_ws = true;
105        } else if i + 1 < n && c == b'/' && bytes[i + 1] == b'*' {
106            flush_word!();
107            i += 2;
108            while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
109                i += 1;
110            }
111            i += 2;
112            pending_ws = true;
113        } else if c == b'"' {
114            flush_word!();
115            i += 1;
116            let mut s = String::new();
117            while i < n && bytes[i] != b'"' {
118                s.push(bytes[i] as char);
119                i += 1;
120            }
121            i += 1; // closing quote
122            tokens.push(Token {
123                text: s,
124                glued: !pending_ws,
125                quoted: true,
126            });
127            pending_ws = false;
128        } else if is_delim(c) {
129            flush_word!();
130            tokens.push(Token {
131                text: (c as char).to_string(),
132                glued: !pending_ws,
133                quoted: false,
134            });
135            pending_ws = false;
136            i += 1;
137        } else if (c as char).is_whitespace() {
138            flush_word!();
139            pending_ws = true;
140            i += 1;
141        } else {
142            if word.is_empty() {
143                word_glued = !pending_ws;
144            }
145            word.push(c as char);
146            pending_ws = false;
147            i += 1;
148        }
149    }
150    flush_word!();
151    tokens
152}
153
154// ───────────────────────────────────────────────────────────────────────────
155//  AST
156// ───────────────────────────────────────────────────────────────────────────
157
158/// A dimensioned quantity: seven SI dimension exponents plus zero or more
159/// numeric components.
160///
161/// OpenFOAM writes these as `[mass length time temperature moles current
162/// luminous]` optionally followed by a value, e.g. `dimensions [0 2 -2 0 0 0
163/// 0];` (no value) or `nu [0 2 -1 0 0 0 0] 1e-05;` (scalar value). The value
164/// components are stored as raw [`FoamValue`]s to support scalar and vector
165/// forms.
166#[derive(Debug, Clone, PartialEq)]
167pub struct Dimensioned {
168    /// The seven SI dimension exponents, OpenFOAM order:
169    /// `[kg, m, s, K, mol, A, cd]`.
170    pub dims: [f64; 7],
171    /// Trailing value component(s); empty for a bare `dimensions […]` entry,
172    /// one element for a dimensioned scalar, three for a dimensioned vector.
173    pub value: Vec<FoamValue>,
174}
175
176/// A leaf value inside a list or a bare token sequence.
177#[derive(Debug, Clone, PartialEq)]
178pub enum FoamValue {
179    /// A numeric scalar.
180    Scalar(f64),
181    /// A bare identifier / keyword-like word (e.g. `ascii`, `Gauss`, `PCG`,
182    /// or a function-style `grad(U)`).
183    Word(String),
184    /// A `"…"` quoted string (stored without the surrounding quotes).
185    Str(String),
186    /// A parenthesised `( … )` list of values (may nest).
187    List(Vec<FoamValue>),
188    /// A `{ … }` sub-dictionary appearing **as a list element**.
189    ///
190    /// OpenFOAM lists routinely carry dictionaries: `constant/polyMesh/boundary`
191    /// and `cellZones` are `N ( name { … } name { … } )`, and GeN-Foam's
192    /// `nuclearData` writes its `states` and `zones` the same way. Without this
193    /// variant the braces tokenise into stray [`Word`](Self::Word)s and the
194    /// entry silently flattens into nonsense rather than failing.
195    ///
196    /// The *name* preceding each such dictionary is a separate
197    /// [`Word`](Self::Word) element in the enclosing list — this variant holds
198    /// only the braced body, exactly as the grammar has it.
199    Dict(FoamDict),
200}
201
202impl FoamValue {
203    /// Interpret a `List` of exactly three scalars as a [`Vector3`]; returns
204    /// `None` for any other shape.
205    ///
206    /// [`Vector3`]: crate::primitives::Vector3
207    pub fn as_vector3(&self) -> Option<crate::primitives::Vector3> {
208        if let FoamValue::List(v) = self {
209            if let [FoamValue::Scalar(x), FoamValue::Scalar(y), FoamValue::Scalar(z)] = v.as_slice()
210            {
211                return Some(crate::primitives::Vector3::new(*x, *y, *z));
212            }
213        }
214        None
215    }
216
217    /// The scalar value, if this is a [`FoamValue::Scalar`].
218    pub fn as_scalar(&self) -> Option<f64> {
219        match self {
220            FoamValue::Scalar(x) => Some(*x),
221            _ => None,
222        }
223    }
224
225    /// The word text, if this is a [`FoamValue::Word`].
226    pub fn as_word(&self) -> Option<&str> {
227        match self {
228            FoamValue::Word(w) => Some(w.as_str()),
229            _ => None,
230        }
231    }
232}
233
234/// The value bound to one keyword in a [`FoamDict`].
235#[derive(Debug, Clone, PartialEq)]
236pub enum FoamEntry {
237    /// A single numeric scalar: `startTime 0;`.
238    Scalar(f64),
239    /// A single word: `application icoFoam;` (or function-style `default
240    /// Gauss;`).
241    Word(String),
242    /// A single quoted string.
243    Str(String),
244    /// A bare, space-separated multi-token value that is **not** parenthesised:
245    /// `div(phi,U) Gauss linearUpwind grad(U);` → the keyword is `div(phi,U)`
246    /// and the entry is `Tokens([Gauss, linearUpwind, grad(U)])`.
247    Tokens(Vec<FoamValue>),
248    /// A single parenthesised list: `( … )`.
249    List(Vec<FoamValue>),
250    /// A dimensioned value: `[0 2 -2 0 0 0 0] …`.
251    Dimensioned(Dimensioned),
252    /// A nested sub-dictionary: `keyword { … }`.
253    SubDict(FoamDict),
254}
255
256/// An **ordered** keyword → [`FoamEntry`] map — the body of a dictionary or a
257/// sub-dictionary.
258///
259/// Insertion order is preserved so that serialising then parsing round-trips
260/// byte-order of the entries. Lookups are linear (dictionaries are small).
261#[derive(Debug, Clone, Default, PartialEq)]
262pub struct FoamDict {
263    entries: Vec<(String, FoamEntry)>,
264}
265
266impl FoamDict {
267    /// A new, empty dictionary.
268    pub fn new() -> Self {
269        Self::default()
270    }
271
272    /// Append `keyword → entry`. If the keyword already exists, the new entry
273    /// replaces the old one **in place** (order unchanged); otherwise it is
274    /// pushed to the end.
275    pub fn insert(&mut self, keyword: impl Into<String>, entry: FoamEntry) -> &mut Self {
276        let keyword = keyword.into();
277        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| *k == keyword) {
278            slot.1 = entry;
279        } else {
280            self.entries.push((keyword, entry));
281        }
282        self
283    }
284
285    /// Borrow the entry bound to `keyword`, if present.
286    pub fn get(&self, keyword: &str) -> Option<&FoamEntry> {
287        self.entries
288            .iter()
289            .find(|(k, _)| k == keyword)
290            .map(|(_, v)| v)
291    }
292
293    /// Borrow the sub-dictionary bound to `keyword`, if the entry is one.
294    pub fn get_dict(&self, keyword: &str) -> Option<&FoamDict> {
295        match self.get(keyword) {
296            Some(FoamEntry::SubDict(d)) => Some(d),
297            _ => None,
298        }
299    }
300
301    /// The scalar bound to `keyword`, if the entry is a scalar.
302    pub fn get_scalar(&self, keyword: &str) -> Option<f64> {
303        match self.get(keyword) {
304            Some(FoamEntry::Scalar(x)) => Some(*x),
305            _ => None,
306        }
307    }
308
309    /// The word bound to `keyword`, if the entry is a word.
310    pub fn get_word(&self, keyword: &str) -> Option<&str> {
311        match self.get(keyword) {
312            Some(FoamEntry::Word(w)) => Some(w.as_str()),
313            _ => None,
314        }
315    }
316
317    /// Iterate over `(keyword, entry)` pairs in insertion order.
318    pub fn iter(&self) -> impl Iterator<Item = (&str, &FoamEntry)> {
319        self.entries.iter().map(|(k, v)| (k.as_str(), v))
320    }
321
322    /// Number of top-level entries.
323    pub fn len(&self) -> usize {
324        self.entries.len()
325    }
326
327    /// True if the dictionary has no entries.
328    pub fn is_empty(&self) -> bool {
329        self.entries.is_empty()
330    }
331}
332
333// ───────────────────────────────────────────────────────────────────────────
334//  Header
335// ───────────────────────────────────────────────────────────────────────────
336
337/// The `FoamFile { … }` header block — an ordered flat keyword → raw-value map.
338///
339/// Values are stored verbatim (quotes preserved on the values that had them)
340/// so that e.g. `version 2.0;` round-trips as `2.0` rather than being reparsed
341/// to `2`, and `location "constant/polyMesh";` keeps its quotes.
342#[derive(Debug, Clone, Default, PartialEq)]
343pub struct FoamHeader {
344    entries: Vec<(String, String)>,
345}
346
347impl FoamHeader {
348    /// A new, empty header.
349    pub fn new() -> Self {
350        Self::default()
351    }
352
353    /// The standard header for a given `class` and `object`, with
354    /// `version 2.0; format ascii;`.
355    pub fn standard(class: &str, object: &str) -> Self {
356        let mut h = Self::new();
357        h.set("version", "2.0");
358        h.set("format", "ascii");
359        h.set("class", class);
360        h.set("object", object);
361        h
362    }
363
364    /// Like [`Self::standard`] but also records a `location "…"`.
365    pub fn standard_with_location(class: &str, location: &str, object: &str) -> Self {
366        let mut h = Self::new();
367        h.set("version", "2.0");
368        h.set("format", "ascii");
369        h.set("class", class);
370        h.set("location", &format!("\"{location}\""));
371        h.set("object", object);
372        h
373    }
374
375    /// Set `keyword → value` (replacing in place if present, else appending).
376    pub fn set(&mut self, keyword: &str, value: &str) -> &mut Self {
377        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| k == keyword) {
378            slot.1 = value.to_string();
379        } else {
380            self.entries.push((keyword.to_string(), value.to_string()));
381        }
382        self
383    }
384
385    /// The raw value bound to `keyword` (quotes still present if it had them).
386    pub fn get(&self, keyword: &str) -> Option<&str> {
387        self.entries
388            .iter()
389            .find(|(k, _)| k == keyword)
390            .map(|(_, v)| v.as_str())
391    }
392
393    /// The `class` field, if present.
394    pub fn class(&self) -> Option<&str> {
395        self.get("class")
396    }
397
398    /// The `object` field, if present.
399    pub fn object(&self) -> Option<&str> {
400        self.get("object")
401    }
402}
403
404// ───────────────────────────────────────────────────────────────────────────
405//  Parser
406// ───────────────────────────────────────────────────────────────────────────
407
408/// Forward-only token cursor with the dictionary-grammar parse routines.
409///
410/// Owns its tokens by value (no lifetime parameters, per the crate's design
411/// rules).
412struct Parser {
413    tokens: Vec<Token>,
414    pos: usize,
415    context: String,
416}
417
418impl Parser {
419    fn new(tokens: Vec<Token>, context: impl Into<String>) -> Self {
420        Self {
421            tokens,
422            pos: 0,
423            context: context.into(),
424        }
425    }
426
427    fn peek(&self) -> Option<&Token> {
428        self.tokens.get(self.pos)
429    }
430
431    fn peek_text(&self) -> Option<&str> {
432        self.tokens.get(self.pos).map(|t| t.text.as_str())
433    }
434
435    fn advance(&mut self) -> Option<Token> {
436        let t = self.tokens.get(self.pos).cloned();
437        if t.is_some() {
438            self.pos += 1;
439        }
440        t
441    }
442
443    fn err(&self, message: impl Into<String>) -> IoError {
444        IoError::parse(self.context.clone(), message.into())
445    }
446
447    fn expect(&mut self, tok: &str) -> Result<(), IoError> {
448        match self.advance() {
449            Some(t) if t.text == tok => Ok(()),
450            Some(t) => Err(self.err(format!("expected `{tok}`, found `{}`", t.text))),
451            None => Err(self.err(format!("expected `{tok}`, found end of input"))),
452        }
453    }
454
455    /// Read a keyword: a word, folding any glued trailing `(…)` group into it
456    /// so `div(phi,U)` becomes a single keyword string.
457    fn parse_keyword(&mut self) -> Result<String, IoError> {
458        let first = self
459            .advance()
460            .ok_or_else(|| self.err("expected a keyword, found end of input"))?;
461        if matches!(first.text.as_str(), "(" | ")" | "{" | "}" | ";" | "[" | "]") {
462            return Err(self.err(format!("expected a keyword, found `{}`", first.text)));
463        }
464        let mut kw = first.text.clone();
465        // Fold a glued `( … )` group (function-style keyword).
466        while matches!(self.peek(), Some(t) if t.text == "(" && t.glued) {
467            kw.push_str(&self.fold_paren_text());
468        }
469        Ok(kw)
470    }
471
472    /// Consume a `( … )` group and return its flattened text (no whitespace),
473    /// used to reassemble a function-style word such as `grad(U)`.
474    fn fold_paren_text(&mut self) -> String {
475        let mut out = String::new();
476        // consume "("
477        self.advance();
478        out.push('(');
479        let mut depth = 1;
480        while depth > 0 {
481            let Some(t) = self.advance() else { break };
482            match t.text.as_str() {
483                "(" => {
484                    depth += 1;
485                    out.push('(');
486                }
487                ")" => {
488                    depth -= 1;
489                    out.push(')');
490                }
491                other => out.push_str(other),
492            }
493        }
494        out
495    }
496
497    /// Parse the body of a dictionary up to (and consuming) a closing `}`, or
498    /// to end-of-input at the top level.
499    fn parse_dict_body(&mut self, top_level: bool) -> Result<FoamDict, IoError> {
500        let mut dict = FoamDict::new();
501        loop {
502            match self.peek_text() {
503                None => {
504                    if top_level {
505                        break;
506                    }
507                    return Err(self.err("unexpected end of input inside `{ … }`"));
508                }
509                Some("}") => {
510                    if top_level {
511                        return Err(self.err("unexpected `}` at top level"));
512                    }
513                    self.advance();
514                    break;
515                }
516                Some(";") => {
517                    self.advance(); // stray semicolon
518                }
519                _ => {
520                    let (kw, entry) = self.parse_entry()?;
521                    dict.insert(kw, entry);
522                }
523            }
524        }
525        Ok(dict)
526    }
527
528    /// Parse one `keyword … ;` (or `keyword { … }`) entry.
529    fn parse_entry(&mut self) -> Result<(String, FoamEntry), IoError> {
530        let keyword = self.parse_keyword()?;
531        match self.peek_text() {
532            Some("{") => {
533                self.advance();
534                let sub = self.parse_dict_body(false)?;
535                // OpenFOAM allows an optional trailing ';' after a sub-dict.
536                if self.peek_text() == Some(";") {
537                    self.advance();
538                }
539                Ok((keyword, FoamEntry::SubDict(sub)))
540            }
541            Some("[") => {
542                let dim = self.parse_dimensioned()?;
543                self.expect(";")?;
544                Ok((keyword, FoamEntry::Dimensioned(dim)))
545            }
546            _ => {
547                let entry = self.parse_value_sequence()?;
548                self.expect(";")?;
549                Ok((keyword, entry))
550            }
551        }
552    }
553
554    /// Parse the seven-exponent `[ … ]` block plus any trailing value tokens.
555    fn parse_dimensioned(&mut self) -> Result<Dimensioned, IoError> {
556        self.expect("[")?;
557        let mut dims = [0.0f64; 7];
558        for (i, slot) in dims.iter_mut().enumerate() {
559            let t = self
560                .advance()
561                .ok_or_else(|| self.err("unexpected end of input inside `[ … ]`"))?;
562            *slot = t.text.parse::<f64>().map_err(|_| {
563                self.err(format!(
564                    "dimension exponent {i} is not a number: `{}`",
565                    t.text
566                ))
567            })?;
568        }
569        self.expect("]")?;
570        // Trailing values (scalar / vector) up to the terminating ';'.
571        let mut value = Vec::new();
572        while !matches!(self.peek_text(), Some(";") | None) {
573            value.push(self.parse_leaf()?);
574        }
575        Ok(Dimensioned { dims, value })
576    }
577
578    /// Parse a bare value sequence terminated by `;` (not consumed) and reduce
579    /// it to the most specific [`FoamEntry`] variant.
580    fn parse_value_sequence(&mut self) -> Result<FoamEntry, IoError> {
581        let mut values = Vec::new();
582        while !matches!(self.peek_text(), Some(";") | None) {
583            values.push(self.parse_leaf()?);
584        }
585        Ok(match values.len() {
586            0 => return Err(self.err("empty entry value")),
587            1 => match values.into_iter().next().unwrap() {
588                FoamValue::Scalar(x) => FoamEntry::Scalar(x),
589                FoamValue::Word(w) => FoamEntry::Word(w),
590                FoamValue::Str(s) => FoamEntry::Str(s),
591                FoamValue::List(l) => FoamEntry::List(l),
592                FoamValue::Dict(d) => FoamEntry::SubDict(d),
593            },
594            _ => FoamEntry::Tokens(values),
595        })
596    }
597
598    /// Parse one leaf value: a list `( … )`, a quoted string, a number, or a
599    /// (possibly function-style) word.
600    fn parse_leaf(&mut self) -> Result<FoamValue, IoError> {
601        let t = self
602            .peek()
603            .ok_or_else(|| self.err("expected a value, found end of input"))?;
604        if t.text == "(" {
605            return self.parse_list();
606        }
607        if t.text == "{" {
608            self.advance();
609            let d = self.parse_dict_body(false)?;
610            return Ok(FoamValue::Dict(d));
611        }
612        // consume the token
613        let t = self.advance().unwrap();
614        if t.quoted {
615            return Ok(FoamValue::Str(t.text.clone()));
616        }
617        let text = t.text.clone();
618        // Function-style word: fold glued trailing `( … )` groups.
619        if self.peek().is_some_and(|p| p.text == "(" && p.glued) {
620            let mut w = text;
621            while matches!(self.peek(), Some(p) if p.text == "(" && p.glued) {
622                w.push_str(&self.fold_paren_text());
623            }
624            return Ok(FoamValue::Word(w));
625        }
626        match text.parse::<f64>() {
627            Ok(x) => Ok(FoamValue::Scalar(x)),
628            Err(_) => Ok(FoamValue::Word(text)),
629        }
630    }
631
632    /// Parse a `( … )` list, skipping any count prefix already handled by the
633    /// caller.
634    fn parse_list(&mut self) -> Result<FoamValue, IoError> {
635        self.expect("(")?;
636        let mut items = Vec::new();
637        loop {
638            match self.peek_text() {
639                Some(")") => {
640                    self.advance();
641                    break;
642                }
643                None => return Err(self.err("unexpected end of input inside `( … )`")),
644                _ => items.push(self.parse_leaf()?),
645            }
646        }
647        Ok(FoamValue::List(items))
648    }
649}
650
651/// Parse an optional `FoamFile { … }` header, returning it plus the remaining
652/// tokens' start position.
653fn parse_header(parser: &mut Parser) -> Result<Option<FoamHeader>, IoError> {
654    if parser.peek_text() != Some("FoamFile") {
655        return Ok(None);
656    }
657    parser.advance(); // FoamFile
658    parser.expect("{")?;
659    let mut header = FoamHeader::new();
660    loop {
661        match parser.peek_text() {
662            Some("}") => {
663                parser.advance();
664                break;
665            }
666            None => return Err(parser.err("unexpected end of input inside FoamFile header")),
667            _ => {
668                let kw = parser.parse_keyword()?;
669                // Header value = single raw token up to ';'.
670                let vt = parser
671                    .advance()
672                    .ok_or_else(|| parser.err("expected a header value"))?;
673                let raw = if vt.quoted {
674                    format!("\"{}\"", vt.text)
675                } else {
676                    vt.text.clone()
677                };
678                parser.expect(";")?;
679                header.set(&kw, &raw);
680            }
681        }
682    }
683    Ok(Some(header))
684}
685
686// ───────────────────────────────────────────────────────────────────────────
687//  Public parse / write API
688// ───────────────────────────────────────────────────────────────────────────
689
690/// A parsed OpenFOAM dictionary file: its `FoamFile` header (if present) and
691/// the body of entries.
692#[derive(Debug, Clone, PartialEq)]
693pub struct FoamFile {
694    /// The `FoamFile { … }` header, or `None` if the file had none.
695    pub header: Option<FoamHeader>,
696    /// The dictionary body.
697    pub dict: FoamDict,
698}
699
700impl FoamFile {
701    /// Parse dictionary `text` into a header (if any) and body.
702    pub fn parse(text: &str) -> Result<Self, IoError> {
703        Self::parse_named(text, "dictionary")
704    }
705
706    /// Like [`Self::parse`] but labels parse errors with `context`.
707    pub fn parse_named(text: &str, context: impl Into<String>) -> Result<Self, IoError> {
708        let tokens = tokenize(text);
709        let mut parser = Parser::new(tokens, context);
710        let header = parse_header(&mut parser)?;
711        let dict = parser.parse_dict_body(true)?;
712        Ok(Self { header, dict })
713    }
714
715    /// Read and parse a dictionary file from `path`.
716    ///
717    /// `#include` directives are **not** followed — use
718    /// [`read_with_includes`](Self::read_with_includes) for that.
719    pub fn read(path: impl AsRef<Path>) -> Result<Self, IoError> {
720        let path = path.as_ref();
721        let text = std::fs::read_to_string(path)
722            .map_err(|e| IoError::io(path.display().to_string(), e))?;
723        Self::parse_named(&text, path.display().to_string())
724    }
725
726    /// Read and parse a dictionary file from `path`, splicing in any
727    /// `#include "relative/name"` directives first.
728    ///
729    /// OpenFOAM's `#include` is a textual include resolved relative to the
730    /// *including* file's directory, and dictionaries in the wild lean on it —
731    /// GeN-Foam's `nuclearData`, for instance, puts each reactor state's zone
732    /// data in its own `XS…` file and includes it inside the `zones ( … )` list.
733    /// Parsing such a file without following the includes does not fail; it
734    /// silently yields a list containing the words `#include` and `XSref`, which
735    /// is the failure mode this exists to avoid.
736    ///
737    /// This is deliberately a **separate entry point** rather than a change to
738    /// [`read`](Self::read): following an include reads files the caller did not
739    /// name, so it is opt-in.
740    ///
741    /// # Scope and limits
742    ///
743    /// - Only `#include "…"` is handled. `#includeEtc`, `#includeFunc`,
744    ///   `#inputMode`, `#calc` and the other OpenFOAM directives are left in
745    ///   place as ordinary tokens.
746    /// - Includes nest, to a depth of [`MAX_INCLUDE_DEPTH`]. Exceeding it is an
747    ///   error rather than a hang, which is what a cyclic include would
748    ///   otherwise cause.
749    /// - The included text is spliced verbatim. An included file carrying its
750    ///   own `FoamFile { … }` header would therefore land mid-body; OpenFOAM
751    ///   behaves the same way, and files meant for inclusion do not carry one.
752    ///
753    /// # Errors
754    ///
755    /// [`IoError`] if `path` or any included file cannot be read, if an
756    /// `#include` is not followed by a quoted filename, or if the include depth
757    /// is exceeded.
758    pub fn read_with_includes(path: impl AsRef<Path>) -> Result<Self, IoError> {
759        let path = path.as_ref();
760        let text = resolve_includes(path, 0)?;
761        Self::parse_named(&text, path.display().to_string())
762    }
763
764    /// Serialise to OpenFOAM ASCII text (banner + header + body).
765    pub fn to_foam_string(&self) -> String {
766        let mut out = String::new();
767        out.push_str(BANNER);
768        if let Some(h) = &self.header {
769            write_header(&mut out, h);
770        }
771        out.push_str(SEPARATOR);
772        out.push('\n');
773        write_dict_body(&mut out, &self.dict, 0);
774        out.push('\n');
775        out.push_str(FOOTER);
776        out
777    }
778
779    /// Write to `path` as OpenFOAM ASCII text.
780    pub fn write(&self, path: impl AsRef<Path>) -> Result<(), IoError> {
781        let path = path.as_ref();
782        std::fs::write(path, self.to_foam_string())
783            .map_err(|e| IoError::io(path.display().to_string(), e))
784    }
785}
786
787// ── Serialisation helpers (also used by poly_mesh / field) ──────────────────
788
789/// The OpenFOAM-style ASCII banner comment written at the top of every file.
790pub(crate) const BANNER: &str = "\
791/*--------------------------------*- C++ -*----------------------------------*\\
792| =========                 |                                                 |
793| \\\\      /  F ield         | OUTRAM PARK: independent OpenFOAM-format I/O    |
794|  \\\\    /   O peration     | (not the official OpenFOAM software)            |
795|   \\\\  /    A nd           |                                                 |
796|    \\\\/     M anipulation  |                                                 |
797\\*---------------------------------------------------------------------------*/
798";
799
800pub(crate) const SEPARATOR: &str =
801    "// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //";
802
803pub(crate) const FOOTER: &str =
804    "// ************************************************************************* //\n";
805
806/// Write the `FoamFile { … }` header block.
807pub(crate) fn write_header(out: &mut String, header: &FoamHeader) {
808    out.push_str("FoamFile\n{\n");
809    for (k, v) in &header.entries {
810        let _ = writeln!(out, "    {k:<12}{v};");
811    }
812    out.push_str("}\n");
813}
814
815/// Format a scalar compactly: integers without a decimal point.
816pub(crate) fn fmt_scalar(x: f64) -> String {
817    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
818        format!("{}", x as i64)
819    } else {
820        format!("{x}")
821    }
822}
823
824fn write_value(out: &mut String, v: &FoamValue) {
825    match v {
826        FoamValue::Scalar(x) => out.push_str(&fmt_scalar(*x)),
827        FoamValue::Word(w) => out.push_str(w),
828        FoamValue::Str(s) => {
829            out.push('"');
830            out.push_str(s);
831            out.push('"');
832        }
833        FoamValue::List(items) => {
834            out.push('(');
835            for (i, it) in items.iter().enumerate() {
836                if i > 0 {
837                    out.push(' ');
838                }
839                write_value(out, it);
840            }
841            out.push(')');
842        }
843        FoamValue::Dict(d) => {
844            // A dictionary inside a list is written on its own lines, the way
845            // OpenFOAM writes `boundary` and `cellZones`, rather than inline.
846            out.push_str("\n{\n");
847            write_dict_body(out, d, 1);
848            out.push('}');
849        }
850    }
851}
852
853fn write_dimensioned(out: &mut String, d: &Dimensioned) {
854    out.push('[');
855    for (i, e) in d.dims.iter().enumerate() {
856        if i > 0 {
857            out.push(' ');
858        }
859        out.push_str(&fmt_scalar(*e));
860    }
861    out.push(']');
862    for v in &d.value {
863        out.push(' ');
864        write_value(out, v);
865    }
866}
867
868fn indent(out: &mut String, level: usize) {
869    for _ in 0..level {
870        out.push_str("    ");
871    }
872}
873
874/// Write the entries of `dict` at nesting `level` (each level = 4 spaces).
875pub(crate) fn write_dict_body(out: &mut String, dict: &FoamDict, level: usize) {
876    for (k, entry) in dict.iter() {
877        match entry {
878            FoamEntry::SubDict(sub) => {
879                indent(out, level);
880                out.push_str(k);
881                out.push('\n');
882                indent(out, level);
883                out.push_str("{\n");
884                write_dict_body(out, sub, level + 1);
885                indent(out, level);
886                out.push_str("}\n");
887            }
888            FoamEntry::Scalar(x) => {
889                indent(out, level);
890                let _ = writeln!(out, "{k:<16}{};", fmt_scalar(*x));
891            }
892            FoamEntry::Word(w) => {
893                indent(out, level);
894                let _ = writeln!(out, "{k:<16}{w};");
895            }
896            FoamEntry::Str(s) => {
897                indent(out, level);
898                let _ = writeln!(out, "{k:<16}\"{s}\";");
899            }
900            FoamEntry::Tokens(vs) => {
901                indent(out, level);
902                let _ = write!(out, "{k:<16}");
903                for (i, v) in vs.iter().enumerate() {
904                    if i > 0 {
905                        out.push(' ');
906                    }
907                    write_value(out, v);
908                }
909                out.push_str(";\n");
910            }
911            FoamEntry::List(items) => {
912                indent(out, level);
913                let _ = write!(out, "{k:<16}");
914                let mut v = String::new();
915                write_value(&mut v, &FoamValue::List(items.clone()));
916                out.push_str(&v);
917                out.push_str(";\n");
918            }
919            FoamEntry::Dimensioned(d) => {
920                indent(out, level);
921                let _ = write!(out, "{k:<16}");
922                write_dimensioned(out, d);
923                out.push_str(";\n");
924            }
925        }
926    }
927}
928
929// ───────────────────────────────────────────────────────────────────────────
930//  Tests
931// ───────────────────────────────────────────────────────────────────────────
932
933/// Maximum `#include` nesting depth for [`FoamFile::read_with_includes`].
934///
935/// A cyclic include would otherwise recurse until the stack runs out; this
936/// turns that into a plain error naming the file.
937pub const MAX_INCLUDE_DEPTH: usize = 16;
938
939/// Read `path` and splice in every `#include "name"` it contains, recursively.
940///
941/// `name` is resolved relative to `path`'s own directory, as OpenFOAM does.
942fn resolve_includes(path: &Path, depth: usize) -> Result<String, IoError> {
943    if depth > MAX_INCLUDE_DEPTH {
944        return Err(IoError::parse(
945            path.display().to_string(),
946            format!("`#include` nesting deeper than {MAX_INCLUDE_DEPTH} (a cyclic include?)"),
947        ));
948    }
949    let text =
950        std::fs::read_to_string(path).map_err(|e| IoError::io(path.display().to_string(), e))?;
951    if !text.contains("#include") {
952        return Ok(text);
953    }
954    let dir = path.parent().unwrap_or_else(|| Path::new("."));
955
956    let mut out = String::with_capacity(text.len());
957    for line in text.lines() {
958        let trimmed = line.trim_start();
959        // Only a line whose first token is `#include` is a directive; the word
960        // appearing inside a comment or a string is left alone.
961        match trimmed.strip_prefix("#include") {
962            Some(rest) if rest.starts_with(char::is_whitespace) => {
963                let quoted = rest.trim();
964                let name = quoted
965                    .strip_prefix('"')
966                    .and_then(|r| r.strip_suffix('"'))
967                    .ok_or_else(|| {
968                        IoError::parse(
969                            path.display().to_string(),
970                            format!("`#include` expects a quoted filename, found `{quoted}`"),
971                        )
972                    })?;
973                out.push_str(&resolve_includes(&dir.join(name), depth + 1)?);
974                out.push('\n');
975            }
976            _ => {
977                out.push_str(line);
978                out.push('\n');
979            }
980        }
981    }
982    Ok(out)
983}
984
985#[cfg(test)]
986mod tests {
987    use super::*;
988
989    #[test]
990    fn tokenize_glued_flag() {
991        let toks = tokenize("div(phi,U) (0 0 1)");
992        // div ( phi,U ) ( 0 0 1 )
993        assert_eq!(toks[0].text, "div");
994        assert_eq!(toks[1].text, "(");
995        assert!(toks[1].glued, "`(` after div should be glued");
996        // the list's opening paren is preceded by whitespace
997        let list_open = toks.iter().rposition(|t| t.text == "(").unwrap();
998        assert!(!toks[list_open].glued);
999    }
1000
1001    #[test]
1002    fn parse_function_keyword() {
1003        let f = FoamFile::parse("divSchemes { div(phi,U) Gauss linear; }").unwrap();
1004        let ds = f.dict.get_dict("divSchemes").unwrap();
1005        match ds.get("div(phi,U)").unwrap() {
1006            FoamEntry::Tokens(v) => {
1007                assert_eq!(v[0], FoamValue::Word("Gauss".into()));
1008                assert_eq!(v[1], FoamValue::Word("linear".into()));
1009            }
1010            other => panic!("expected Tokens, got {other:?}"),
1011        }
1012    }
1013
1014    #[test]
1015    fn dimensioned_entry() {
1016        let f = FoamFile::parse("nu [0 2 -1 0 0 0 0] 1e-05;").unwrap();
1017        match f.dict.get("nu").unwrap() {
1018            FoamEntry::Dimensioned(d) => {
1019                assert_eq!(d.dims, [0.0, 2.0, -1.0, 0.0, 0.0, 0.0, 0.0]);
1020                assert_eq!(d.value, vec![FoamValue::Scalar(1e-5)]);
1021            }
1022            other => panic!("expected Dimensioned, got {other:?}"),
1023        }
1024    }
1025}