powerio/format/powerworld/
auxiliary.rs1use std::fmt::Write as _;
20
21use crate::{Error, Result};
22
23const FMT: &str = "PowerWorld .aux";
24
25#[derive(Debug, Clone, PartialEq)]
27pub struct AuxFile {
28 pub sections: Vec<AuxSection>,
29}
30
31impl AuxFile {
32 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 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#[derive(Debug, Clone, PartialEq)]
51pub enum AuxSection {
52 Data(AuxObject),
53 Script(AuxScript),
54}
55
56#[derive(Debug, Clone, PartialEq)]
58pub struct AuxScript {
59 pub name: Option<String>,
60 pub lines: Vec<String>,
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct AuxObject {
67 pub object_type: String,
68 pub data_name: Option<String>,
70 pub fields: Vec<String>,
72 pub create_if_not_found: Option<String>,
75 pub rows: Vec<AuxRow>,
76}
77
78impl AuxObject {
79 #[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#[derive(Debug, Clone, PartialEq, Default)]
90pub struct AuxRow {
91 pub values: Vec<String>,
93 pub subdata: Vec<AuxSubData>,
94}
95
96#[derive(Debug, Clone, PartialEq)]
100pub struct AuxSubData {
101 pub name: String,
102 pub lines: Vec<String>,
103}
104
105pub 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 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 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 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 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 #[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 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 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 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 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
440fn 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
448fn first_word_is(text: &str, word: &str) -> bool {
450 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
461fn 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
475fn 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
485fn split_values_into(line: &str, csv: bool, out: &mut Vec<String>) {
489 if csv {
490 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 let mut cur = String::with_capacity(32);
516 let mut in_quote = false;
517 let mut started = false; 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#[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 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
606fn 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 assert!(parse_aux("DATA foo(] x [)").is_err());
659 }
660}