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}