powerio_tx/format/powerworld/
auxiliary.rs1use std::borrow::Cow;
20use std::fmt::Write as _;
21
22use crate::{Error, Result};
23
24const FMT: &str = "PowerWorld .aux";
25
26#[derive(Debug, Clone, PartialEq)]
28pub struct AuxFile<'a> {
29 pub sections: Vec<AuxSection<'a>>,
30}
31
32impl<'a> AuxFile<'a> {
33 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 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#[derive(Debug, Clone, PartialEq)]
52pub enum AuxSection<'a> {
53 Data(AuxObject<'a>),
54 Script(AuxScript<'a>),
55}
56
57#[derive(Debug, Clone, PartialEq)]
59pub struct AuxScript<'a> {
60 pub name: Option<String>,
61 pub lines: Vec<&'a str>,
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct AuxObject<'a> {
69 pub object_type: String,
70 pub data_name: Option<String>,
72 pub fields: Vec<String>,
74 pub create_if_not_found: Option<String>,
77 pub rows: Vec<AuxRow<'a>>,
78}
79
80impl AuxObject<'_> {
81 #[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#[derive(Debug, Clone, PartialEq, Default)]
92pub struct AuxRow<'a> {
93 pub values: Vec<Cow<'a, str>>,
97 pub subdata: Vec<AuxSubData<'a>>,
98}
99
100#[derive(Debug, Clone, PartialEq)]
104pub struct AuxSubData<'a> {
105 pub name: &'a str,
106 pub lines: Vec<&'a str>,
107}
108
109pub 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 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 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 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 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 #[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 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 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 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 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
441fn 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
449fn first_word_is(text: &str, word: &str) -> bool {
451 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
462fn 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
476fn 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
486fn split_values_into<'a>(line: &'a str, csv: bool, out: &mut Vec<Cow<'a, str>>) {
492 if csv {
493 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 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#[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 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
616fn 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 assert!(parse_aux("DATA foo(] x [)").is_err());
669 }
670}