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 let object_type = inner[..bracket_open].trim().trim_end_matches(',').trim();
304 if object_type.is_empty() {
305 return Err(self.err("legacy DATA header has no object type"));
306 }
307 let fields = split_fields(&inner[bracket_open + 1..bracket_close]);
308 if fields.is_empty() {
309 return Err(self.err("empty field list"));
310 }
311 let mut csv = false;
312 let mut create = None;
313 for arg in inner[bracket_close + 1..].split(',') {
314 let arg = arg.trim();
315 if arg.is_empty() {
316 continue;
317 }
318 match arg.to_ascii_uppercase().as_str() {
319 "AUXCSV" | "CSV" | "CSVAUX" => csv = true,
320 "AUXDEF" | "DEF" => {}
321 "YES" | "NO" | "PROMPT" => create = Some(arg.to_ascii_uppercase()),
322 other => {
323 return Err(self.err(format!("unknown DATA header argument {other:?}")));
324 }
325 }
326 }
327 Ok((object_type.to_string(), data_name, fields, csv, create))
328 } else {
329 let mut words = before.split_whitespace();
331 let object_type = words
332 .next()
333 .ok_or_else(|| self.err("concise header has no object type"))?
334 .to_string();
335 let data_name = words.next().map(str::to_string);
336 if words.next().is_some() {
337 return Err(self.err("concise header has more than two words before `(`"));
338 }
339 let fields = split_fields(inner);
340 if fields.is_empty() {
341 return Err(self.err("empty field list"));
342 }
343 Ok((object_type, data_name, fields, false, None))
344 }
345 }
346
347 fn expect_open_brace(&mut self) -> Result<()> {
348 loop {
349 let Some(line) = self.next_line() else {
350 return Err(self.err("DATA section with no `{`"));
351 };
352 let t = strip_comment(line).trim();
353 if t == "{" {
354 return Ok(());
355 }
356 if !t.is_empty() {
357 return Err(self.err(format!("expected `{{` after DATA header, found {t:?}")));
358 }
359 }
360 }
361
362 fn body(&mut self, fields: &[String], csv: bool) -> Result<Vec<AuxRow>> {
366 let mut rows: Vec<AuxRow> = Vec::new();
367 let mut pending: Vec<String> = Vec::new();
368 loop {
369 let Some(line) = self.next_line() else {
370 return Err(self.err("unterminated DATA section (no closing `}`)"));
371 };
372 let trimmed = line.trim();
373 if trimmed == "}" {
374 if !pending.is_empty() {
375 return Err(self.err(format!(
376 "row ended with {} of {} values at the closing brace",
377 pending.len(),
378 fields.len()
379 )));
380 }
381 return Ok(rows);
382 }
383 if let Some(name) = subdata_open(trimmed) {
384 if !pending.is_empty() {
385 return Err(self.err(format!(
386 "SUBDATA after an incomplete row ({} of {} values)",
387 pending.len(),
388 fields.len()
389 )));
390 }
391 let subdata = self.subdata(name)?;
392 let Some(row) = rows.last_mut() else {
393 return Err(self.err("SUBDATA before any value row"));
394 };
395 row.subdata.push(subdata);
396 continue;
397 }
398 let stripped = strip_comment(line).trim();
399 if stripped.is_empty() {
400 continue;
401 }
402 split_values_into(stripped, csv, &mut pending);
403 if pending.len() > fields.len() {
404 return Err(self.err(format!(
405 "row has {} values for {} declared fields",
406 pending.len(),
407 fields.len()
408 )));
409 }
410 if pending.len() == fields.len() {
411 rows.push(AuxRow {
412 values: std::mem::take(&mut pending),
413 subdata: Vec::new(),
414 });
415 }
416 }
417 }
418
419 fn subdata(&mut self, name: &str) -> Result<AuxSubData> {
421 let mut lines = Vec::new();
422 loop {
423 let Some(line) = self.next_line() else {
424 return Err(self.err(format!("unterminated SUBDATA {name}")));
425 };
426 if line.trim().eq_ignore_ascii_case("</SUBDATA>") {
427 return Ok(AuxSubData {
428 name: name.to_string(),
429 lines,
430 });
431 }
432 lines.push(line.to_string());
433 }
434 }
435}
436
437fn subdata_open(line: &str) -> Option<&str> {
439 let rest = line.strip_prefix("<SUBDATA")?;
440 let rest = rest.strip_suffix('>')?;
441 let name = rest.trim();
442 (!name.is_empty()).then_some(name)
443}
444
445fn first_word_is(text: &str, word: &str) -> bool {
447 text.get(..word.len())
451 .is_some_and(|head| head.eq_ignore_ascii_case(word))
452 && !text[word.len()..]
453 .chars()
454 .next()
455 .is_some_and(|c| c.is_alphanumeric() || c == '_')
456}
457
458fn strip_comment(line: &str) -> &str {
460 let bytes = line.as_bytes();
461 let mut in_quote = false;
462 for i in 0..bytes.len() {
463 match bytes[i] {
464 b'"' => in_quote = !in_quote,
465 b'/' if !in_quote && bytes.get(i + 1) == Some(&b'/') => return &line[..i],
466 _ => {}
467 }
468 }
469 line
470}
471
472fn split_fields(text: &str) -> Vec<String> {
475 text.split(',')
476 .map(str::trim)
477 .filter(|f| !f.is_empty())
478 .map(str::to_string)
479 .collect()
480}
481
482fn split_values_into(line: &str, csv: bool, out: &mut Vec<String>) {
486 if csv {
487 let mut start = 0;
490 let mut in_quote = false;
491 let bytes = line.as_bytes();
492 for i in 0..=bytes.len() {
493 let at_end = i == bytes.len();
494 if at_end || (bytes[i] == b',' && !in_quote) {
495 let piece = line[start..i].trim();
496 let value = piece
497 .strip_prefix('"')
498 .and_then(|p| p.strip_suffix('"'))
499 .unwrap_or(piece);
500 out.push(value.to_string());
501 start = i + 1;
502 } else if bytes[i] == b'"' {
503 in_quote = !in_quote;
504 }
505 }
506 return;
507 }
508 let mut cur = String::new();
509 let mut in_quote = false;
510 let mut started = false; for c in line.chars() {
512 match c {
513 '"' => {
514 in_quote = !in_quote;
515 started = true;
516 }
517 c if c.is_whitespace() && !in_quote => {
518 if started {
519 out.push(std::mem::take(&mut cur));
520 started = false;
521 }
522 }
523 c => {
524 cur.push(c);
525 started = true;
526 }
527 }
528 }
529 if started {
530 out.push(cur);
531 }
532}
533
534#[must_use]
540pub fn write_aux(file: &AuxFile) -> String {
541 let mut s = String::new();
542 for section in &file.sections {
543 match section {
544 AuxSection::Data(d) => write_object(&mut s, d),
545 AuxSection::Script(sc) => {
546 match &sc.name {
547 Some(name) => {
548 let _ = writeln!(s, "SCRIPT {name}");
549 }
550 None => s.push_str("SCRIPT\n"),
551 }
552 s.push_str("{\n");
553 for line in &sc.lines {
554 s.push_str(line);
555 s.push('\n');
556 }
557 s.push_str("}\n\n");
558 }
559 }
560 }
561 s
562}
563
564fn write_object(s: &mut String, d: &AuxObject) {
565 match &d.data_name {
567 Some(name) => {
568 let _ = write!(s, "DATA {name}");
569 }
570 None => s.push_str("DATA "),
571 }
572 let _ = write!(s, "({}, [{}]", d.object_type, d.fields.join(", "));
573 if let Some(create) = &d.create_if_not_found {
574 let _ = write!(s, ", AUXDEF, {create}");
575 }
576 s.push_str(")\n{\n");
577 for row in &d.rows {
578 s.push_str(" ");
579 for (i, v) in row.values.iter().enumerate() {
580 if i > 0 {
581 s.push(' ');
582 }
583 push_value(s, v);
584 }
585 s.push('\n');
586 for sub in &row.subdata {
587 let _ = writeln!(s, " <SUBDATA {}>", sub.name);
588 for line in &sub.lines {
589 s.push_str(line);
590 s.push('\n');
591 }
592 s.push_str(" </SUBDATA>\n");
593 }
594 }
595 s.push_str("}\n\n");
596}
597
598fn push_value(s: &mut String, v: &str) {
604 let needs_quotes = v.is_empty()
605 || v.contains(char::is_whitespace)
606 || v.contains(',')
607 || v.contains("//")
608 || v.contains('"');
609 if needs_quotes {
610 s.push('"');
611 for ch in v.chars() {
612 s.push(if ch == '"' { ' ' } else { ch });
613 }
614 s.push('"');
615 } else {
616 s.push_str(v);
617 }
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623
624 #[test]
625 fn strip_comment_keeps_double_slash_inside_quotes() {
626 assert_eq!(
627 strip_comment(r#"1 "http://example" // trailing"#),
628 r#"1 "http://example" "#
629 );
630 }
631
632 #[test]
633 fn csv_split_keeps_quoted_commas_and_empty_values() {
634 let mut out = Vec::new();
635 split_values_into(r#""a,b", "", plain"#, true, &mut out);
636 assert_eq!(out, vec!["a,b", "", "plain"]);
637 }
638
639 #[test]
640 fn whitespace_split_keeps_quoted_comment_marker() {
641 let mut out = Vec::new();
642 split_values_into(r#"one "two // three" four"#, false, &mut out);
643 assert_eq!(out, vec!["one", "two // three", "four"]);
644 }
645}