Skip to main content

powerio/format/powerworld/
auxiliary.rs

1//! The generic auxiliary file grammar: parse any `.aux` into [`AuxFile`] and
2//! serialize it back.
3//!
4//! This layer knows the file format and nothing about power systems. The
5//! grammar follows the official guide ("Auxiliary File Format for Simulator
6//! 24", PowerWorld Corporation): a file is a sequence of `DATA` and `SCRIPT`
7//! sections; both the legacy header (`DATA Name(Object, [fields], CSV, NO)`)
8//! and the concise header (`Object Name(fields)`) are read; field lists and
9//! value rows may span lines; `//` starts a comment anywhere outside quotes;
10//! `<SUBDATA Type> ... </SUBDATA>` blocks attach to the value row above them
11//! and their interior lines are kept verbatim.
12//!
13//! [`write_aux`] emits a canonical form: legacy headers, space delimited
14//! values, one row per line. Canonical output is idempotent (parsing it and
15//! writing again reproduces it byte for byte) but does not preserve the
16//! source's whitespace or comments; the byte exact same format round trip
17//! comes from the retained source (see [`crate::write_as`]).
18
19use std::fmt::Write as _;
20
21use crate::{Error, Result};
22
23const FMT: &str = "PowerWorld .aux";
24
25/// A parsed auxiliary file: the ordered `DATA` and `SCRIPT` sections.
26#[derive(Debug, Clone, PartialEq)]
27pub struct AuxFile {
28    pub sections: Vec<AuxSection>,
29}
30
31impl AuxFile {
32    /// The `DATA` sections, in file order.
33    pub fn data(&self) -> impl Iterator<Item = &AuxObject> {
34        self.sections.iter().filter_map(|s| match s {
35            AuxSection::Data(d) => Some(d),
36            AuxSection::Script(_) => None,
37        })
38    }
39
40    /// The `DATA` sections for one object type (a type may appear more than
41    /// once with different field lists; ACTIVSg exports carry two `Branch`
42    /// blocks, lines and transformers).
43    pub fn data_of<'a>(&'a self, object_type: &'a str) -> impl Iterator<Item = &'a AuxObject> {
44        self.data()
45            .filter(move |d| d.object_type.eq_ignore_ascii_case(object_type))
46    }
47}
48
49/// One section of an auxiliary file.
50#[derive(Debug, Clone, PartialEq)]
51pub enum AuxSection {
52    Data(AuxObject),
53    Script(AuxScript),
54}
55
56/// A `SCRIPT` section, retained verbatim: powerio executes nothing.
57#[derive(Debug, Clone, PartialEq)]
58pub struct AuxScript {
59    pub name: Option<String>,
60    /// Body lines between the braces, byte for byte.
61    pub lines: Vec<String>,
62}
63
64/// One `DATA` section: an object type, its declared field list, and the rows.
65#[derive(Debug, Clone, PartialEq)]
66pub struct AuxObject {
67    pub object_type: String,
68    /// Optional section name (callable from `LoadData` scripts).
69    pub data_name: Option<String>,
70    /// Declared fields, in order, location suffixes preserved (`BusNum:1`).
71    pub fields: Vec<String>,
72    /// `CREATE_IF_NOT_FOUND` argument when the header carried one
73    /// (`YES`/`NO`/`PROMPT`).
74    pub create_if_not_found: Option<String>,
75    pub rows: Vec<AuxRow>,
76}
77
78impl AuxObject {
79    /// Position of `field` in the declared field list (case insensitive).
80    #[must_use]
81    pub fn field_index(&self, field: &str) -> Option<usize> {
82        self.fields
83            .iter()
84            .position(|f| f.eq_ignore_ascii_case(field))
85    }
86}
87
88/// One value row of a `DATA` section, with any `SUBDATA` blocks that follow it.
89#[derive(Debug, Clone, PartialEq, Default)]
90pub struct AuxRow {
91    /// One value per declared field, quotes removed.
92    pub values: Vec<String>,
93    pub subdata: Vec<AuxSubData>,
94}
95
96/// A `<SUBDATA Type> ... </SUBDATA>` block. The interior format is fixed per
97/// subobject type (some are free text, some are per line records), so the
98/// lines are kept verbatim.
99#[derive(Debug, Clone, PartialEq)]
100pub struct AuxSubData {
101    pub name: String,
102    pub lines: Vec<String>,
103}
104
105// ---- Parser -----------------------------------------------------------------
106
107/// Parse auxiliary file `text` into an [`AuxFile`].
108///
109/// # Errors
110/// [`Error::FormatRead`] with the line number on malformed input: an
111/// unterminated section, a row with more values than declared fields, a row cut
112/// short at the closing brace, `SUBDATA` with no owning row, or an unknown
113/// file type specifier.
114pub fn parse_aux(text: &str) -> Result<AuxFile> {
115    Parser {
116        lines: text.lines().collect(),
117        pos: 0,
118    }
119    .parse()
120}
121
122struct Parser<'a> {
123    lines: Vec<&'a str>,
124    pos: usize,
125}
126
127impl<'a> Parser<'a> {
128    fn parse(mut self) -> Result<AuxFile> {
129        let mut sections = Vec::new();
130        while let Some(line) = self.peek_content() {
131            if first_word_is(line, "SCRIPT") {
132                sections.push(AuxSection::Script(self.script()?));
133            } else {
134                sections.push(AuxSection::Data(self.data()?));
135            }
136        }
137        Ok(AuxFile { sections })
138    }
139
140    /// The next line with content after comment stripping, without consuming
141    /// it. Skips blank and comment lines.
142    fn peek_content(&mut self) -> Option<&'a str> {
143        while self.pos < self.lines.len() {
144            let stripped = strip_comment(self.lines[self.pos]).trim();
145            if !stripped.is_empty() {
146                return Some(stripped);
147            }
148            self.pos += 1;
149        }
150        None
151    }
152
153    fn err(&self, message: impl Into<String>) -> Error {
154        Error::FormatRead {
155            format: FMT,
156            message: format!(
157                "line {}: {}",
158                self.pos.min(self.lines.len()),
159                message.into()
160            ),
161        }
162    }
163
164    /// Consume a `SCRIPT Name { ... }` section, body verbatim.
165    fn script(&mut self) -> Result<AuxScript> {
166        let header = strip_comment(self.lines[self.pos]).trim().to_string();
167        self.pos += 1;
168        let mut rest = header["SCRIPT".len()..].trim();
169        let brace_in_header = rest.ends_with('{');
170        if brace_in_header {
171            rest = rest[..rest.len() - 1].trim();
172        }
173        let name = (!rest.is_empty()).then(|| rest.to_string());
174        if !brace_in_header {
175            loop {
176                let Some(line) = self.next_line() else {
177                    return Err(self.err("SCRIPT section with no `{`"));
178                };
179                let t = strip_comment(line).trim();
180                if t == "{" {
181                    break;
182                }
183                if !t.is_empty() {
184                    return Err(self.err("expected `{` after SCRIPT header"));
185                }
186            }
187        }
188        let mut lines = Vec::new();
189        loop {
190            let Some(line) = self.next_line() else {
191                return Err(self.err("unterminated SCRIPT section"));
192            };
193            if line.trim() == "}" {
194                return Ok(AuxScript { name, lines });
195            }
196            lines.push(line.to_string());
197        }
198    }
199
200    fn next_line(&mut self) -> Option<&'a str> {
201        let line = self.lines.get(self.pos).copied();
202        if line.is_some() {
203            self.pos += 1;
204        }
205        line
206    }
207
208    /// Consume a `DATA` section, legacy or concise header.
209    fn data(&mut self) -> Result<AuxObject> {
210        let header = self.header_text()?;
211        let close = header
212            .rfind(')')
213            .ok_or_else(|| self.err("header has no `)`"))?;
214        let brace_in_header = match header[close + 1..].trim() {
215            "" => false,
216            "{" => true,
217            other => {
218                return Err(self.err(format!("unexpected text after section header: {other:?}")));
219            }
220        };
221        let (object_type, data_name, fields, csv, create_if_not_found) =
222            self.split_header(&header[..=close])?;
223        if !brace_in_header {
224            self.expect_open_brace()?;
225        }
226        let rows = self.body(&fields, csv)?;
227        Ok(AuxObject {
228            object_type,
229            data_name,
230            fields,
231            create_if_not_found,
232            rows,
233        })
234    }
235
236    /// Accumulate header lines (comments stripped) until the parentheses
237    /// balance.
238    fn header_text(&mut self) -> Result<String> {
239        let start = self.pos;
240        let mut text = String::new();
241        let mut depth = 0i32;
242        let mut opened = false;
243        while let Some(line) = self.next_line() {
244            let stripped = strip_comment(line).trim();
245            if !text.is_empty() && !stripped.is_empty() {
246                text.push(' ');
247            }
248            text.push_str(stripped);
249            let mut in_quote = false;
250            for c in stripped.chars() {
251                match c {
252                    '"' => in_quote = !in_quote,
253                    '(' if !in_quote => {
254                        depth += 1;
255                        opened = true;
256                    }
257                    ')' if !in_quote => depth -= 1,
258                    _ => {}
259                }
260            }
261            if opened && depth == 0 {
262                return Ok(text);
263            }
264            if self.pos - start > 200 {
265                break;
266            }
267        }
268        Err(self.err("unterminated section header (unbalanced parentheses)"))
269    }
270
271    /// Split a balanced header into its parts. Legacy form:
272    /// `DATA Name(Object, [fields], specifier, create)`. Concise form:
273    /// `Object Name(fields)`.
274    #[allow(clippy::type_complexity)]
275    fn split_header(
276        &self,
277        header: &str,
278    ) -> Result<(String, Option<String>, Vec<String>, bool, Option<String>)> {
279        let open = header
280            .find('(')
281            .ok_or_else(|| self.err("header has no `(`"))?;
282        let close = header
283            .rfind(')')
284            .ok_or_else(|| self.err("header has no `)`"))?;
285        if close <= open {
286            return Err(self.err("header `)` precedes `(`"));
287        }
288        let before = header[..open].trim();
289        let inner = &header[open + 1..close];
290        let legacy = first_word_is(before, "DATA");
291
292        if legacy {
293            let data_name = before["DATA".len()..].trim();
294            let data_name = (!data_name.is_empty()).then(|| data_name.to_string());
295            // Object type, then `[fields]`, then optional specifier and
296            // create_if_not_found.
297            let bracket_open = inner
298                .find('[')
299                .ok_or_else(|| self.err("legacy DATA header has no `[fields]` list"))?;
300            let bracket_close = inner
301                .rfind(']')
302                .ok_or_else(|| self.err("legacy DATA header has no closing `]`"))?;
303            if bracket_close <= bracket_open {
304                return Err(self.err("legacy DATA header `]` precedes `[`"));
305            }
306            let object_type = inner[..bracket_open].trim().trim_end_matches(',').trim();
307            if object_type.is_empty() {
308                return Err(self.err("legacy DATA header has no object type"));
309            }
310            let fields = split_fields(&inner[bracket_open + 1..bracket_close]);
311            if fields.is_empty() {
312                return Err(self.err("empty field list"));
313            }
314            let mut csv = false;
315            let mut create = None;
316            for arg in inner[bracket_close + 1..].split(',') {
317                let arg = arg.trim();
318                if arg.is_empty() {
319                    continue;
320                }
321                match arg.to_ascii_uppercase().as_str() {
322                    "AUXCSV" | "CSV" | "CSVAUX" => csv = true,
323                    "AUXDEF" | "DEF" => {}
324                    "YES" | "NO" | "PROMPT" => create = Some(arg.to_ascii_uppercase()),
325                    other => {
326                        return Err(self.err(format!("unknown DATA header argument {other:?}")));
327                    }
328                }
329            }
330            Ok((object_type.to_string(), data_name, fields, csv, create))
331        } else {
332            // Concise: `object_type [DataName](fields)`, always space delimited.
333            let mut words = before.split_whitespace();
334            let object_type = words
335                .next()
336                .ok_or_else(|| self.err("concise header has no object type"))?
337                .to_string();
338            let data_name = words.next().map(str::to_string);
339            if words.next().is_some() {
340                return Err(self.err("concise header has more than two words before `(`"));
341            }
342            let fields = split_fields(inner);
343            if fields.is_empty() {
344                return Err(self.err("empty field list"));
345            }
346            Ok((object_type, data_name, fields, false, None))
347        }
348    }
349
350    fn expect_open_brace(&mut self) -> Result<()> {
351        loop {
352            let Some(line) = self.next_line() else {
353                return Err(self.err("DATA section with no `{`"));
354            };
355            let t = strip_comment(line).trim();
356            if t == "{" {
357                return Ok(());
358            }
359            if !t.is_empty() {
360                return Err(self.err(format!("expected `{{` after DATA header, found {t:?}")));
361            }
362        }
363    }
364
365    /// Parse the value rows between the braces. A row may span lines; it is
366    /// complete when it has one value per declared field. `SUBDATA` blocks
367    /// attach to the row above them.
368    fn body(&mut self, fields: &[String], csv: bool) -> Result<Vec<AuxRow>> {
369        let mut rows: Vec<AuxRow> = Vec::new();
370        let mut pending: Vec<String> = Vec::new();
371        loop {
372            let Some(line) = self.next_line() else {
373                return Err(self.err("unterminated DATA section (no closing `}`)"));
374            };
375            let trimmed = line.trim();
376            if trimmed == "}" {
377                if !pending.is_empty() {
378                    return Err(self.err(format!(
379                        "row ended with {} of {} values at the closing brace",
380                        pending.len(),
381                        fields.len()
382                    )));
383                }
384                return Ok(rows);
385            }
386            if let Some(name) = subdata_open(trimmed) {
387                if !pending.is_empty() {
388                    return Err(self.err(format!(
389                        "SUBDATA after an incomplete row ({} of {} values)",
390                        pending.len(),
391                        fields.len()
392                    )));
393                }
394                let subdata = self.subdata(name)?;
395                let Some(row) = rows.last_mut() else {
396                    return Err(self.err("SUBDATA before any value row"));
397                };
398                row.subdata.push(subdata);
399                continue;
400            }
401            let stripped = strip_comment(line).trim();
402            if stripped.is_empty() {
403                continue;
404            }
405            split_values_into(stripped, csv, &mut pending);
406            if pending.len() > fields.len() {
407                return Err(self.err(format!(
408                    "row has {} values for {} declared fields",
409                    pending.len(),
410                    fields.len()
411                )));
412            }
413            if pending.len() == fields.len() {
414                rows.push(AuxRow {
415                    values: std::mem::take(&mut pending),
416                    subdata: Vec::new(),
417                });
418            }
419        }
420    }
421
422    /// Collect a `<SUBDATA name>` block's interior verbatim.
423    fn subdata(&mut self, name: &str) -> Result<AuxSubData> {
424        let mut lines = Vec::new();
425        loop {
426            let Some(line) = self.next_line() else {
427                return Err(self.err(format!("unterminated SUBDATA {name}")));
428            };
429            if line.trim().eq_ignore_ascii_case("</SUBDATA>") {
430                return Ok(AuxSubData {
431                    name: name.to_string(),
432                    lines,
433                });
434            }
435            lines.push(line.to_string());
436        }
437    }
438}
439
440/// The `<SUBDATA name>` opener's name, if `line` is one.
441fn subdata_open(line: &str) -> Option<&str> {
442    let rest = line.strip_prefix("<SUBDATA")?;
443    let rest = rest.strip_suffix('>')?;
444    let name = rest.trim();
445    (!name.is_empty()).then_some(name)
446}
447
448/// Does `text` start with `word` as a whole word (case insensitive)?
449fn first_word_is(text: &str, word: &str) -> bool {
450    // `get` instead of indexing: `word.len()` may land inside a multibyte
451    // character on arbitrary input text, where slicing would panic; a non
452    // boundary there correctly means the keyword is not present whole.
453    text.get(..word.len())
454        .is_some_and(|head| head.eq_ignore_ascii_case(word))
455        && !text[word.len()..]
456            .chars()
457            .next()
458            .is_some_and(|c| c.is_alphanumeric() || c == '_')
459}
460
461/// Truncate `line` at the first `//` outside quotes.
462fn strip_comment(line: &str) -> &str {
463    let bytes = line.as_bytes();
464    let mut in_quote = false;
465    for i in 0..bytes.len() {
466        match bytes[i] {
467            b'"' => in_quote = !in_quote,
468            b'/' if !in_quote && bytes.get(i + 1) == Some(&b'/') => return &line[..i],
469            _ => {}
470        }
471    }
472    line
473}
474
475/// Split a field list on commas, trimming each name. Empty entries (a trailing
476/// comma before a line break) are dropped.
477fn split_fields(text: &str) -> Vec<String> {
478    text.split(',')
479        .map(str::trim)
480        .filter(|f| !f.is_empty())
481        .map(str::to_string)
482        .collect()
483}
484
485/// Append the values on one line to `out`. Space delimited unless `csv`;
486/// quoted strings keep their interior (including embedded spaces and commas)
487/// and an empty quoted token (`""`) is preserved as an empty value.
488fn split_values_into(line: &str, csv: bool, out: &mut Vec<String>) {
489    if csv {
490        // Split on top-level commas, then unquote each piece. Whitespace
491        // around a piece is insignificant; the quoted interior is verbatim.
492        let mut start = 0;
493        let mut in_quote = false;
494        let bytes = line.as_bytes();
495        for i in 0..=bytes.len() {
496            let at_end = i == bytes.len();
497            if at_end || (bytes[i] == b',' && !in_quote) {
498                let piece = line[start..i].trim();
499                let value = piece
500                    .strip_prefix('"')
501                    .and_then(|p| p.strip_suffix('"'))
502                    .unwrap_or(piece);
503                out.push(value.to_string());
504                start = i + 1;
505            } else if bytes[i] == b'"' {
506                in_quote = !in_quote;
507            }
508        }
509        return;
510    }
511    // Keep `cur`'s capacity across tokens. Taking the buffer handed it away,
512    // so every token regrew from empty through the 8/16/32 realloc chain;
513    // cloning costs one exact-size allocation and leaves the scratch buffer
514    // sized for the next token.
515    let mut cur = String::with_capacity(32);
516    let mut in_quote = false;
517    let mut started = false; // a token has begun, including an empty quoted one
518    for c in line.chars() {
519        match c {
520            '"' => {
521                in_quote = !in_quote;
522                started = true;
523            }
524            c if c.is_whitespace() && !in_quote => {
525                if started {
526                    out.push(cur.clone());
527                    cur.clear();
528                    started = false;
529                }
530            }
531            c => {
532                cur.push(c);
533                started = true;
534            }
535        }
536    }
537    if started {
538        out.push(cur);
539    }
540}
541
542// ---- Canonical writer -------------------------------------------------------
543
544/// Serialize an [`AuxFile`] in canonical form: legacy headers, space delimited
545/// values, one row per line, two space indentation. Idempotent under
546/// `parse_aux`.
547#[must_use]
548pub fn write_aux(file: &AuxFile) -> String {
549    let mut s = String::new();
550    for section in &file.sections {
551        match section {
552            AuxSection::Data(d) => write_object(&mut s, d),
553            AuxSection::Script(sc) => {
554                match &sc.name {
555                    Some(name) => {
556                        let _ = writeln!(s, "SCRIPT {name}");
557                    }
558                    None => s.push_str("SCRIPT\n"),
559                }
560                s.push_str("{\n");
561                for line in &sc.lines {
562                    s.push_str(line);
563                    s.push('\n');
564                }
565                s.push_str("}\n\n");
566            }
567        }
568    }
569    s
570}
571
572fn write_object(s: &mut String, d: &AuxObject) {
573    // Legacy syntax puts the optional section name between DATA and `(`.
574    match &d.data_name {
575        Some(name) => {
576            let _ = write!(s, "DATA {name}");
577        }
578        None => s.push_str("DATA "),
579    }
580    let _ = write!(s, "({}, [{}]", d.object_type, d.fields.join(", "));
581    if let Some(create) = &d.create_if_not_found {
582        let _ = write!(s, ", AUXDEF, {create}");
583    }
584    s.push_str(")\n{\n");
585    for row in &d.rows {
586        s.push_str("  ");
587        for (i, v) in row.values.iter().enumerate() {
588            if i > 0 {
589                s.push(' ');
590            }
591            push_value(s, v);
592        }
593        s.push('\n');
594        for sub in &row.subdata {
595            let _ = writeln!(s, "  <SUBDATA {}>", sub.name);
596            for line in &sub.lines {
597                s.push_str(line);
598                s.push('\n');
599            }
600            s.push_str("  </SUBDATA>\n");
601        }
602    }
603    s.push_str("}\n\n");
604}
605
606/// Write one value, quoting when the bare token would not survive a re-read:
607/// empty, embedded whitespace or comma, or a `//` that would read as a comment.
608/// An embedded `"` is replaced with a space before quoting: the tokenizer toggles
609/// on `"` with no un-escaping, so a literal quote would close the field early and
610/// shift every later column.
611fn push_value(s: &mut String, v: &str) {
612    let needs_quotes = v.is_empty()
613        || v.contains(char::is_whitespace)
614        || v.contains(',')
615        || v.contains("//")
616        || v.contains('"');
617    if needs_quotes {
618        s.push('"');
619        for ch in v.chars() {
620            s.push(if ch == '"' { ' ' } else { ch });
621        }
622        s.push('"');
623    } else {
624        s.push_str(v);
625    }
626}
627
628#[cfg(test)]
629mod tests {
630    use super::*;
631
632    #[test]
633    fn strip_comment_keeps_double_slash_inside_quotes() {
634        assert_eq!(
635            strip_comment(r#"1 "http://example" // trailing"#),
636            r#"1 "http://example" "#
637        );
638    }
639
640    #[test]
641    fn csv_split_keeps_quoted_commas_and_empty_values() {
642        let mut out = Vec::new();
643        split_values_into(r#""a,b", "", plain"#, true, &mut out);
644        assert_eq!(out, vec!["a,b", "", "plain"]);
645    }
646
647    #[test]
648    fn whitespace_split_keeps_quoted_comment_marker() {
649        let mut out = Vec::new();
650        split_values_into(r#"one "two // three" four"#, false, &mut out);
651        assert_eq!(out, vec!["one", "two // three", "four"]);
652    }
653
654    #[test]
655    fn legacy_data_header_reversed_brackets_errs_without_panic() {
656        // `]` before `[` in the field list must be a structured error, not a
657        // slice-index panic on inner[bracket_open + 1..bracket_close].
658        assert!(parse_aux("DATA foo(] x [)").is_err());
659    }
660}