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}
189
190impl FoamValue {
191    /// Interpret a `List` of exactly three scalars as a [`Vector3`]; returns
192    /// `None` for any other shape.
193    ///
194    /// [`Vector3`]: crate::primitives::Vector3
195    pub fn as_vector3(&self) -> Option<crate::primitives::Vector3> {
196        if let FoamValue::List(v) = self {
197            if let [FoamValue::Scalar(x), FoamValue::Scalar(y), FoamValue::Scalar(z)] = v.as_slice()
198            {
199                return Some(crate::primitives::Vector3::new(*x, *y, *z));
200            }
201        }
202        None
203    }
204
205    /// The scalar value, if this is a [`FoamValue::Scalar`].
206    pub fn as_scalar(&self) -> Option<f64> {
207        match self {
208            FoamValue::Scalar(x) => Some(*x),
209            _ => None,
210        }
211    }
212
213    /// The word text, if this is a [`FoamValue::Word`].
214    pub fn as_word(&self) -> Option<&str> {
215        match self {
216            FoamValue::Word(w) => Some(w.as_str()),
217            _ => None,
218        }
219    }
220}
221
222/// The value bound to one keyword in a [`FoamDict`].
223#[derive(Debug, Clone, PartialEq)]
224pub enum FoamEntry {
225    /// A single numeric scalar: `startTime 0;`.
226    Scalar(f64),
227    /// A single word: `application icoFoam;` (or function-style `default
228    /// Gauss;`).
229    Word(String),
230    /// A single quoted string.
231    Str(String),
232    /// A bare, space-separated multi-token value that is **not** parenthesised:
233    /// `div(phi,U) Gauss linearUpwind grad(U);` → the keyword is `div(phi,U)`
234    /// and the entry is `Tokens([Gauss, linearUpwind, grad(U)])`.
235    Tokens(Vec<FoamValue>),
236    /// A single parenthesised list: `( … )`.
237    List(Vec<FoamValue>),
238    /// A dimensioned value: `[0 2 -2 0 0 0 0] …`.
239    Dimensioned(Dimensioned),
240    /// A nested sub-dictionary: `keyword { … }`.
241    SubDict(FoamDict),
242}
243
244/// An **ordered** keyword → [`FoamEntry`] map — the body of a dictionary or a
245/// sub-dictionary.
246///
247/// Insertion order is preserved so that serialising then parsing round-trips
248/// byte-order of the entries. Lookups are linear (dictionaries are small).
249#[derive(Debug, Clone, Default, PartialEq)]
250pub struct FoamDict {
251    entries: Vec<(String, FoamEntry)>,
252}
253
254impl FoamDict {
255    /// A new, empty dictionary.
256    pub fn new() -> Self {
257        Self::default()
258    }
259
260    /// Append `keyword → entry`. If the keyword already exists, the new entry
261    /// replaces the old one **in place** (order unchanged); otherwise it is
262    /// pushed to the end.
263    pub fn insert(&mut self, keyword: impl Into<String>, entry: FoamEntry) -> &mut Self {
264        let keyword = keyword.into();
265        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| *k == keyword) {
266            slot.1 = entry;
267        } else {
268            self.entries.push((keyword, entry));
269        }
270        self
271    }
272
273    /// Borrow the entry bound to `keyword`, if present.
274    pub fn get(&self, keyword: &str) -> Option<&FoamEntry> {
275        self.entries
276            .iter()
277            .find(|(k, _)| k == keyword)
278            .map(|(_, v)| v)
279    }
280
281    /// Borrow the sub-dictionary bound to `keyword`, if the entry is one.
282    pub fn get_dict(&self, keyword: &str) -> Option<&FoamDict> {
283        match self.get(keyword) {
284            Some(FoamEntry::SubDict(d)) => Some(d),
285            _ => None,
286        }
287    }
288
289    /// The scalar bound to `keyword`, if the entry is a scalar.
290    pub fn get_scalar(&self, keyword: &str) -> Option<f64> {
291        match self.get(keyword) {
292            Some(FoamEntry::Scalar(x)) => Some(*x),
293            _ => None,
294        }
295    }
296
297    /// The word bound to `keyword`, if the entry is a word.
298    pub fn get_word(&self, keyword: &str) -> Option<&str> {
299        match self.get(keyword) {
300            Some(FoamEntry::Word(w)) => Some(w.as_str()),
301            _ => None,
302        }
303    }
304
305    /// Iterate over `(keyword, entry)` pairs in insertion order.
306    pub fn iter(&self) -> impl Iterator<Item = (&str, &FoamEntry)> {
307        self.entries.iter().map(|(k, v)| (k.as_str(), v))
308    }
309
310    /// Number of top-level entries.
311    pub fn len(&self) -> usize {
312        self.entries.len()
313    }
314
315    /// True if the dictionary has no entries.
316    pub fn is_empty(&self) -> bool {
317        self.entries.is_empty()
318    }
319}
320
321// ───────────────────────────────────────────────────────────────────────────
322//  Header
323// ───────────────────────────────────────────────────────────────────────────
324
325/// The `FoamFile { … }` header block — an ordered flat keyword → raw-value map.
326///
327/// Values are stored verbatim (quotes preserved on the values that had them)
328/// so that e.g. `version 2.0;` round-trips as `2.0` rather than being reparsed
329/// to `2`, and `location "constant/polyMesh";` keeps its quotes.
330#[derive(Debug, Clone, Default, PartialEq)]
331pub struct FoamHeader {
332    entries: Vec<(String, String)>,
333}
334
335impl FoamHeader {
336    /// A new, empty header.
337    pub fn new() -> Self {
338        Self::default()
339    }
340
341    /// The standard header for a given `class` and `object`, with
342    /// `version 2.0; format ascii;`.
343    pub fn standard(class: &str, object: &str) -> Self {
344        let mut h = Self::new();
345        h.set("version", "2.0");
346        h.set("format", "ascii");
347        h.set("class", class);
348        h.set("object", object);
349        h
350    }
351
352    /// Like [`Self::standard`] but also records a `location "…"`.
353    pub fn standard_with_location(class: &str, location: &str, object: &str) -> Self {
354        let mut h = Self::new();
355        h.set("version", "2.0");
356        h.set("format", "ascii");
357        h.set("class", class);
358        h.set("location", &format!("\"{location}\""));
359        h.set("object", object);
360        h
361    }
362
363    /// Set `keyword → value` (replacing in place if present, else appending).
364    pub fn set(&mut self, keyword: &str, value: &str) -> &mut Self {
365        if let Some(slot) = self.entries.iter_mut().find(|(k, _)| k == keyword) {
366            slot.1 = value.to_string();
367        } else {
368            self.entries.push((keyword.to_string(), value.to_string()));
369        }
370        self
371    }
372
373    /// The raw value bound to `keyword` (quotes still present if it had them).
374    pub fn get(&self, keyword: &str) -> Option<&str> {
375        self.entries
376            .iter()
377            .find(|(k, _)| k == keyword)
378            .map(|(_, v)| v.as_str())
379    }
380
381    /// The `class` field, if present.
382    pub fn class(&self) -> Option<&str> {
383        self.get("class")
384    }
385
386    /// The `object` field, if present.
387    pub fn object(&self) -> Option<&str> {
388        self.get("object")
389    }
390}
391
392// ───────────────────────────────────────────────────────────────────────────
393//  Parser
394// ───────────────────────────────────────────────────────────────────────────
395
396/// Forward-only token cursor with the dictionary-grammar parse routines.
397///
398/// Owns its tokens by value (no lifetime parameters, per the crate's design
399/// rules).
400struct Parser {
401    tokens: Vec<Token>,
402    pos: usize,
403    context: String,
404}
405
406impl Parser {
407    fn new(tokens: Vec<Token>, context: impl Into<String>) -> Self {
408        Self {
409            tokens,
410            pos: 0,
411            context: context.into(),
412        }
413    }
414
415    fn peek(&self) -> Option<&Token> {
416        self.tokens.get(self.pos)
417    }
418
419    fn peek_text(&self) -> Option<&str> {
420        self.tokens.get(self.pos).map(|t| t.text.as_str())
421    }
422
423    fn advance(&mut self) -> Option<Token> {
424        let t = self.tokens.get(self.pos).cloned();
425        if t.is_some() {
426            self.pos += 1;
427        }
428        t
429    }
430
431    fn err(&self, message: impl Into<String>) -> IoError {
432        IoError::parse(self.context.clone(), message.into())
433    }
434
435    fn expect(&mut self, tok: &str) -> Result<(), IoError> {
436        match self.advance() {
437            Some(t) if t.text == tok => Ok(()),
438            Some(t) => Err(self.err(format!("expected `{tok}`, found `{}`", t.text))),
439            None => Err(self.err(format!("expected `{tok}`, found end of input"))),
440        }
441    }
442
443    /// Read a keyword: a word, folding any glued trailing `(…)` group into it
444    /// so `div(phi,U)` becomes a single keyword string.
445    fn parse_keyword(&mut self) -> Result<String, IoError> {
446        let first = self
447            .advance()
448            .ok_or_else(|| self.err("expected a keyword, found end of input"))?;
449        if matches!(first.text.as_str(), "(" | ")" | "{" | "}" | ";" | "[" | "]") {
450            return Err(self.err(format!("expected a keyword, found `{}`", first.text)));
451        }
452        let mut kw = first.text.clone();
453        // Fold a glued `( … )` group (function-style keyword).
454        while matches!(self.peek(), Some(t) if t.text == "(" && t.glued) {
455            kw.push_str(&self.fold_paren_text());
456        }
457        Ok(kw)
458    }
459
460    /// Consume a `( … )` group and return its flattened text (no whitespace),
461    /// used to reassemble a function-style word such as `grad(U)`.
462    fn fold_paren_text(&mut self) -> String {
463        let mut out = String::new();
464        // consume "("
465        self.advance();
466        out.push('(');
467        let mut depth = 1;
468        while depth > 0 {
469            let Some(t) = self.advance() else { break };
470            match t.text.as_str() {
471                "(" => {
472                    depth += 1;
473                    out.push('(');
474                }
475                ")" => {
476                    depth -= 1;
477                    out.push(')');
478                }
479                other => out.push_str(other),
480            }
481        }
482        out
483    }
484
485    /// Parse the body of a dictionary up to (and consuming) a closing `}`, or
486    /// to end-of-input at the top level.
487    fn parse_dict_body(&mut self, top_level: bool) -> Result<FoamDict, IoError> {
488        let mut dict = FoamDict::new();
489        loop {
490            match self.peek_text() {
491                None => {
492                    if top_level {
493                        break;
494                    }
495                    return Err(self.err("unexpected end of input inside `{ … }`"));
496                }
497                Some("}") => {
498                    if top_level {
499                        return Err(self.err("unexpected `}` at top level"));
500                    }
501                    self.advance();
502                    break;
503                }
504                Some(";") => {
505                    self.advance(); // stray semicolon
506                }
507                _ => {
508                    let (kw, entry) = self.parse_entry()?;
509                    dict.insert(kw, entry);
510                }
511            }
512        }
513        Ok(dict)
514    }
515
516    /// Parse one `keyword … ;` (or `keyword { … }`) entry.
517    fn parse_entry(&mut self) -> Result<(String, FoamEntry), IoError> {
518        let keyword = self.parse_keyword()?;
519        match self.peek_text() {
520            Some("{") => {
521                self.advance();
522                let sub = self.parse_dict_body(false)?;
523                // OpenFOAM allows an optional trailing ';' after a sub-dict.
524                if self.peek_text() == Some(";") {
525                    self.advance();
526                }
527                Ok((keyword, FoamEntry::SubDict(sub)))
528            }
529            Some("[") => {
530                let dim = self.parse_dimensioned()?;
531                self.expect(";")?;
532                Ok((keyword, FoamEntry::Dimensioned(dim)))
533            }
534            _ => {
535                let entry = self.parse_value_sequence()?;
536                self.expect(";")?;
537                Ok((keyword, entry))
538            }
539        }
540    }
541
542    /// Parse the seven-exponent `[ … ]` block plus any trailing value tokens.
543    fn parse_dimensioned(&mut self) -> Result<Dimensioned, IoError> {
544        self.expect("[")?;
545        let mut dims = [0.0f64; 7];
546        for (i, slot) in dims.iter_mut().enumerate() {
547            let t = self
548                .advance()
549                .ok_or_else(|| self.err("unexpected end of input inside `[ … ]`"))?;
550            *slot = t.text.parse::<f64>().map_err(|_| {
551                self.err(format!(
552                    "dimension exponent {i} is not a number: `{}`",
553                    t.text
554                ))
555            })?;
556        }
557        self.expect("]")?;
558        // Trailing values (scalar / vector) up to the terminating ';'.
559        let mut value = Vec::new();
560        while !matches!(self.peek_text(), Some(";") | None) {
561            value.push(self.parse_leaf()?);
562        }
563        Ok(Dimensioned { dims, value })
564    }
565
566    /// Parse a bare value sequence terminated by `;` (not consumed) and reduce
567    /// it to the most specific [`FoamEntry`] variant.
568    fn parse_value_sequence(&mut self) -> Result<FoamEntry, IoError> {
569        let mut values = Vec::new();
570        while !matches!(self.peek_text(), Some(";") | None) {
571            values.push(self.parse_leaf()?);
572        }
573        Ok(match values.len() {
574            0 => return Err(self.err("empty entry value")),
575            1 => match values.into_iter().next().unwrap() {
576                FoamValue::Scalar(x) => FoamEntry::Scalar(x),
577                FoamValue::Word(w) => FoamEntry::Word(w),
578                FoamValue::Str(s) => FoamEntry::Str(s),
579                FoamValue::List(l) => FoamEntry::List(l),
580            },
581            _ => FoamEntry::Tokens(values),
582        })
583    }
584
585    /// Parse one leaf value: a list `( … )`, a quoted string, a number, or a
586    /// (possibly function-style) word.
587    fn parse_leaf(&mut self) -> Result<FoamValue, IoError> {
588        let t = self
589            .peek()
590            .ok_or_else(|| self.err("expected a value, found end of input"))?;
591        if t.text == "(" {
592            return self.parse_list();
593        }
594        // consume the token
595        let t = self.advance().unwrap();
596        if t.quoted {
597            return Ok(FoamValue::Str(t.text.clone()));
598        }
599        let text = t.text.clone();
600        // Function-style word: fold glued trailing `( … )` groups.
601        if self.peek().is_some_and(|p| p.text == "(" && p.glued) {
602            let mut w = text;
603            while matches!(self.peek(), Some(p) if p.text == "(" && p.glued) {
604                w.push_str(&self.fold_paren_text());
605            }
606            return Ok(FoamValue::Word(w));
607        }
608        match text.parse::<f64>() {
609            Ok(x) => Ok(FoamValue::Scalar(x)),
610            Err(_) => Ok(FoamValue::Word(text)),
611        }
612    }
613
614    /// Parse a `( … )` list, skipping any count prefix already handled by the
615    /// caller.
616    fn parse_list(&mut self) -> Result<FoamValue, IoError> {
617        self.expect("(")?;
618        let mut items = Vec::new();
619        loop {
620            match self.peek_text() {
621                Some(")") => {
622                    self.advance();
623                    break;
624                }
625                None => return Err(self.err("unexpected end of input inside `( … )`")),
626                _ => items.push(self.parse_leaf()?),
627            }
628        }
629        Ok(FoamValue::List(items))
630    }
631}
632
633/// Parse an optional `FoamFile { … }` header, returning it plus the remaining
634/// tokens' start position.
635fn parse_header(parser: &mut Parser) -> Result<Option<FoamHeader>, IoError> {
636    if parser.peek_text() != Some("FoamFile") {
637        return Ok(None);
638    }
639    parser.advance(); // FoamFile
640    parser.expect("{")?;
641    let mut header = FoamHeader::new();
642    loop {
643        match parser.peek_text() {
644            Some("}") => {
645                parser.advance();
646                break;
647            }
648            None => return Err(parser.err("unexpected end of input inside FoamFile header")),
649            _ => {
650                let kw = parser.parse_keyword()?;
651                // Header value = single raw token up to ';'.
652                let vt = parser
653                    .advance()
654                    .ok_or_else(|| parser.err("expected a header value"))?;
655                let raw = if vt.quoted {
656                    format!("\"{}\"", vt.text)
657                } else {
658                    vt.text.clone()
659                };
660                parser.expect(";")?;
661                header.set(&kw, &raw);
662            }
663        }
664    }
665    Ok(Some(header))
666}
667
668// ───────────────────────────────────────────────────────────────────────────
669//  Public parse / write API
670// ───────────────────────────────────────────────────────────────────────────
671
672/// A parsed OpenFOAM dictionary file: its `FoamFile` header (if present) and
673/// the body of entries.
674#[derive(Debug, Clone, PartialEq)]
675pub struct FoamFile {
676    /// The `FoamFile { … }` header, or `None` if the file had none.
677    pub header: Option<FoamHeader>,
678    /// The dictionary body.
679    pub dict: FoamDict,
680}
681
682impl FoamFile {
683    /// Parse dictionary `text` into a header (if any) and body.
684    pub fn parse(text: &str) -> Result<Self, IoError> {
685        Self::parse_named(text, "dictionary")
686    }
687
688    /// Like [`Self::parse`] but labels parse errors with `context`.
689    pub fn parse_named(text: &str, context: impl Into<String>) -> Result<Self, IoError> {
690        let tokens = tokenize(text);
691        let mut parser = Parser::new(tokens, context);
692        let header = parse_header(&mut parser)?;
693        let dict = parser.parse_dict_body(true)?;
694        Ok(Self { header, dict })
695    }
696
697    /// Read and parse a dictionary file from `path`.
698    pub fn read(path: impl AsRef<Path>) -> Result<Self, IoError> {
699        let path = path.as_ref();
700        let text = std::fs::read_to_string(path)
701            .map_err(|e| IoError::io(path.display().to_string(), e))?;
702        Self::parse_named(&text, path.display().to_string())
703    }
704
705    /// Serialise to OpenFOAM ASCII text (banner + header + body).
706    pub fn to_foam_string(&self) -> String {
707        let mut out = String::new();
708        out.push_str(BANNER);
709        if let Some(h) = &self.header {
710            write_header(&mut out, h);
711        }
712        out.push_str(SEPARATOR);
713        out.push('\n');
714        write_dict_body(&mut out, &self.dict, 0);
715        out.push('\n');
716        out.push_str(FOOTER);
717        out
718    }
719
720    /// Write to `path` as OpenFOAM ASCII text.
721    pub fn write(&self, path: impl AsRef<Path>) -> Result<(), IoError> {
722        let path = path.as_ref();
723        std::fs::write(path, self.to_foam_string())
724            .map_err(|e| IoError::io(path.display().to_string(), e))
725    }
726}
727
728// ── Serialisation helpers (also used by poly_mesh / field) ──────────────────
729
730/// The OpenFOAM-style ASCII banner comment written at the top of every file.
731pub(crate) const BANNER: &str = "\
732/*--------------------------------*- C++ -*----------------------------------*\\
733| =========                 |                                                 |
734| \\\\      /  F ield         | OUTRAM PARK: independent OpenFOAM-format I/O    |
735|  \\\\    /   O peration     | (not the official OpenFOAM software)            |
736|   \\\\  /    A nd           |                                                 |
737|    \\\\/     M anipulation  |                                                 |
738\\*---------------------------------------------------------------------------*/
739";
740
741pub(crate) const SEPARATOR: &str =
742    "// * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * //";
743
744pub(crate) const FOOTER: &str =
745    "// ************************************************************************* //\n";
746
747/// Write the `FoamFile { … }` header block.
748pub(crate) fn write_header(out: &mut String, header: &FoamHeader) {
749    out.push_str("FoamFile\n{\n");
750    for (k, v) in &header.entries {
751        let _ = writeln!(out, "    {k:<12}{v};");
752    }
753    out.push_str("}\n");
754}
755
756/// Format a scalar compactly: integers without a decimal point.
757pub(crate) fn fmt_scalar(x: f64) -> String {
758    if x.is_finite() && x == x.trunc() && x.abs() < 1e15 {
759        format!("{}", x as i64)
760    } else {
761        format!("{x}")
762    }
763}
764
765fn write_value(out: &mut String, v: &FoamValue) {
766    match v {
767        FoamValue::Scalar(x) => out.push_str(&fmt_scalar(*x)),
768        FoamValue::Word(w) => out.push_str(w),
769        FoamValue::Str(s) => {
770            out.push('"');
771            out.push_str(s);
772            out.push('"');
773        }
774        FoamValue::List(items) => {
775            out.push('(');
776            for (i, it) in items.iter().enumerate() {
777                if i > 0 {
778                    out.push(' ');
779                }
780                write_value(out, it);
781            }
782            out.push(')');
783        }
784    }
785}
786
787fn write_dimensioned(out: &mut String, d: &Dimensioned) {
788    out.push('[');
789    for (i, e) in d.dims.iter().enumerate() {
790        if i > 0 {
791            out.push(' ');
792        }
793        out.push_str(&fmt_scalar(*e));
794    }
795    out.push(']');
796    for v in &d.value {
797        out.push(' ');
798        write_value(out, v);
799    }
800}
801
802fn indent(out: &mut String, level: usize) {
803    for _ in 0..level {
804        out.push_str("    ");
805    }
806}
807
808/// Write the entries of `dict` at nesting `level` (each level = 4 spaces).
809pub(crate) fn write_dict_body(out: &mut String, dict: &FoamDict, level: usize) {
810    for (k, entry) in dict.iter() {
811        match entry {
812            FoamEntry::SubDict(sub) => {
813                indent(out, level);
814                out.push_str(k);
815                out.push('\n');
816                indent(out, level);
817                out.push_str("{\n");
818                write_dict_body(out, sub, level + 1);
819                indent(out, level);
820                out.push_str("}\n");
821            }
822            FoamEntry::Scalar(x) => {
823                indent(out, level);
824                let _ = writeln!(out, "{k:<16}{};", fmt_scalar(*x));
825            }
826            FoamEntry::Word(w) => {
827                indent(out, level);
828                let _ = writeln!(out, "{k:<16}{w};");
829            }
830            FoamEntry::Str(s) => {
831                indent(out, level);
832                let _ = writeln!(out, "{k:<16}\"{s}\";");
833            }
834            FoamEntry::Tokens(vs) => {
835                indent(out, level);
836                let _ = write!(out, "{k:<16}");
837                for (i, v) in vs.iter().enumerate() {
838                    if i > 0 {
839                        out.push(' ');
840                    }
841                    write_value(out, v);
842                }
843                out.push_str(";\n");
844            }
845            FoamEntry::List(items) => {
846                indent(out, level);
847                let _ = write!(out, "{k:<16}");
848                let mut v = String::new();
849                write_value(&mut v, &FoamValue::List(items.clone()));
850                out.push_str(&v);
851                out.push_str(";\n");
852            }
853            FoamEntry::Dimensioned(d) => {
854                indent(out, level);
855                let _ = write!(out, "{k:<16}");
856                write_dimensioned(out, d);
857                out.push_str(";\n");
858            }
859        }
860    }
861}
862
863// ───────────────────────────────────────────────────────────────────────────
864//  Tests
865// ───────────────────────────────────────────────────────────────────────────
866
867#[cfg(test)]
868mod tests {
869    use super::*;
870
871    #[test]
872    fn tokenize_glued_flag() {
873        let toks = tokenize("div(phi,U) (0 0 1)");
874        // div ( phi,U ) ( 0 0 1 )
875        assert_eq!(toks[0].text, "div");
876        assert_eq!(toks[1].text, "(");
877        assert!(toks[1].glued, "`(` after div should be glued");
878        // the list's opening paren is preceded by whitespace
879        let list_open = toks.iter().rposition(|t| t.text == "(").unwrap();
880        assert!(!toks[list_open].glued);
881    }
882
883    #[test]
884    fn parse_function_keyword() {
885        let f = FoamFile::parse("divSchemes { div(phi,U) Gauss linear; }").unwrap();
886        let ds = f.dict.get_dict("divSchemes").unwrap();
887        match ds.get("div(phi,U)").unwrap() {
888            FoamEntry::Tokens(v) => {
889                assert_eq!(v[0], FoamValue::Word("Gauss".into()));
890                assert_eq!(v[1], FoamValue::Word("linear".into()));
891            }
892            other => panic!("expected Tokens, got {other:?}"),
893        }
894    }
895
896    #[test]
897    fn dimensioned_entry() {
898        let f = FoamFile::parse("nu [0 2 -1 0 0 0 0] 1e-05;").unwrap();
899        match f.dict.get("nu").unwrap() {
900            FoamEntry::Dimensioned(d) => {
901                assert_eq!(d.dims, [0.0, 2.0, -1.0, 0.0, 0.0, 0.0, 0.0]);
902                assert_eq!(d.value, vec![FoamValue::Scalar(1e-5)]);
903            }
904            other => panic!("expected Dimensioned, got {other:?}"),
905        }
906    }
907}