Skip to main content

powerio_dist/dss/
raw.rs

1//! Script execution and the raw object layer.
2//!
3//! A `.dss` file is a command script. This layer splits it into command
4//! lines (handling block comments), resolves command verbs with the same
5//! exact-then-prefix rule OpenDSS uses, follows `Redirect`/`Compile`
6//! includes, and accumulates `New`/`Edit`/`~` property assignments into raw
7//! objects with property names resolved against the class tables. Values
8//! stay untyped [`Value`] tokens; interpretation happens in the readers.
9
10use 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
17/// The OpenDSS executive command list, in definition order
18/// (Executive/ExecCommands.cpp). Order fixes abbreviation resolution: a verb
19/// matches exactly first, then the first command here with the verb as a
20/// prefix. Only a handful execute in this layer; the rest are preserved as
21/// [`RawCommand`]s.
22static 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/// One property assignment as applied to an object, in application order.
164#[derive(Clone, Debug, PartialEq)]
165pub struct RawProp {
166    /// Canonical property name when resolved against the class table;
167    /// the name as written when the class or property is unknown; `None`
168    /// for a positional value on an unknown class.
169    pub name: Option<String>,
170    pub value: Value,
171}
172
173/// An accumulated object: every `New`/`Edit`/`~`/`like` assignment that
174/// touched it, in order. Values are raw tokens.
175#[derive(Clone, Debug)]
176pub struct RawObject {
177    /// Canonical lowercase class name (`line`, `load`, ...), known or not.
178    pub class: String,
179    /// Object name as written; lookup is case insensitive.
180    pub name: String,
181    pub props: Vec<RawProp>,
182    /// Prop-count checkpoints at edit boundaries. Every object command line
183    /// (`New`/`Edit`/`~`/`More`/property reference) is one engine Edit, and
184    /// the class Edit ends in RecalcElementData; readers with end-of-edit
185    /// side effects (Load) segment `props` on these. `like=` splices the
186    /// source's checkpoints too: MakeLike copies the source's recalced
187    /// state, so its boundaries must replay.
188    pub edits: Vec<usize>,
189}
190
191impl RawObject {
192    /// The last assignment to a canonical property name, if any.
193    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    /// Edit boundary checkpoints, closed over the full prop list: a
202    /// trailing segment without a recorded boundary counts as one more
203    /// edit, so callers always see `props.len()` last.
204    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/// A command this layer does not execute, preserved verbatim.
212#[derive(Clone, Debug, PartialEq)]
213pub struct RawCommand {
214    /// Canonical verb when recognized, the first token as written otherwise.
215    pub verb: String,
216    /// Everything after the verb, trimmed.
217    pub args: String,
218}
219
220/// Bus coordinates from a `BusCoords` file.
221#[derive(Clone, Debug, PartialEq)]
222pub struct BusCoord {
223    pub bus: String,
224    pub x: f64,
225    pub y: f64,
226}
227
228/// The executed script: objects, options, and preserved commands.
229#[derive(Debug, Default)]
230pub struct RawDss {
231    pub circuit_name: Option<String>,
232    pub objects: Vec<RawObject>,
233    /// `Set option=value` assignments in order.
234    pub options: Vec<(String, Value)>,
235    /// Commands preserved without execution (solve, calcvoltagebases, ...).
236    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
264/// Supplies included file text, so tests can run without a filesystem.
265pub 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
278/// Redirect nesting limit; OpenDSS recurses unbounded, this bounds cycles.
279const MAX_REDIRECT_DEPTH: usize = 64;
280
281struct Executor<'l, L: Loader> {
282    raw: RawDss,
283    loader: &'l mut L,
284    /// Directory stack for relative include resolution; starts with the
285    /// root file's directory, so its depth is the redirect nesting level.
286    dirs: Vec<PathBuf>,
287}
288
289/// Splits script text into command lines, dropping block comments. A block
290/// comment starts when the first nonspace characters are `/*` and ends on the
291/// first line containing `*/`; both boundary lines are consumed whole,
292/// matching the OpenDSS executive.
293fn 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        // The scanner substitutes against a snapshot of the var table so the
322        // live table stays free for mutation: `var` inserts into it directly
323        // and redirected files both see and extend it. The snapshot only
324        // diverges for a self referencing `var` line, which OpenDSS scripts
325        // do not write.
326        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                    // First parameter is name=value: a property reference
335                    // like `Transformer.Reg1.Taps=[...]`.
336                    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    /// `var @name=value ...` defines parser variables. TParserVar::Add
376    /// stores every value brace wrapped unless it begins with `@`;
377    /// CheckforVar unwraps the braces into a quoted token, so a definition
378    /// like `var @z=(8 1000 /)` still evaluates as RPN where it is used.
379    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    /// A leading `name=value` parameter is a property reference
396    /// (ExecCommands ProcessCommand): `Class.Name.Prop=value`,
397    /// `Name.Prop=value` with the class omitted, or `Prop=value` on the
398    /// active object. ParseObjName cuts the object part at the second dot;
399    /// SetObject resolves an omitted class to the last referenced one,
400    /// which here is the active object's class.
401    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            // A new circuit brings its Vsource named "source"; the line's
486            // remaining properties edit that source. Its defaults (bus1 =
487            // sourcebus etc.) stay implicit here so the reader can tell
488            // written values from materialized defaults.
489            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    /// Resolves a file argument relative to the current file's directory.
552    /// Backslash separators (the format's DOS heritage) become `/`.
553    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                // The engine keeps one current directory: Redirect restores
578                // the caller's on return (SetCurrentDir(SaveDir)), Compile
579                // pins it to the compiled file's OWN directory — ExecHelper
580                // DoRedirect sets CurrDir once from the file path (~:300)
581                // and compile exit reapplies it via SetDataPath (~:361) —
582                // even when the compiled script itself compiled deeper. The
583                // caller's later relative paths follow the compiled file.
584                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    /// Reads `Class.Name` (or `object=Class.Name`) from the next parameter.
633    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            // `like=<name>` splices the source object's accumulated props,
694            // checkpoints included: MakeLike copies the source's recalced
695            // state (Load.cpp ~810-815 takes kWBase, kvarBase, LoadSpecType,
696            // AND PFNominal), which equals replaying the source's writes
697            // with its own edit boundaries.
698            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        // This command line was one engine Edit; it ends in
722        // RecalcElementData, so record the boundary.
723        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
732/// Reads the remaining parameters of an object command, resolving names
733/// (with abbreviation) and positional order against the class table. The
734/// positional pointer continues from the last named property, as in the
735/// reference. `after` seeds the pointer for property reference lines.
736fn 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                    // Getcommand yields 0 for an unknown name, so the next
756                    // positional lands on property 1 (the class Edit loops:
757                    // `ParamPointer = CommandList.Getcommand(ParamName)`).
758                    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
790/// Parses `.dss` text. `path` anchors relative includes; pass the file's
791/// path when the text came from a file, anything descriptive otherwise.
792pub 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
807/// Parses a `.dss` file from disk, following its includes.
808pub 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        // After r1=0.1 (index 5), the next positional is x1 (index 6).
849        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        // `ParamPointer = Getcommand("bogus")` is 0 in the engine, so the
857        // next positional gets property 1 (bus1), not the one after r1.
858        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        // bus1 was not written; the default (sourcebus) is the reader's to
891        // materialize, so the raw layer must not invent it.
892        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        // SetObject with no dot in the spec looks the name up in the last
922        // referenced class, line here via the active object.
923        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        // After `Compile sub/feeder.dss`, the caller's relative paths
1023        // resolve against sub/; after a Redirect they resolve against the
1024        // caller's own directory again. Both directories carry a lines.dss
1025        // so the wrong resolution shows up as the wrong object.
1026        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        // ExecHelper DoRedirect sets CurrDir from the file path once at
1057        // entry and compile exit reapplies it (SetDataPath → ChDir), so a
1058        // Compile that itself compiles deeper still leaves the caller in
1059        // the directly compiled file's directory, not the innermost one.
1060        // probe.dss exists in both sub/ and sub/inner/; the engine resolves
1061        // sub/probe.dss.
1062        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        // One checkpoint per command line; like= splices the source's
1089        // boundaries (offset) before the splicing edit's own.
1090        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        // The braces TParserVar::Add wraps around the stored value come
1109        // back off as a quoted token, so the substituted expression still
1110        // evaluates as RPN.
1111        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        // A var defined in the parent substitutes inside the include, and a
1120        // var defined in the include survives back in the parent.
1121        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}