Skip to main content

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