1use std::collections::BTreeMap;
11use std::path::{Path, PathBuf};
12
13use super::lex::{Scanner, Value, VarMap};
14use super::prop::{self, DssClass};
15use crate::error::{Error, Result};
16
17static COMMANDS: &[&str] = &[
23 "new",
24 "edit",
25 "more",
26 "m",
27 "~",
28 "select",
29 "save",
30 "show",
31 "solve",
32 "enable",
33 "disable",
34 "plot",
35 "reset",
36 "compile",
37 "set",
38 "dump",
39 "open",
40 "close",
41 "//",
42 "redirect",
43 "help",
44 "quit",
45 "?",
46 "next",
47 "panel",
48 "sample",
49 "clear",
50 "about",
51 "calcvoltagebases",
52 "setkvbase",
53 "buildy",
54 "get",
55 "init",
56 "export",
57 "fileedit",
58 "voltages",
59 "currents",
60 "powers",
61 "seqvoltages",
62 "seqcurrents",
63 "seqpowers",
64 "losses",
65 "phaselosses",
66 "cktlosses",
67 "allocateloads",
68 "formedit",
69 "totals",
70 "capacity",
71 "classes",
72 "userclasses",
73 "zsc",
74 "zsc10",
75 "zscrefresh",
76 "ysc",
77 "puvoltages",
78 "varvalues",
79 "varnames",
80 "buscoords",
81 "makebuslist",
82 "makeposseq",
83 "reduce",
84 "interpolate",
85 "alignfile",
86 "top",
87 "rotate",
88 "vdiff",
89 "summary",
90 "distribute",
91 "di_plot",
92 "comparecases",
93 "yearlycurves",
94 "cd",
95 "visualize",
96 "closedi",
97 "doscmd",
98 "estimate",
99 "reconductor",
100 "_initsnap",
101 "_solvenocontrol",
102 "_samplecontrols",
103 "_docontrolactions",
104 "_showcontrolqueue",
105 "_solvedirect",
106 "_solvepflow",
107 "addbusmarker",
108 "uuids",
109 "setloadandgenkv",
110 "cvrtloadshapes",
111 "nodediff",
112 "rephase",
113 "setbusxy",
114 "updatestorage",
115 "obfuscate",
116 "latlongcoords",
117 "batchedit",
118 "pstcalc",
119 "variable",
120 "reprocessbuses",
121 "clearbusmarkers",
122 "relcalc",
123 "var",
124 "cleanup",
125 "finishtimestep",
126 "nodelist",
127 "newactor",
128 "clearall",
129 "wait",
130 "solveall",
131 "calcincmatrix",
132 "calcincmatrix_o",
133 "tear_circuit",
134 "connect",
135 "disconnect",
136 "refine_buslevels",
137 "remove",
138 "abort",
139 "calclaplacian",
140 "clone",
141 "fncspublish",
142 "exportoverloads",
143 "exportvviolations",
144 "zsc012",
145 "aggregateprofiles",
146 "allpceatbus",
147 "allpdeatbus",
148 "totalpowers",
149 "comhelp",
150 "gis",
151 "giscoords",
152 "readefieldhdf",
153];
154
155fn command_index(verb: &str) -> Option<usize> {
156 let v = verb.to_ascii_lowercase();
157 COMMANDS
158 .iter()
159 .position(|c| *c == v)
160 .or_else(|| COMMANDS.iter().position(|c| c.starts_with(&v)))
161}
162
163#[derive(Clone, Debug, PartialEq)]
165pub struct RawProp {
166 pub name: Option<String>,
170 pub value: Value,
171}
172
173#[derive(Clone, Debug)]
176pub struct RawObject {
177 pub class: String,
179 pub name: String,
181 pub props: Vec<RawProp>,
182 pub edits: Vec<usize>,
189}
190
191impl RawObject {
192 pub fn get(&self, name: &str) -> Option<&Value> {
194 self.props
195 .iter()
196 .rev()
197 .find(|p| p.name.as_deref() == Some(name))
198 .map(|p| &p.value)
199 }
200
201 pub fn edit_bounds(&self) -> impl Iterator<Item = usize> + '_ {
205 let tail =
206 (self.edits.last().copied() != Some(self.props.len())).then_some(self.props.len());
207 self.edits.iter().copied().chain(tail)
208 }
209}
210
211#[derive(Clone, Debug, PartialEq)]
213pub struct RawCommand {
214 pub verb: String,
216 pub args: String,
218}
219
220#[derive(Clone, Debug, PartialEq)]
222pub struct BusCoord {
223 pub bus: String,
224 pub x: f64,
225 pub y: f64,
226}
227
228#[derive(Debug, Default)]
230pub struct RawDss {
231 pub circuit_name: Option<String>,
232 pub objects: Vec<RawObject>,
233 pub options: Vec<(String, Value)>,
235 pub commands: Vec<RawCommand>,
237 pub buscoords: Vec<BusCoord>,
238 pub vars: VarMap,
239 pub warnings: Vec<String>,
240 index: BTreeMap<(String, String), usize>,
241 active: Option<usize>,
242}
243
244impl RawDss {
245 pub fn find(&self, class: &str, name: &str) -> Option<&RawObject> {
246 self.index
247 .get(&(class.to_ascii_lowercase(), name.to_ascii_lowercase()))
248 .map(|&i| &self.objects[i])
249 }
250
251 pub fn of_class<'a>(&'a self, class: &'a str) -> impl Iterator<Item = &'a RawObject> {
252 self.objects.iter().filter(move |o| o.class == class)
253 }
254
255 fn warn(&mut self, msg: impl Into<String>) {
256 self.warnings.push(msg.into());
257 }
258
259 fn clear(&mut self) {
260 *self = RawDss::default();
261 }
262}
263
264pub trait Loader {
266 fn load(&mut self, path: &Path) -> std::io::Result<String>;
267}
268
269impl<F> Loader for F
270where
271 F: FnMut(&Path) -> std::io::Result<String>,
272{
273 fn load(&mut self, path: &Path) -> std::io::Result<String> {
274 self(path)
275 }
276}
277
278const MAX_REDIRECT_DEPTH: usize = 64;
280
281struct Executor<'l, L: Loader> {
282 raw: RawDss,
283 loader: &'l mut L,
284 dirs: Vec<PathBuf>,
287}
288
289fn command_lines(text: &str) -> impl Iterator<Item = (usize, &str)> {
294 let mut in_block = false;
295 text.lines().enumerate().filter_map(move |(i, line)| {
296 if in_block {
297 if line.contains("*/") {
298 in_block = false;
299 }
300 return None;
301 }
302 if line.trim_start().starts_with("/*") {
303 in_block = true;
304 if line.contains("*/") {
305 in_block = false;
306 }
307 return None;
308 }
309 Some((i + 1, line))
310 })
311}
312
313impl<L: Loader> Executor<'_, L> {
314 fn run_script(&mut self, text: &str, file: &str) {
315 for (line_no, line) in command_lines(text) {
316 self.run_command(line, file, line_no);
317 }
318 }
319
320 fn run_command(&mut self, line: &str, file: &str, line_no: usize) {
321 let vars = self.raw.vars.clone();
327 let mut scan = Scanner::new(line, Some(&vars));
328 let ctx = |msg: String| format!("{file}:{line_no}: {msg}");
329 match scan.next_param() {
330 None => {}
331 Some(first) if first.value.text.is_empty() && first.name.is_none() => {}
332 Some(first) => {
333 if let Some(name) = first.name {
334 self.edit_property_reference(&name, first.value, &mut scan, &ctx);
337 } else {
338 self.dispatch(first.value.text, &mut scan, &ctx);
339 }
340 }
341 }
342 }
343
344 fn dispatch(&mut self, verb: String, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
345 match command_index(&verb).map(|i| COMMANDS[i]) {
346 Some("new") => self.do_new(scan, ctx),
347 Some("edit") => self.do_edit(scan, ctx),
348 Some("more" | "m" | "~") => self.do_more(scan, ctx),
349 Some("select") => self.do_select(scan, ctx),
350 Some("set") => self.do_set(scan),
351 Some("redirect") => self.do_redirect(scan, false, ctx),
352 Some("compile") => self.do_redirect(scan, true, ctx),
353 Some("buscoords") => self.do_buscoords(scan, ctx),
354 Some("var") => self.do_var(scan),
355 Some("clear" | "clearall") => self.raw.clear(),
356 Some("//") => {}
357 Some(canonical) => {
358 self.raw.commands.push(RawCommand {
359 verb: canonical.to_string(),
360 args: scan.remainder().to_string(),
361 });
362 }
363 None => {
364 self.raw.warn(ctx(format!(
365 "unknown command `{verb}`; line preserved verbatim"
366 )));
367 self.raw.commands.push(RawCommand {
368 verb,
369 args: scan.remainder().to_string(),
370 });
371 }
372 }
373 }
374
375 fn do_var(&mut self, scan: &mut Scanner) {
380 while let Some(p) = scan.next_param() {
381 if p.value.text.is_empty() && p.name.is_none() {
382 break;
383 }
384 if let Some(name) = p.name {
385 let stored = if p.value.text.starts_with('@') {
386 p.value.text
387 } else {
388 format!("{{{}}}", p.value.text)
389 };
390 self.raw.vars.insert(name.to_ascii_lowercase(), stored);
391 }
392 }
393 }
394
395 fn edit_property_reference(
402 &mut self,
403 spec: &str,
404 value: Value,
405 scan: &mut Scanner,
406 ctx: &dyn Fn(String) -> String,
407 ) {
408 let (object, prop) = match spec.split_once('.') {
409 None => (None, spec),
410 Some((first, rest)) => match rest.split_once('.') {
411 None => (Some((None, first)), rest),
412 Some((name, prop)) => (Some((Some(first), name)), prop),
413 },
414 };
415 let active_or = |raw: &mut RawDss| {
416 let active = raw.active;
417 if active.is_none() {
418 raw.warn(ctx(format!("`{spec}=` with no active object")));
419 }
420 active
421 };
422 let idx = match object {
423 None => match active_or(&mut self.raw) {
424 Some(idx) => idx,
425 None => return,
426 },
427 Some((class, name)) => {
428 let class = match class {
429 Some(c) => c.to_ascii_lowercase(),
430 None => match active_or(&mut self.raw) {
431 Some(idx) => self.raw.objects[idx].class.clone(),
432 None => return,
433 },
434 };
435 if let Some(idx) = self
436 .raw
437 .index
438 .get(&(class.clone(), name.to_ascii_lowercase()))
439 .copied()
440 {
441 idx
442 } else {
443 self.raw.warn(ctx(format!(
444 "property reference to unknown object `{class}.{name}`"
445 )));
446 return;
447 }
448 }
449 };
450 self.raw.active = Some(idx);
451 let table = prop_table(&self.raw.objects[idx].class);
452 let name = match table {
453 Some(c) => {
454 if let Some(i) = c.prop_index(prop) {
455 c.props[i].to_string()
456 } else {
457 self.raw.warn(ctx(format!(
458 "unknown property `{prop}` on {}; kept as written",
459 c.name
460 )));
461 prop.to_ascii_lowercase()
462 }
463 }
464 None => prop.to_ascii_lowercase(),
465 };
466 let mut props = vec![RawProp {
467 name: Some(name),
468 value,
469 }];
470 props.extend(collect_props_for(
471 table,
472 scan,
473 Some(prop),
474 &mut self.raw.warnings,
475 ctx,
476 ));
477 self.apply_props(idx, props, ctx);
478 }
479
480 fn do_new(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
481 let Some((class, name)) = self.object_spec(scan, ctx) else {
482 return;
483 };
484 if class.eq_ignore_ascii_case("circuit") {
485 self.raw.circuit_name = Some(name);
490 let idx = self.make_object("vsource", "source".into());
491 self.consume_and_apply(idx, scan, ctx);
492 return;
493 }
494 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
495 let idx = match self.raw.index.get(&key) {
496 Some(&existing) => {
497 self.raw.warn(ctx(format!(
498 "duplicate `New {class}.{name}`; editing the existing object"
499 )));
500 existing
501 }
502 None => self.make_object(&class, name),
503 };
504 self.consume_and_apply(idx, scan, ctx);
505 }
506
507 fn do_edit(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
508 let Some((class, name)) = self.object_spec(scan, ctx) else {
509 return;
510 };
511 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
512 let Some(&idx) = self.raw.index.get(&key) else {
513 self.raw
514 .warn(ctx(format!("`Edit {class}.{name}` on an unknown object")));
515 return;
516 };
517 self.consume_and_apply(idx, scan, ctx);
518 }
519
520 fn do_more(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
521 let Some(idx) = self.raw.active else {
522 self.raw.warn(ctx("`~` with no active object".into()));
523 return;
524 };
525 self.consume_and_apply(idx, scan, ctx);
526 }
527
528 fn do_select(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
529 let Some((class, name)) = self.object_spec(scan, ctx) else {
530 return;
531 };
532 let key = (class.to_ascii_lowercase(), name.to_ascii_lowercase());
533 match self.raw.index.get(&key) {
534 Some(&idx) => self.raw.active = Some(idx),
535 None => self
536 .raw
537 .warn(ctx(format!("`Select {class}.{name}` on an unknown object"))),
538 }
539 }
540
541 fn do_set(&mut self, scan: &mut Scanner) {
542 while let Some(p) = scan.next_param() {
543 if p.value.text.is_empty() && p.name.is_none() {
544 break;
545 }
546 let name = p.name.unwrap_or_default().to_ascii_lowercase();
547 self.raw.options.push((name, p.value));
548 }
549 }
550
551 fn resolve(&self, file_arg: &str) -> PathBuf {
554 let rel = file_arg.replace('\\', "/");
555 self.dirs
556 .last()
557 .map_or_else(|| PathBuf::from(&rel), |d| d.join(&rel))
558 }
559
560 fn do_redirect(&mut self, scan: &mut Scanner, compile: bool, ctx: &dyn Fn(String) -> String) {
561 let Some(p) = scan.next_param() else {
562 self.raw.warn(ctx("redirect with no file".into()));
563 return;
564 };
565 let path = self.resolve(&p.value.text);
566 if self.dirs.len() > MAX_REDIRECT_DEPTH {
567 self.raw
568 .warn(ctx(format!("redirect depth limit at {}", path.display())));
569 return;
570 }
571 match self.loader.load(&path) {
572 Ok(text) => {
573 let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
574 self.dirs.push(dir.clone());
575 self.run_script(&text, &path.display().to_string());
576 self.dirs.pop();
577 if compile && let Some(top) = self.dirs.last_mut() {
585 *top = dir;
586 }
587 }
588 Err(e) => {
589 let verb = if compile { "compile" } else { "redirect" };
590 self.raw
591 .warn(ctx(format!("{verb} {}: {e}", path.display())));
592 }
593 }
594 }
595
596 fn do_buscoords(&mut self, scan: &mut Scanner, ctx: &dyn Fn(String) -> String) {
597 let Some(p) = scan.next_param() else {
598 self.raw.warn(ctx("buscoords with no file".into()));
599 return;
600 };
601 let path = self.resolve(&p.value.text);
602 match self.loader.load(&path) {
603 Ok(text) => {
604 for (line_no, line) in text.lines().enumerate() {
605 let mut s = Scanner::new(line, None);
606 let Some(bus) = s.next_param() else { continue };
607 if bus.value.text.is_empty() {
608 continue;
609 }
610 let x = s.next_param().map(|p| p.value).unwrap_or_default();
611 let y = s.next_param().map(|p| p.value).unwrap_or_default();
612 match (x.to_f64(None), y.to_f64(None)) {
613 (Ok(x), Ok(y)) => self.raw.buscoords.push(BusCoord {
614 bus: bus.value.text,
615 x,
616 y,
617 }),
618 _ => self.raw.warn(ctx(format!(
619 "buscoords {}:{}: unparseable coordinates",
620 path.display(),
621 line_no + 1
622 ))),
623 }
624 }
625 }
626 Err(e) => self
627 .raw
628 .warn(ctx(format!("buscoords {}: {e}", path.display()))),
629 }
630 }
631
632 fn object_spec(
634 &mut self,
635 scan: &mut Scanner,
636 ctx: &dyn Fn(String) -> String,
637 ) -> Option<(String, String)> {
638 let p = scan.next_param()?;
639 if let Some(name) = &p.name {
640 if !name.eq_ignore_ascii_case("object") {
641 self.raw
642 .warn(ctx(format!("expected Class.Name, got `{name}=`")));
643 return None;
644 }
645 }
646 let spec = p.value.text;
647 match spec.split_once('.') {
648 Some((class, name)) if !class.is_empty() && !name.is_empty() => {
649 Some((class.to_string(), name.to_string()))
650 }
651 _ => {
652 self.raw
653 .warn(ctx(format!("malformed object spec `{spec}`")));
654 None
655 }
656 }
657 }
658
659 fn make_object(&mut self, class: &str, name: String) -> usize {
660 let class_lc = class.to_ascii_lowercase();
661 let idx = self.raw.objects.len();
662 self.raw
663 .index
664 .insert((class_lc.clone(), name.to_ascii_lowercase()), idx);
665 self.raw.objects.push(RawObject {
666 class: class_lc,
667 name,
668 props: Vec::new(),
669 edits: Vec::new(),
670 });
671 idx
672 }
673
674 fn consume_and_apply(
675 &mut self,
676 idx: usize,
677 scan: &mut Scanner,
678 ctx: &dyn Fn(String) -> String,
679 ) {
680 let props = collect_props_for(
681 prop_table(&self.raw.objects[idx].class),
682 scan,
683 None,
684 &mut self.raw.warnings,
685 ctx,
686 );
687 self.apply_props(idx, props, ctx);
688 }
689
690 fn apply_props(&mut self, idx: usize, props: Vec<RawProp>, ctx: &dyn Fn(String) -> String) {
691 self.raw.active = Some(idx);
692 for p in props {
693 if p.name.as_deref() == Some("like") {
699 let class = self.raw.objects[idx].class.clone();
700 let key = (class.clone(), p.value.text.to_ascii_lowercase());
701 match self.raw.index.get(&key).copied() {
702 Some(src) => {
703 let base = self.raw.objects[idx].props.len();
704 let cloned = self.raw.objects[src].props.clone();
705 let bounds: Vec<usize> = self.raw.objects[src]
706 .edit_bounds()
707 .map(|e| base + e)
708 .collect();
709 self.raw.objects[idx].props.extend(cloned);
710 self.raw.objects[idx].edits.extend(bounds);
711 }
712 None => self.raw.warn(ctx(format!(
713 "like={} names an unknown {class}",
714 p.value.text
715 ))),
716 }
717 continue;
718 }
719 self.raw.objects[idx].props.push(p);
720 }
721 let end = self.raw.objects[idx].props.len();
724 self.raw.objects[idx].edits.push(end);
725 }
726}
727
728fn prop_table(class: &str) -> Option<&'static DssClass> {
729 prop::class_by_name(class)
730}
731
732fn collect_props_for(
737 class: Option<&'static DssClass>,
738 scan: &mut Scanner,
739 after: Option<&str>,
740 warnings: &mut Vec<String>,
741 ctx: &dyn Fn(String) -> String,
742) -> Vec<RawProp> {
743 let mut out = Vec::new();
744 let mut pointer: Option<usize> = class.zip(after).and_then(|(c, name)| c.prop_index(name));
745 while let Some(p) = scan.next_param() {
746 if p.value.text.is_empty() && p.name.is_none() {
747 break;
748 }
749 let name = match (&p.name, class) {
750 (Some(written), Some(c)) => {
751 if let Some(i) = c.prop_index(written) {
752 pointer = Some(i);
753 Some(c.props[i].to_string())
754 } else {
755 pointer = None;
759 warnings.push(ctx(format!(
760 "unknown property `{written}` on {}; kept as written",
761 c.name
762 )));
763 Some(written.to_ascii_lowercase())
764 }
765 }
766 (Some(written), None) => Some(written.to_ascii_lowercase()),
767 (None, Some(c)) => {
768 let next = pointer.map_or(0, |i| i + 1);
769 pointer = Some(next);
770 if let Some(canon) = c.props.get(next) {
771 Some((*canon).to_string())
772 } else {
773 warnings.push(ctx(format!(
774 "positional value `{}` beyond the last {} property",
775 p.value.text, c.name
776 )));
777 None
778 }
779 }
780 (None, None) => None,
781 };
782 out.push(RawProp {
783 name,
784 value: p.value,
785 });
786 }
787 out
788}
789
790pub fn parse_raw_with(text: &str, path: &str, loader: &mut impl Loader) -> RawDss {
793 let mut exec = Executor {
794 raw: RawDss::default(),
795 loader,
796 dirs: vec![
797 Path::new(path)
798 .parent()
799 .map(Path::to_path_buf)
800 .unwrap_or_default(),
801 ],
802 };
803 exec.run_script(text, path);
804 exec.raw
805}
806
807pub fn parse_raw_file(path: impl AsRef<Path>) -> Result<RawDss> {
809 let path = path.as_ref();
810 let text = std::fs::read_to_string(path).map_err(|source| Error::Io {
811 path: path.display().to_string(),
812 source,
813 })?;
814 Ok(parse_raw_with(
815 &text,
816 &path.display().to_string(),
817 &mut |p: &Path| std::fs::read_to_string(p),
818 ))
819}
820
821#[cfg(test)]
822mod tests {
823 use super::*;
824
825 fn no_files(_: &Path) -> std::io::Result<String> {
826 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "test"))
827 }
828
829 fn parse(text: &str) -> RawDss {
830 parse_raw_with(text, "test.dss", &mut no_files)
831 }
832
833 #[test]
834 fn new_object_with_positional_and_named() {
835 let raw = parse("New Line.l1 b1 b2 lc 0.3 phases=2 r1=0.1");
836 let l = raw.find("line", "l1").unwrap();
837 assert_eq!(l.get("bus1").unwrap().text, "b1");
838 assert_eq!(l.get("bus2").unwrap().text, "b2");
839 assert_eq!(l.get("linecode").unwrap().text, "lc");
840 assert_eq!(l.get("length").unwrap().text, "0.3");
841 assert_eq!(l.get("phases").unwrap().text, "2");
842 assert_eq!(l.get("r1").unwrap().text, "0.1");
843 assert!(raw.warnings.is_empty());
844 }
845
846 #[test]
847 fn positional_continues_after_named() {
848 let raw = parse("New Line.l1 r1=0.1 0.2");
850 let l = raw.find("line", "l1").unwrap();
851 assert_eq!(l.get("x1").unwrap().text, "0.2");
852 }
853
854 #[test]
855 fn unknown_property_resets_the_positional_pointer() {
856 let raw = parse("New Line.l1 r1=0.1 bogus=2 0.5");
859 let l = raw.find("line", "l1").unwrap();
860 assert_eq!(l.get("bus1").unwrap().text, "0.5");
861 assert!(l.get("x1").is_none());
862 assert_eq!(raw.warnings.len(), 1);
863 }
864
865 #[test]
866 fn tilde_continues_the_active_object() {
867 let raw = parse("New Load.ld bus1=b1\n~ kW=15 kvar=3\nMore pf=0.9");
868 let ld = raw.find("load", "ld").unwrap();
869 assert_eq!(ld.get("kw").unwrap().text, "15");
870 assert_eq!(ld.get("kvar").unwrap().text, "3");
871 assert_eq!(ld.get("pf").unwrap().text, "0.9");
872 }
873
874 #[test]
875 fn abbreviated_property_names() {
876 let raw = parse("New Line.l1 ph=3 len=2 rm=(1 | 0 1)");
877 let l = raw.find("line", "l1").unwrap();
878 assert_eq!(l.get("phases").unwrap().text, "3");
879 assert_eq!(l.get("length").unwrap().text, "2");
880 assert!(l.get("rmatrix").unwrap().quoted);
881 }
882
883 #[test]
884 fn new_circuit_creates_the_source() {
885 let raw = parse("New Circuit.test basekv=115 pu=1.05\n~ angle=30");
886 assert_eq!(raw.circuit_name.as_deref(), Some("test"));
887 let vs = raw.find("vsource", "source").unwrap();
888 assert_eq!(vs.get("basekv").unwrap().text, "115");
889 assert_eq!(vs.get("angle").unwrap().text, "30");
890 assert!(vs.get("bus1").is_none());
893 }
894
895 #[test]
896 fn edit_and_property_reference() {
897 let raw = parse("New Line.l1 length=1\nEdit Line.l1 length=2\nLine.l1.Length=3 phases=2");
898 let l = raw.find("line", "l1").unwrap();
899 assert_eq!(l.get("length").unwrap().text, "3");
900 assert_eq!(l.get("phases").unwrap().text, "2");
901 }
902
903 #[test]
904 fn property_reference_resolves_abbreviations() {
905 let raw = parse("New Line.l1 bus1=a\nLine.l1.Len=2.5");
906 let l = raw.find("line", "l1").unwrap();
907 assert_eq!(l.get("length").unwrap().text, "2.5");
908 assert!(raw.warnings.is_empty());
909 }
910
911 #[test]
912 fn bare_property_edits_the_active_object() {
913 let raw = parse("New Line.l1 bus1=a bus2=b\nlength=2.5");
914 let l = raw.find("line", "l1").unwrap();
915 assert_eq!(l.get("length").unwrap().text, "2.5");
916 assert!(raw.warnings.is_empty());
917 }
918
919 #[test]
920 fn classless_reference_uses_the_active_class() {
921 let raw = parse("New Line.l1 bus1=a\nNew Line.l2 bus1=b\nl1.length=7 phases=2");
924 let l1 = raw.find("line", "l1").unwrap();
925 assert_eq!(l1.get("length").unwrap().text, "7");
926 assert_eq!(l1.get("phases").unwrap().text, "2");
927 assert!(raw.find("line", "l2").unwrap().get("length").is_none());
928 assert!(raw.warnings.is_empty());
929 }
930
931 #[test]
932 fn like_splices_source_props() {
933 let raw = parse("New Load.a kW=10 pf=0.9\nNew Load.b like=a kW=20");
934 let b = raw.find("load", "b").unwrap();
935 assert_eq!(b.get("kw").unwrap().text, "20");
936 assert_eq!(b.get("pf").unwrap().text, "0.9");
937 }
938
939 #[test]
940 fn unknown_class_is_preserved_raw() {
941 let raw = parse("New Reactor.r1 bus1=b1 x=3");
942 let r = raw.find("reactor", "r1").unwrap();
943 assert_eq!(r.get("bus1").unwrap().text, "b1");
944 assert_eq!(r.get("x").unwrap().text, "3");
945 }
946
947 #[test]
948 fn set_options_accumulate() {
949 let raw = parse("Set VoltageBases=[115, 12.47]\nset mode=snapshot");
950 assert_eq!(raw.options[0].0, "voltagebases");
951 assert_eq!(
952 raw.options[0].1.to_vector(None).unwrap(),
953 vec![115.0, 12.47]
954 );
955 assert_eq!(raw.options[1].0, "mode");
956 }
957
958 #[test]
959 fn unexecuted_commands_are_preserved() {
960 let raw = parse("Solve\ncalcv\nShow Voltages LN");
961 let verbs: Vec<&str> = raw.commands.iter().map(|c| c.verb.as_str()).collect();
962 assert_eq!(verbs, vec!["solve", "calcvoltagebases", "show"]);
963 assert_eq!(raw.commands[2].args, "Voltages LN");
964 }
965
966 #[test]
967 fn clear_resets() {
968 let raw = parse("New Line.l1 length=1\nClear\nNew Line.l2 length=2");
969 assert!(raw.find("line", "l1").is_none());
970 assert!(raw.find("line", "l2").is_some());
971 }
972
973 #[test]
974 fn block_comments_skip_lines() {
975 let raw = parse("/* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
976 assert!(raw.find("line", "l1").is_none());
977 assert!(raw.find("line", "l2").is_some());
978 }
979
980 #[test]
981 fn indented_block_comments_skip_lines() {
982 let raw = parse(" /* comment\nNew Line.l1 length=1\n*/\nNew Line.l2 length=2");
983 assert!(raw.find("line", "l1").is_none());
984 assert!(raw.find("line", "l2").is_some());
985 }
986
987 #[test]
988 fn one_line_block_comment() {
989 let raw = parse("\t/* x */\nNew Line.l2 length=2");
990 assert!(raw.find("line", "l2").is_some());
991 }
992
993 #[test]
994 fn redirect_includes_a_file() {
995 let mut files = BTreeMap::from([(
996 PathBuf::from("sub/codes.dss"),
997 "New Linecode.lc1 nphases=3".to_string(),
998 )]);
999 let mut loader = move |p: &Path| {
1000 files
1001 .remove(p)
1002 .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1003 };
1004 let raw = parse_raw_with(
1005 "Redirect sub/codes.dss\nNew Line.l1 linecode=lc1",
1006 "test.dss",
1007 &mut loader,
1008 );
1009 assert!(raw.find("linecode", "lc1").is_some());
1010 assert!(raw.warnings.is_empty());
1011 }
1012
1013 #[test]
1014 fn missing_redirect_warns() {
1015 let raw = parse("Redirect nope.dss");
1016 assert_eq!(raw.warnings.len(), 1);
1017 assert!(raw.warnings[0].contains("nope.dss"));
1018 }
1019
1020 #[test]
1021 fn compile_moves_the_directory_redirect_restores_it() {
1022 let root = std::env::temp_dir().join(format!("powerio-dist-raw-{}", std::process::id()));
1027 let sub = root.join("sub");
1028 std::fs::create_dir_all(&sub).unwrap();
1029 std::fs::write(sub.join("feeder.dss"), "New Linecode.lc1 nphases=3").unwrap();
1030 std::fs::write(sub.join("lines.dss"), "New Line.fromsub bus1=a").unwrap();
1031 std::fs::write(root.join("lines.dss"), "New Line.fromroot bus1=a").unwrap();
1032 std::fs::write(
1033 root.join("compile.dss"),
1034 "Compile sub/feeder.dss\nRedirect lines.dss",
1035 )
1036 .unwrap();
1037 std::fs::write(
1038 root.join("redirect.dss"),
1039 "Redirect sub/feeder.dss\nRedirect lines.dss",
1040 )
1041 .unwrap();
1042
1043 let compiled = parse_raw_file(root.join("compile.dss")).unwrap();
1044 assert_eq!(compiled.warnings, Vec::<String>::new());
1045 assert!(compiled.find("line", "fromsub").is_some());
1046
1047 let redirected = parse_raw_file(root.join("redirect.dss")).unwrap();
1048 assert_eq!(redirected.warnings, Vec::<String>::new());
1049 assert!(redirected.find("line", "fromroot").is_some());
1050
1051 std::fs::remove_dir_all(&root).unwrap();
1052 }
1053
1054 #[test]
1055 fn compile_inside_compile_pins_the_compiled_files_directory() {
1056 let root =
1063 std::env::temp_dir().join(format!("powerio-dist-rawnest-{}", std::process::id()));
1064 let sub = root.join("sub");
1065 let inner = sub.join("inner");
1066 std::fs::create_dir_all(&inner).unwrap();
1067 std::fs::write(
1068 root.join("main.dss"),
1069 "Compile sub/a.dss\nRedirect probe.dss",
1070 )
1071 .unwrap();
1072 std::fs::write(sub.join("a.dss"), "Compile inner/b.dss").unwrap();
1073 std::fs::write(inner.join("b.dss"), "New Linecode.lc1 nphases=1").unwrap();
1074 std::fs::write(sub.join("probe.dss"), "New Line.fromsub bus1=a").unwrap();
1075 std::fs::write(inner.join("probe.dss"), "New Line.frominner bus1=a").unwrap();
1076
1077 let raw = parse_raw_file(root.join("main.dss")).unwrap();
1078 assert_eq!(raw.warnings, Vec::<String>::new());
1079 assert!(raw.find("linecode", "lc1").is_some());
1080 assert!(raw.find("line", "fromsub").is_some());
1081 assert!(raw.find("line", "frominner").is_none());
1082
1083 std::fs::remove_dir_all(&root).unwrap();
1084 }
1085
1086 #[test]
1087 fn edit_boundaries_are_recorded() {
1088 let raw = parse("New Load.a kW=10 pf=0.9\n~ kvar=5\nNew Load.b like=a kw=20");
1091 let a = raw.find("load", "a").unwrap();
1092 assert_eq!(a.edits, vec![2, 3]);
1093 let b = raw.find("load", "b").unwrap();
1094 assert_eq!(b.props.len(), 4);
1095 assert_eq!(b.edits, vec![2, 3, 4]);
1096 assert_eq!(b.edit_bounds().collect::<Vec<_>>(), vec![2, 3, 4]);
1097 }
1098
1099 #[test]
1100 fn var_definition_and_use() {
1101 let raw = parse("var @kv=12.47\nNew Load.ld kv=@kv");
1102 let ld = raw.find("load", "ld").unwrap();
1103 assert_eq!(ld.get("kv").unwrap().text, "12.47");
1104 }
1105
1106 #[test]
1107 fn quoted_var_value_stays_rpn() {
1108 let raw = parse("var @z=(8 1000 /)\nNew Load.ld kW=@z");
1112 let v = raw.find("load", "ld").unwrap().get("kw").unwrap();
1113 assert!(v.quoted);
1114 assert_eq!(v.to_f64(None), Ok(0.008));
1115 }
1116
1117 #[test]
1118 fn vars_cross_redirect_boundaries() {
1119 let mut loader = |p: &Path| {
1122 if p == Path::new("inc.dss") {
1123 Ok("New Load.inner kv=@kv\nvar @kw=42".to_string())
1124 } else {
1125 Err(std::io::Error::new(std::io::ErrorKind::NotFound, "missing"))
1126 }
1127 };
1128 let raw = parse_raw_with(
1129 "var @kv=12.47\nRedirect inc.dss\nNew Load.outer kW=@kw",
1130 "test.dss",
1131 &mut loader,
1132 );
1133 assert_eq!(raw.warnings, Vec::<String>::new());
1134 assert_eq!(
1135 raw.find("load", "inner").unwrap().get("kv").unwrap().text,
1136 "12.47"
1137 );
1138 assert_eq!(
1139 raw.find("load", "outer").unwrap().get("kw").unwrap().text,
1140 "42"
1141 );
1142 }
1143
1144 #[test]
1145 fn duplicate_new_warns_and_edits() {
1146 let raw = parse("New Line.l1 length=1\nNew Line.l1 length=2");
1147 assert_eq!(raw.warnings.len(), 1);
1148 assert_eq!(
1149 raw.find("line", "l1").unwrap().get("length").unwrap().text,
1150 "2"
1151 );
1152 }
1153
1154 #[test]
1155 fn rpn_value_via_props() {
1156 let raw = parse("New Load.ld kW=(8 1000 /)");
1157 let v = raw.find("load", "ld").unwrap().get("kw").unwrap().clone();
1158 assert_eq!(v.to_f64(None), Ok(0.008));
1159 }
1160}