Skip to main content

epics_libcom_rs/runtime/
mac_lib.rs

1//! libCom `macLib`: the `$(name)` / `${name}` expansion engine (`macCore.c`)
2//! and the `name=value,...` definition grammar (`macUtil.c`).
3//!
4//! It sits in this crate, not under the database loader that is its heaviest
5//! user, for the reason C has it in libCom: a consumer with a macro string to honour — an
6//! areaDetector `NDAttributesMacros`, a `seq` program's arguments — need not
7//! link the database to do it.
8
9use std::collections::HashMap;
10
11/// Resolution options for the macLib expansion engine ([`expand_macros`]).
12#[derive(Clone, Copy, Debug, Default)]
13pub struct MacroExpandOptions {
14    /// Fall back to the process environment when a name is unset,
15    /// matching C `macCreateHandle(&h, environ)`. The `.db` parser
16    /// leaves this off (its substitutions come only from the
17    /// `dbLoadRecords` / `.substitutions` macro map); `dbLoadGroup`
18    /// and autosave turn it on.
19    pub env_fallback: bool,
20    /// Treat `$$` as a literal `$`. An autosave `.req` convenience, NOT
21    /// a macLib behavior — C macLib leaves `$$` verbatim (`$` is only
22    /// special before `(`/`{`), so the `.db` parser leaves it off.
23    pub dollar_escape: bool,
24    /// C `macSuppressWarning` / `FLAG_SUPPRESS_WARNINGS`
25    /// (`macCore.c:155-168`). It silences the `macLib:` diagnostics AND
26    /// changes the text: a suppressed undefined reference is written back
27    /// as `$(name)` where a warned one is `$(name,undefined)`
28    /// (`refer`, `macCore.c:920-928`). Off is C's default; the `.db`
29    /// loader turns it on from `dbQuietMacroWarnings`
30    /// (`dbLexRoutines.c:58`, `:273`) and `msi` turns it on outright
31    /// (`msi.cpp:154`).
32    pub suppress_warnings: bool,
33}
34
35/// Outcome of [`expand_macros`]: the expanded text, plus the fault that
36/// made it wrong if one did.
37///
38/// Three faults reach here — a name with no definition, a name that
39/// resolves into itself, and a reference whose closing delimiter never
40/// matched its opener — and they are C's single `entry->error`
41/// (`macCore.c:216-224`) split by cause. All three lists are therefore
42/// private, and the only way to read them is [`Self::fault`], or
43/// [`Self::errored`] for the same answer as a bool. A consumer able to
44/// reach one list directly hard-fails on the fault it named and returns
45/// the other one's broken text as success; that is not hypothetical, it
46/// is what the autosave `.req` reader did for as long as `undefined`
47/// was `pub`.
48#[derive(Clone, Debug, Default)]
49pub struct MacroExpansion {
50    pub text: String,
51    faults: MacroFaults,
52}
53
54/// C `MAC_ENTRY.error`, kept as the names that set it rather than a
55/// bool, and kept as ONE value because C keeps one: `refer` translates a
56/// reference name and a used default through the caller's own `entry`
57/// so their faults land here, and translates scoped definitions through
58/// a separate `MAC_ENTRY subs` whose `error` is never merged back
59/// (`macCore.c:820-826`). That second rule is why this is a struct and
60/// not three loose fields on [`ExpandCtx`] — [`ExpandCtx::detached`]
61/// swaps the whole set out for the scoped region in one move, so no
62/// future arm can leak into the caller by being added to only two of
63/// three lists.
64#[derive(Clone, Debug, Default)]
65struct MacroFaults {
66    /// Every macro referenced with neither a definition (nor an env
67    /// value, when `env_fallback`) nor a default. The text still carries
68    /// C's `$(name,undefined)` placeholder for each
69    /// (`refer:errval = ",undefined)"`).
70    undefined: Vec<String>,
71    /// Every macro this expansion refused to resolve because resolving
72    /// it re-entered itself — C `refer` finding `refentry->visited`
73    /// already set (`macCore.c:895-904`). Separate from
74    /// [`Self::undefined`] because the two are different faults with
75    /// different placeholders, and a caller that hard-fails on an
76    /// undefined name must not be told a recursive one was undefined.
77    recursive: Vec<String>,
78    /// Every `$(`/`${` whose closing delimiter never arrived — C
79    /// `refer`'s first error arm (`macCore.c:862-875`). Each entry is
80    /// the raw text C copied through verbatim, from the `$` to the end
81    /// of the string, because that arm writes no placeholder and names
82    /// no macro: the reference never became a name to look up.
83    unterminated: Vec<String>,
84}
85
86/// Which fault an expansion hit, and the first text that hit it — the
87/// macro name for the two arms that parsed one, the copied-through
88/// source for the arm that did not. One variant per way C sets
89/// `entry->error`, so a caller cannot report a recursion as an undefined
90/// name, nor either of them as a reference that was never closed.
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
92pub enum MacroFault<'a> {
93    /// No definition, no env value, no default — C's
94    /// `$(name,undefined)` placeholder (`macCore.c:913-928`).
95    Undefined(&'a str),
96    /// Resolving the name re-entered itself — C's `refentry->visited`
97    /// guard (`macCore.c:895-904`).
98    Recursive(&'a str),
99    /// The closing delimiter never matched the opener, so C copied the
100    /// reference and everything after it through verbatim
101    /// (`macCore.c:862-875`). The payload is that copied text — this arm
102    /// carries no macro NAME because the reference never produced one.
103    Unterminated(&'a str),
104}
105
106impl MacroExpansion {
107    /// The single owner of "did this expansion come out wrong, and
108    /// why". Every arm is read here so that no caller can read only
109    /// some of them.
110    ///
111    /// A macro is never in more than one list: `recursive` is recorded
112    /// only where the name resolved to a table entry, `undefined` only
113    /// where it resolved to nothing, `unterminated` only where no name
114    /// was ever parsed. So the order below decides nothing but which of
115    /// several independent faults a string carrying more than one
116    /// reports first.
117    #[must_use]
118    pub fn fault(&self) -> Option<MacroFault<'_>> {
119        if let Some(name) = self.faults.undefined.first() {
120            return Some(MacroFault::Undefined(name.as_str()));
121        }
122        if let Some(name) = self.faults.recursive.first() {
123            return Some(MacroFault::Recursive(name.as_str()));
124        }
125        self.faults
126            .unterminated
127            .first()
128            .map(|raw| MacroFault::Unterminated(raw.as_str()))
129    }
130
131    /// C `MAC_ENTRY.error`: whether the expansion came out wrong, by any
132    /// of the arms that set it. `macExpandString` returns a negative
133    /// length for all of them alike (`macCore.c:216-224`), and that
134    /// negative length is the whole of what the `.db` reader's per-line
135    /// warning and `macDefExpand`'s `NULL` are looking at. Delegates to
136    /// [`Self::fault`] so the bool and the name can never disagree.
137    #[must_use]
138    pub fn errored(&self) -> bool {
139        self.fault().is_some()
140    }
141}
142
143/// C `entry->type`: the word every `macLib:` notice prints in front of
144/// the entry name, and the reason those notices are not all about
145/// `string`. C carries six (`macCore.c:208`, `:283`, `:418`, `:595`,
146/// `:811`, `:826`); the `"default value"` seat is the one this port has
147/// no use for, because it slices a default out instead of running C's
148/// discarding first pass over it.
149const KIND_STRING: &str = "string";
150const KIND_MACRO: &str = "macro";
151const KIND_ENVIRONMENT: &str = "environment variable";
152const KIND_SCOPE_MARKER: &str = "scope marker";
153const KIND_SCOPED_MACRO: &str = "scoped macro";
154
155/// One `MAC_ENTRY.error`, carrying the text that raised it.
156///
157/// C keeps a bare `int error` and merges it with `entry->error =
158/// entry->error || refentry->error` (`macCore.c:885`), which is all its
159/// callers need: every one of them only compares `macExpandString`'s
160/// returned length against zero. This port's callers ask WHICH fault
161/// ([`MacroExpansion::fault`]), so the cause travels with the flag and a
162/// fault merged out of a cached value still names the macro that broke.
163#[derive(Clone, Debug)]
164enum TableFault {
165    Undefined(String),
166    Recursive(String),
167    Unterminated(String),
168}
169
170impl MacroFaults {
171    /// The only way a fault gets in, so an arm added to [`MacroFault`]
172    /// cannot be routed to the wrong list or to no list at all.
173    fn raise(&mut self, fault: TableFault) {
174        match fault {
175            TableFault::Undefined(name) => self.undefined.push(name),
176            TableFault::Recursive(name) => self.recursive.push(name),
177            TableFault::Unterminated(text) => self.unterminated.push(text),
178        }
179    }
180
181    /// C `entry->error = entry->error || refentry->error`
182    /// (`macCore.c:885`): a reference that resolves to a cached value
183    /// inherits that value's fault.
184    ///
185    /// C inherits one bit and so cannot say what the macro's fault was;
186    /// this inherits the causes, which costs nothing — only
187    /// [`MacroExpansion::fault`] reads them, and it reads the first.
188    fn merge(&mut self, other: &MacroFaults) {
189        self.undefined.extend_from_slice(&other.undefined);
190        self.recursive.extend_from_slice(&other.recursive);
191        self.unterminated.extend_from_slice(&other.unterminated);
192    }
193
194    /// Re-seat every recursion in this set onto `name`.
195    ///
196    /// A recursion is a property of the macro that could not be
197    /// resolved, not of the inner reference the resolution had to refuse
198    /// to find that out — C's own notice says so in as many words,
199    /// `macro A is recursive (expanding macro B)` (`macCore.c:895-901`).
200    /// So a fault merged out of `A`'s cached value reports `A`, which is
201    /// the name the caller wrote. The other two arms are properties of
202    /// something INSIDE the value — a name that resolves to nothing, a
203    /// bracket that never closes — and keep their own text.
204    fn rename_recursive(&mut self, name: &str) {
205        for entry in &mut self.recursive {
206            name.clone_into(entry);
207        }
208    }
209}
210
211/// C `MAC_ENTRY` (`macLib.h:34-45`): one macro, holding both the
212/// definition as given and the expansion cached from it.
213///
214/// The cache is the point of the type. C expands every raw value into
215/// `entry->value` in one pass over the whole table and then resolves a
216/// reference by COPYING that value (`refer`, `macCore.c:882-886`), so a
217/// macro whose own value is faulty raises its notice once, under its own
218/// name, before any caller's string is looked at — and every later
219/// reference to it reports the cached fault instead of deriving a fresh
220/// one from a different seat.
221#[derive(Clone, Debug)]
222struct MacEntry {
223    /// C `entry->name`. The scope markers carry the literal `<scope>`.
224    name: String,
225    /// C `entry->type` — one of the `KIND_*` constants above.
226    kind: &'static str,
227    /// C `entry->rawval`: the definition exactly as given.
228    rawval: String,
229    /// C `entry->value`: the definition with its own references
230    /// resolved. `None` until [`expand_table`] fills it, and set back to
231    /// `None` by any redefinition of this entry.
232    value: Option<String>,
233    /// C `entry->error`, as the causes that raised it — see
234    /// [`TableFault`]. Reset at the top of every [`expand_table`] pass,
235    /// exactly where C resets the bool (`macCore.c:670`).
236    faults: MacroFaults,
237    /// C `entry->visited`: raised around a translation of THIS entry's
238    /// raw value, so a reference that comes back round to it is refused
239    /// rather than followed (`macCore.c:888-893`).
240    visited: bool,
241    /// C `entry->special`: a `<scope>` marker rather than a macro
242    /// (`macPushScope`, `macCore.c:416-419`).
243    special: bool,
244    /// C `entry->level`: the scope depth this entry was defined at,
245    /// which decides whether a redefinition overwrites it or shadows it.
246    level: usize,
247}
248
249/// The `macPutValue` calls a caller wants made, in the order it wants
250/// them made in.
251///
252/// C builds its table one `macPutValue` at a time — `macInstallMacros`
253/// walks the `pairs` array `macParseDefns` produced, in file order
254/// (`macUtil.c:250-275`) — and `expand` then walks the table in that
255/// same order (`macCore.c:655`), so the sequence a `.db` load's macLib
256/// notices come out in is the sequence the definitions were written in.
257/// A [`HashMap`] cannot carry that, and sorting its keys by name only
258/// looked right because the shapes measured so far happened to be in
259/// alphabetical order already.
260///
261/// So the order is carried here instead, and the conversion from a
262/// [`HashMap`] is the one place the loss is named: those definitions
263/// arrive in no order at all, and sorting them by name at least makes
264/// the notices reproducible from run to run.
265#[derive(Clone, Debug, Default, PartialEq, Eq)]
266pub struct MacroDefs {
267    /// Name/value pairs in definition order, at most one entry per name.
268    defs: Vec<(String, String)>,
269}
270
271impl MacroDefs {
272    /// An empty set of definitions.
273    #[must_use]
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// C `macPutValue` (`macCore.c:262-289`): a redefinition replaces the
279    /// value and keeps the entry where it is, because `rawval` writes
280    /// through the entry `lookup` found rather than appending a second
281    /// one.
282    pub fn put(&mut self, name: impl Into<String>, value: impl Into<String>) {
283        let name = name.into();
284        match self.defs.iter_mut().find(|(n, _)| *n == name) {
285            Some(slot) => slot.1 = value.into(),
286            None => self.defs.push((name, value.into())),
287        }
288    }
289
290    /// The definitions in order.
291    pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
292        self.defs.iter().map(|(n, v)| (n.as_str(), v.as_str()))
293    }
294
295    /// How many names are defined.
296    #[must_use]
297    pub fn len(&self) -> usize {
298        self.defs.len()
299    }
300
301    /// Whether nothing is defined.
302    #[must_use]
303    pub fn is_empty(&self) -> bool {
304        self.defs.is_empty()
305    }
306}
307
308impl FromIterator<(String, String)> for MacroDefs {
309    /// Definition order, last definition of a name winning — C's, since
310    /// every one of these is a `macPutValue`.
311    fn from_iter<I: IntoIterator<Item = (String, String)>>(iter: I) -> Self {
312        let mut defs = Self::new();
313        for (name, value) in iter {
314            defs.put(name, value);
315        }
316        defs
317    }
318}
319
320impl From<Vec<(String, String)>> for MacroDefs {
321    fn from(pairs: Vec<(String, String)>) -> Self {
322        pairs.into_iter().collect()
323    }
324}
325
326impl From<&MacroDefs> for MacroDefs {
327    fn from(defs: &MacroDefs) -> Self {
328        defs.clone()
329    }
330}
331
332impl From<&HashMap<String, String>> for MacroDefs {
333    /// The lossy direction, and the only one: a hash map has no
334    /// definition order to carry, so the names are sorted to make the
335    /// notice order at least the same on every run. A caller that knows
336    /// the order the operator wrote should build a [`MacroDefs`]
337    /// directly.
338    fn from(macros: &HashMap<String, String>) -> Self {
339        let mut names: Vec<&String> = macros.keys().collect();
340        names.sort_unstable();
341        names
342            .into_iter()
343            .map(|name| (name.clone(), macros[name].clone()))
344            .collect()
345    }
346}
347
348/// C `MAC_HANDLE` (`macLib.h:47-57`): the macro table, the scope depth,
349/// and the one bit that says whether the cached values can be trusted.
350///
351/// This is the unit C creates once per FILE and expands once per line
352/// (`dbReadCOM` holds `macHandle` across the whole `.db`,
353/// `dbLexRoutines.c:256-300`), which is what makes a macro's own fault a
354/// once-per-file notice rather than a once-per-line one. Callers that
355/// have a single string and no file keep using [`expand_macros`], which
356/// is this type for the length of one call.
357///
358/// Entries are in the order they were defined in, which is C's
359/// `macPutValue` call order and the order the expansion pass walks them
360/// in (C `expand`, `macCore.c:655`) — so it is the order the notices a
361/// faulty definition raises come out in.
362/// That order is a property of [`MacroDefs`], not of this type: a table
363/// built from one is in the caller's order by construction, and a table
364/// built from a [`HashMap`] is in the only order a hash map can offer.
365pub struct MacroTable {
366    /// C `handle->list`. Ordered and searched from the tail.
367    ///
368    /// While an expansion is running it is only appended to or truncated
369    /// at the tail — see [`MacroTable::pop_scope`] — which is what lets
370    /// `refer` hold an index across a nested translation.
371    /// [`MacroTable::undefine`] is the one mutation that removes from the
372    /// middle, and it is reachable only from the file reader, between
373    /// lines, where no such index exists.
374    entries: Vec<MacEntry>,
375    /// C `handle->level`: how many scopes are open.
376    level: usize,
377    /// C `handle->dirty`: some raw value has changed since the last
378    /// [`expand_table`], so no cached value may be used. Raised by every
379    /// definition and every scope pop, lowered only by a completed
380    /// expansion pass.
381    dirty: bool,
382    /// C's handle flags, plus this port's `$$` convenience.
383    opts: MacroExpandOptions,
384}
385
386impl MacroTable {
387    /// C `macCreateHandle` + one `macPutValue` per pair
388    /// (`macCore.c:64-118`). The table starts dirty: nothing is expanded
389    /// until something asks for an expansion.
390    ///
391    /// The definitions arrive in [`MacroDefs`] order and are installed in
392    /// it, so the notice order of the first expansion pass is the
393    /// caller's own definition order and does not have to be arranged
394    /// for afterwards.
395    #[must_use]
396    pub fn new(defs: impl Into<MacroDefs>, opts: MacroExpandOptions) -> Self {
397        let entries = defs
398            .into()
399            .defs
400            .into_iter()
401            .map(|(name, rawval)| MacEntry {
402                name,
403                kind: KIND_MACRO,
404                rawval,
405                value: None,
406                faults: MacroFaults::default(),
407                visited: false,
408                special: false,
409                level: 0,
410            })
411            .collect();
412        Self {
413            entries,
414            level: 0,
415            dirty: true,
416            opts,
417        }
418    }
419
420    /// C `macExpandString` (`macCore.c:175-227`): bring the cached
421    /// values up to date, then translate `src` under a stack entry typed
422    /// `"string"` whose name is `src` itself.
423    ///
424    /// Both halves matter to what comes out. The table pass is what
425    /// decides the text of a reference into a cycle and the seat of
426    /// every notice a macro's own value raises; the string pass is the
427    /// only place the caller's own text is ever looked at.
428    #[must_use]
429    pub fn expand(&mut self, src: &str) -> MacroExpansion {
430        let suppressed = self.opts.suppress_warnings;
431        let mut ctx = ExpandCtx {
432            table: self,
433            seat: Seat {
434                kind: KIND_STRING,
435                name: src.to_string(),
436                faults: MacroFaults::default(),
437            },
438            suppressed,
439        };
440        expand_table(&mut ctx);
441        let chars: Vec<char> = src.chars().collect();
442        let mut out = String::with_capacity(src.len());
443        trans(&chars, 0, &mut ctx, &mut out);
444        MacroExpansion {
445            text: out,
446            faults: ctx.seat.faults,
447        }
448    }
449
450    /// C `macPutValue( handle, name, value )` (`macCore.c:262-289`) with
451    /// a non-NULL value: install `rawval` for `name` at the current scope
452    /// level.
453    ///
454    /// The value is stored RAW. C never expands a definition as it is
455    /// installed — `macInstallMacros` hands `macPutValue` exactly the
456    /// bytes `macParseDefns` cut out (`macUtil.c:250-275`) — so a
457    /// definition that mentions another macro stays live and follows
458    /// whatever that macro is when it is finally read.
459    pub fn define(&mut self, name: &str, rawval: String) {
460        self.put(name, KIND_MACRO, rawval);
461    }
462
463    /// C `macPutValue( handle, name, NULL )` (`macCore.c:274-280`): the
464    /// name is deleted rather than defined, which is what a definition
465    /// with no `=` in it means (`macParseDefns`'s `del[i]`,
466    /// `macUtil.c:105-110`).
467    ///
468    /// Deleting is not the same as defining nothing: an OUTER definition
469    /// of the same name is uncovered by it, and a reference that finds
470    /// nothing at all is undefined rather than empty.
471    pub fn undefine(&mut self, name: &str) {
472        let Some(idx) = self.lookup(name) else {
473            return;
474        };
475        self.entries.remove(idx);
476        self.dirty = true;
477    }
478
479    /// C `lookup( handle, name, FALSE )` (`macCore.c:571-585`): search
480    /// backwards "so scoping works" — the newest definition of a name
481    /// wins — and never match a scope marker.
482    fn lookup(&self, name: &str) -> Option<usize> {
483        self.entries
484            .iter()
485            .rposition(|e| !e.special && e.name == name)
486    }
487
488    /// [`Self::lookup`] with the rest of C's `lookup`: on a miss under
489    /// `FLAG_USE_ENVIRONMENT`, the environment is read and the value is
490    /// INSTALLED as an entry typed `"environment variable"`
491    /// (`macCore.c:586-598`).
492    ///
493    /// Installing it is not an optimisation, it is why an environment
494    /// hit dirties the table: the entry arrives with no cached value, so
495    /// every reference after it in the same string resolves from raw
496    /// values until the next expansion pass.
497    fn lookup_or_env(&mut self, name: &str) -> Option<usize> {
498        if let Some(i) = self.lookup(name) {
499            return Some(i);
500        }
501        if !self.opts.env_fallback || name.is_empty() {
502            return None;
503        }
504        let value = crate::runtime::env::get(name)?;
505        Some(self.put(name, KIND_ENVIRONMENT, value))
506    }
507
508    /// C `macPutValue` (`macCore.c:262-289`) and the `rawval` it ends in
509    /// (`:610-619`).
510    ///
511    /// A definition at or below the current scope level overwrites in
512    /// place; one that came from an OUTER scope is shadowed by a new
513    /// entry instead, so popping the scope brings the outer value back.
514    /// Either way the whole table goes dirty, because any other entry
515    /// may reference this one and no cached value can be trusted until
516    /// they are all rebuilt.
517    fn put(&mut self, name: &str, kind: &'static str, rawval: String) -> usize {
518        let idx = match self.lookup(name) {
519            Some(i) if self.entries[i].level >= self.level => i,
520            _ => {
521                self.entries.push(MacEntry {
522                    name: name.to_string(),
523                    kind,
524                    rawval: String::new(),
525                    value: None,
526                    faults: MacroFaults::default(),
527                    visited: false,
528                    special: false,
529                    level: self.level,
530                });
531                self.entries.len() - 1
532            }
533        };
534        let entry = &mut self.entries[idx];
535        entry.kind = kind;
536        entry.rawval = rawval;
537        entry.value = None;
538        entry.faults = MacroFaults::default();
539        self.dirty = true;
540        idx
541    }
542
543    /// C `macPushScope` (`macCore.c:400-424`): a marker entry at the
544    /// tail, which everything defined from here on sits after.
545    fn push_scope(&mut self) {
546        self.level += 1;
547        self.entries.push(MacEntry {
548            name: String::from("<scope>"),
549            kind: KIND_SCOPE_MARKER,
550            rawval: String::new(),
551            value: None,
552            faults: MacroFaults::default(),
553            visited: false,
554            special: true,
555            level: self.level,
556        });
557    }
558
559    /// C `macPopScope` (`macCore.c:434-475`): delete the most recent
560    /// `<scope>` marker and every entry defined since it.
561    ///
562    /// Those entries are exactly the tail — nothing is ever inserted
563    /// before an existing entry — so the deletion is a truncation, and
564    /// an index held by an enclosing [`refer`] frame into anything below
565    /// the marker survives it. C's `delete` dirties the table for the
566    /// same reason a redefinition does: a surviving entry may have
567    /// referenced what just went away.
568    fn pop_scope(&mut self) {
569        let at = self
570            .entries
571            .iter()
572            .rposition(|e| e.special)
573            .expect("refer pushes the scope marker before it can pop one");
574        self.entries.truncate(at);
575        self.level -= 1;
576        self.dirty = true;
577    }
578}
579
580/// The `MAC_ENTRY` a translation runs under: the two words every
581/// `macLib:` notice prints, and the `error` field the faults land in.
582///
583/// C swaps it four ways and the swaps are the whole of why one notice
584/// says `string` and the next says `macro`. `macExpandString` seats a
585/// stack entry typed `"string"` whose name is the caller's whole source
586/// string (`macCore.c:206-209`); [`expand_table`] seats the table entry
587/// being expanded (`:668`); and `refer` seats a throwaway `dflt` around
588/// the default's discarding pass (`:805-816`) and a throwaway `subs`
589/// around the scoped definitions (`:820-826`), neither of which merges
590/// its error back. Everything else — a resolved value translated raw, a
591/// used default — keeps the caller's seat, which is why a fault found
592/// three macros deep still names the entry the chain started from.
593struct Seat {
594    kind: &'static str,
595    name: String,
596    faults: MacroFaults,
597}
598
599/// Engine state threaded through [`trans`] / [`refer`] / [`parse_scoped`]:
600/// the table being resolved against, the [`Seat`] the current
601/// translation runs under, and the suppression bit the guards below
602/// raise and lower.
603///
604/// The scope stack and the recursion stack that used to be here are both
605/// gone into the table, where C keeps them: a scoped definition is an
606/// entry between a `<scope>` marker and the tail, and "currently being
607/// expanded" is [`MacEntry::visited`].
608struct ExpandCtx<'a> {
609    table: &'a mut MacroTable,
610    seat: Seat,
611    /// C `handle->flags & FLAG_SUPPRESS_WARNINGS`, which `refer` raises
612    /// and lowers around regions rather than setting once
613    /// (`macCore.c:795-800`, `:805-816`, `:822-859`). Seeded from
614    /// [`MacroExpandOptions::suppress_warnings`] and read wherever a
615    /// `macLib:` notice is about to be written, so a caller's knob and a
616    /// region's own quiet are the same bit.
617    suppressed: bool,
618}
619
620impl ExpandCtx<'_> {
621    /// C's `flags = handle->flags; handle->flags |=
622    /// FLAG_SUPPRESS_WARNINGS; …; handle->flags = flags` around the
623    /// translation of a reference NAME (`macCore.c:795-800`). The
624    /// notices go quiet, but the seat is unchanged, so a fault inside the
625    /// name still fails the surrounding expansion. Measured on `softIoc
626    /// R7.0.10`: `$($(NAMEREF))` writes ONE line and it names the
627    /// suppressed placeholder, `macro $(NAMEREF) is undefined`.
628    fn suppressing<R>(&mut self, body: impl FnOnce(&mut Self) -> R) -> R {
629        let saved = std::mem::replace(&mut self.suppressed, true);
630        let out = body(self);
631        self.suppressed = saved;
632        out
633    }
634
635    /// Run `body` under a different [`Seat`] and hand back the faults it
636    /// raised, for the caller to merge or to drop. Every seat change in
637    /// C is this shape, so this is the only way the seat moves.
638    fn seated<R>(&mut self, seat: Seat, body: impl FnOnce(&mut Self) -> R) -> (R, MacroFaults) {
639        let saved = std::mem::replace(&mut self.seat, seat);
640        let out = body(self);
641        let raised = std::mem::replace(&mut self.seat, saved).faults;
642        (out, raised)
643    }
644
645    /// C's `MAC_ENTRY subs` for the scoped-definition region
646    /// (`macCore.c:820-826`): a fresh seat whose `error` is never merged
647    /// back, under a raised suppression flag. So a fault inside
648    /// `,K=$(UNDEF)` neither warns nor fails the expansion around it —
649    /// measured on `softIoc R7.0.10`, `$(P,K=$(UNDEF))` with `P` defined
650    /// is silent and loads as `pval`.
651    ///
652    /// `name` is C's `subs.name`, which it re-seats between the two
653    /// halves of a definition: the reference's own name while the
654    /// definition's NAME is translated, the definition's name while its
655    /// VALUE is (`:840`, `:847`).
656    fn detached<R>(&mut self, name: String, body: impl FnOnce(&mut Self) -> R) -> R {
657        let seat = Seat {
658            kind: KIND_SCOPED_MACRO,
659            name,
660            faults: MacroFaults::default(),
661        };
662        let (out, _discarded) = self.suppressing(|ctx| ctx.seated(seat, body));
663        out
664    }
665}
666
667/// C `expand` (`macCore.c:645-679`): translate every raw value into its
668/// cached value, each under its OWN entry, then mark the table clean.
669///
670/// This is not a pass over "the macros the string mentions" — C expands
671/// the whole table on the first use after any change, and that is what
672/// makes a resolved reference a COPY (`refer`, `:882-886`) rather than a
673/// re-translation. Both halves of the difference are observable.
674/// Measured on `softIoc R7.0.10` with `A=$(B)`, `B=$(A)` delivered
675/// through `dbLoadTemplate` and a `.db` line reading `$(A)`: C writes
676/// `macro A is recursive (expanding macro B)` and then `macro B is
677/// recursive (expanding macro A)` — both seated on the table entry, not
678/// on the string — and loads `$(B,recursive)`, which is `A`'s cached
679/// value and not anything the string pass could have produced.
680///
681/// The table stays dirty for the duration, so a reference met while
682/// expanding takes `refer`'s raw branch and [`MacEntry::visited`] is the
683/// only thing between a cycle and an unbounded recursion — exactly as in
684/// C, where `handle->dirty` is cleared only after the loop.
685fn expand_table(ctx: &mut ExpandCtx) {
686    if !ctx.table.dirty {
687        return;
688    }
689    let mut i = 0;
690    // Not a `for` over a snapshot: resolving one entry can APPEND
691    // another (an environment value materialises as an entry), and C's
692    // list walk reaches those too.
693    while i < ctx.table.entries.len() {
694        // A `<scope>` marker has no raw value at all — `create` leaves it
695        // NULL (`macCore.c:556`) and C would fault here. It never does,
696        // because every scope `refer` opens is closed inside the same
697        // reference, and the public `macPushScope` callers expand between
698        // scopes rather than inside one.
699        if ctx.table.entries[i].special {
700            i += 1;
701            continue;
702        }
703        let raw: Vec<char> = ctx.table.entries[i].rawval.chars().collect();
704        let seat = Seat {
705            kind: ctx.table.entries[i].kind,
706            name: ctx.table.entries[i].name.clone(),
707            faults: MacroFaults::default(),
708        };
709        let mut value = String::new();
710        // C starts at level 1 "so quotes and escapes will be removed
711        // from expanded value" (`macCore.c:672-673`) — a cached value is
712        // never the user's own text.
713        let (_, raised) = ctx.seated(seat, |ctx| trans(&raw, 1, ctx, &mut value));
714        let entry = &mut ctx.table.entries[i];
715        entry.value = Some(value);
716        entry.faults = raised;
717        i += 1;
718    }
719    ctx.table.dirty = false;
720}
721
722/// Expand `$(...)` / `${...}` macro references, mirroring the C `macLib`
723/// engine (`modules/libcom/src/macLib/macCore.c` `expand` / `trans` /
724/// `refer`). This is the single macLib implementation for the crate; the
725/// `.db` parser, `dbLoadGroup`, and autosave all route through it (with
726/// per-caller [`MacroExpandOptions`]) rather than re-implementing it.
727///
728/// One call is one [`MacroTable`], which is C's handle for the length of
729/// one string. Callers that expand many strings against one set of
730/// macros — a file's worth of lines — should build the table once and
731/// call [`MacroTable::expand`] per line instead, because a macro whose
732/// own value is faulty raises its notice once per TABLE, not once per
733/// string.
734///
735/// Implemented behaviors:
736///
737///   - every raw value is expanded into a cached value before the
738///     caller's string is looked at, and a reference to a macro COPIES
739///     that cached value with its error merged rather than translating
740///     the raw value again (C `expand`, `macCore.c:645-679`; `refer`,
741///     `:882-886`). A definition, a scope pop or an environment hit
742///     invalidates the cache, and until the next pass references
743///     resolve from raw values.
744///   - `\<char>` blocks macro detection; both bytes reach the output in
745///     the caller's own string, and the backslash is dropped from
746///     anything that arrived through a macro (`trans:701-703,739-744`;
747///     `macLib.plt:52`).
748///   - macros are NOT expanded inside single quotes (`trans:733-736`);
749///     the quote characters themselves survive only at level 0.
750///   - a reference name is itself macro-expanded before lookup
751///     (`refer` runs `trans` on the name — `$($(WHICH))`).
752///   - the name terminates at `=`, `,` or the closing bracket
753///     (`macEnd = "=,)"`); `,name=val` introduces scoped macro
754///     definitions visible only inside that reference's expansion, and
755///     visible to the definitions AFTER them in the same list
756///     (`macPushScope` precedes the loop, `macCore.c:827-850`).
757///   - a self- or mutually-referential macro is refused at C's
758///     per-entry `visited` guard (`macCore.c:888-904`), leaving
759///     `$(name,recursive)` and [`MacroFault::Recursive`].
760///   - an undefined macro with no default emits the placeholder
761///     `$(name,undefined)` (`refer:errval = ",undefined)"`) and comes
762///     back as [`MacroFault::Undefined`].
763///   - a reference whose closing delimiter never matched its opener is
764///     copied through verbatim together with everything after it, and
765///     nothing in that tail is expanded a second time
766///     (`refer`, `macCore.c:862-875`) — [`MacroFault::Unterminated`].
767///   - with [`MacroExpandOptions::env_fallback`], an otherwise-unset
768///     name resolves from the process environment before the default
769///     (C `macCreateHandle(&h, environ)`).
770#[must_use]
771pub fn expand_macros(
772    input: &str,
773    macros: impl Into<MacroDefs>,
774    opts: MacroExpandOptions,
775) -> MacroExpansion {
776    MacroTable::new(macros, opts).expand(input)
777}
778
779/// Expand `$(...)` / `${...}` macro references with the default
780/// macLib options (no env fallback, no `$` escape, undefined →
781/// placeholder). Thin wrapper over [`expand_macros`], for the callers
782/// those defaults are right for: the `include` / `path` / `substitute`
783/// directives of the `.db` reader, and `.acf` text through
784/// [`substitute_macros_per_line`]. `dbLoadGroup` and autosave reuse the
785/// same engine but not this wrapper — both need options of their own,
786/// and both call [`expand_macros`] directly.
787pub fn substitute_macros(input: &str, macros: impl Into<MacroDefs>) -> String {
788    expand_macros(input, macros, MacroExpandOptions::default()).text
789}
790
791/// [`substitute_macros`], one line at a time against ONE table — how C's
792/// file readers feed macLib.
793///
794/// `dbLoadRecords` hands `macExpandString` a single `fgets` line
795/// (`dbLexRoutines.c:375-391`), and `asInitFile` does the same
796/// (`asLibRoutines.c:202-219`), so the expander's quote tracking (`trans`'s
797/// `quote`, C `macCore.c`) resets at every newline. Expanding a whole file
798/// in one [`substitute_macros`] call let one line's quote state leak into
799/// the next: an apostrophe in a `#` comment opened single-quote suppression
800/// and silently disabled every `$(...)` on the lines after it, until the
801/// parser failed on an unexpanded record name. Within a line the quote
802/// rules are unchanged — `'$(X)'` still suppresses.
803///
804/// The table is the file's, not the line's, because C's is: `asInitFile`
805/// creates one handle and reads every line through it, so a macro whose
806/// own value is faulty is expanded — and complained about — once.
807pub fn substitute_macros_per_line(input: &str, macros: impl Into<MacroDefs>) -> String {
808    let mut table = MacroTable::new(macros, MacroExpandOptions::default());
809    input
810        .split_inclusive('\n')
811        .map(|line| table.expand(line).text)
812        .collect()
813}
814
815/// Translate `chars` into `out`, expanding macro references.
816///
817/// `level` is C's: 0 is the string the caller handed
818/// [`MacroTable::expand`], and every recursion — a macro's value, a
819/// reference name, a default, a scoped definition, a cached value being
820/// built — runs one deeper, which is what decides whether quotes and
821/// backslashes are syntax or text. Scopes and the recursion guard live
822/// in [`ExpandCtx::table`].
823fn trans(chars: &[char], level: usize, ctx: &mut ExpandCtx, out: &mut String) {
824    // C `macCore.c:701-703`: "discard quotes and escapes if level is > 0
825    // (i.e. if these aren't the user's quotes and escapes)".
826    let discard = level > 0;
827    let mut quote: Option<char> = None;
828    let mut i = 0;
829    while i < chars.len() {
830        let c = chars[i];
831
832        // Track single/double quote state (C `trans` `quote` var).
833        if let Some(q) = quote {
834            if c == q {
835                quote = None;
836                if discard {
837                    i += 1;
838                    continue;
839                }
840            }
841        } else if c == '"' || c == '\'' {
842            quote = Some(c);
843            if discard {
844                i += 1;
845                continue;
846            }
847        }
848
849        // `$$` → literal `$` (opt-in; autosave `.req` convenience).
850        if ctx.table.opts.dollar_escape && c == '$' && i + 1 < chars.len() && chars[i + 1] == '$' {
851            out.push('$');
852            i += 2;
853            continue;
854        }
855
856        // `\<char>`: skip macro detection; the backslash itself is
857        // emitted only at level 0 (C `if (v < valend && !discard)`).
858        if c == '\\' && i + 1 < chars.len() {
859            if !discard {
860                out.push('\\');
861            }
862            out.push(chars[i + 1]);
863            i += 2;
864            continue;
865        }
866
867        // Macro reference: `$` followed by `(` or `{`, and NOT inside
868        // single quotes (C `macRef && quote != '\''`).
869        let mac_ref =
870            c == '$' && i + 1 < chars.len() && (chars[i + 1] == '(' || chars[i + 1] == '{');
871        if mac_ref && quote != Some('\'') {
872            i = refer(chars, i, level, ctx, out);
873            continue;
874        }
875
876        out.push(c);
877        i += 1;
878    }
879}
880
881/// Expand one macro reference starting at `chars[start]` (`$`). Returns
882/// the index just past the closing bracket, or — when the closing
883/// delimiter never matched the opener — the index past the whole
884/// remaining scan, which this then copies out verbatim (C
885/// `macCore.c:862-875`). `None` is never returned: every `$(`/`${` the
886/// caller hands over is consumed here, terminated or not.
887fn refer(
888    chars: &[char],
889    start: usize,
890    level: usize,
891    ctx: &mut ExpandCtx,
892    out: &mut String,
893) -> usize {
894    let close = if chars[start + 1] == '(' { ')' } else { '}' };
895    // Find the matching close bracket, honoring nested `$(`/`${`.
896    let body_start = start + 2;
897    let mut depth = 1usize;
898    let mut j = body_start;
899    while j < chars.len() && depth > 0 {
900        if j + 1 < chars.len() && chars[j] == '$' && (chars[j + 1] == '(' || chars[j + 1] == '{') {
901            depth += 1;
902            j += 2;
903            continue;
904        }
905        if depth == 1 && chars[j] == close || depth > 1 && (chars[j] == ')' || chars[j] == '}') {
906            depth -= 1;
907            if depth == 0 {
908                break;
909            }
910        }
911        j += 1;
912    }
913    if depth != 0 {
914        // C `refer`'s first error arm (`macCore.c:862-875`): the closing
915        // delimiter never matched the opener, so this is not a
916        // reference. C rewinds the output pointer to where the reference
917        // began (`v = *value`) and copies the raw text from the `$`
918        // through the last character `trans` scanned — the whole rest of
919        // the string, because the failed name translation ran to the end
920        // of it — then sets `entry->error` and says so.
921        //
922        // Copying the tail HERE, rather than handing the caller a `$` to
923        // re-scan, is what makes the text match: a re-scan re-enters
924        // `trans` just past the `$` and expands whatever `$(…)` follows,
925        // where C never looks at the tail again. Measured on `softIoc
926        // R7.0.10` with `Q=ZZZ`, C writes `x$(A $(Q) y` for the line
927        // `"x$(A $(Q) y"` — the inner `$(Q)` was consumed as part of the
928        // unterminated reference's NAME and is not expanded a second
929        // time. This port used to write `x$(A ZZZ y`.
930        //
931        // The arm is reached from a pre-scan for the matching bracket,
932        // before any scoped definition in the body has been parsed,
933        // where C discovers the mismatch only after parsing them and
934        // pushing a scope it then pops. Neither the text nor this notice
935        // can see the difference; the one thing that can is that C
936        // leaves the table dirty afterwards and this does not, so C may
937        // re-expand — and re-announce — a faulty macro value that this
938        // announces once.
939        let verbatim: String = chars[start..].iter().collect();
940        ctx.seat
941            .faults
942            .raise(TableFault::Unterminated(verbatim.clone()));
943        if !ctx.suppressed {
944            // C's third `macLib:` notice, alongside `undefined` and
945            // `recursive` below and painted by the same `ANSI_MAGENTA`
946            // (`errlog.h:301`) that `errlog` strips off a non-terminal
947            // console. It quotes `entry->type entry->name`, which is
948            // whatever seat this translation runs under: the caller's
949            // whole string when the `$(` is in the string, the macro's
950            // own name when it is in a macro's value.
951            let unterminated = if crate::runtime::log::errlog_console_paints() {
952                "\x1b[35;1munterminated\x1b[0m"
953            } else {
954                "unterminated"
955            };
956            crate::runtime::log::errlog_printf(&format!(
957                "macLib: {unterminated} macro reference in {} {}\n",
958                ctx.seat.kind, ctx.seat.name
959            ));
960        }
961        out.push_str(&verbatim);
962        return chars.len();
963    }
964    let body = &chars[body_start..j];
965    let after = j + 1;
966
967    // Split the body at the first top-level `=` or `,` (the C
968    // `macEnd` terminator set). Nested `$(...)` brackets are skipped
969    // so a `=`/`,` inside an inner reference does not terminate.
970    let split = top_level_terminator(body);
971    let (name_chars, rest) = match split {
972        Some(k) => (&body[..k], &body[k..]),
973        None => (body, &body[body.len()..]),
974    };
975
976    // The name itself may contain macro references — expand it, quietly.
977    // C raises `FLAG_SUPPRESS_WARNINGS` for exactly this translation and
978    // lowers it again (`macCore.c:795-800`), so the placeholder an
979    // unresolved inner reference leaves in the name is the SHORT `$(X)`
980    // form and no notice is written for it. The fault still lands,
981    // because C hands the name translation the caller's own seat.
982    let mut name = String::new();
983    ctx.suppressing(|ctx| trans(name_chars, level + 1, ctx, &mut name));
984
985    // Default value (`=...`) and scoped definitions (`,k=v`).
986    let mut default: Option<&[char]> = None;
987    let mut scoped: Option<&[char]> = None;
988    if let Some(first) = rest.first() {
989        if *first == '=' {
990            // Default runs until the first top-level `,` or end.
991            let dflt = &rest[1..];
992            match top_level_comma(dflt) {
993                Some(k) => {
994                    default = Some(&dflt[..k]);
995                    scoped = Some(&dflt[k..]);
996                }
997                None => default = Some(dflt),
998            }
999        } else if *first == ',' {
1000            scoped = Some(rest);
1001        }
1002    }
1003
1004    // C pushes the scope only when a `,` list follows, and pops it at
1005    // the single exit (`macCore.c:822-830`, `:932-935`). The condition
1006    // is not a saving: a push and its pop each dirty the table, so an
1007    // unconditional pair would make every reference in a string throw
1008    // away the cached values the reference before it was resolved from.
1009    //
1010    // The frame goes on BEFORE the definitions are read, because C's
1011    // `macPushScope` does and each `macPutValue` lands in it as the loop
1012    // reaches it (`:850`). So definition N's value is translated with
1013    // definitions 1..N-1 visible, and only those: measured on `softIoc
1014    // R7.0.10` with an outer `A=outer`, `$(B,A=1,B=$(A))` is `1` while
1015    // the reverse `$(B,B=$(A),A=1)` is `outer`.
1016    let pop = scoped.is_some();
1017    if let Some(defs) = scoped {
1018        ctx.table.push_scope();
1019        parse_scoped(defs, level, ctx, &name);
1020    }
1021
1022    match ctx.table.lookup_or_env(&name) {
1023        Some(idx) => {
1024            if ctx.table.entries[idx].visited {
1025                // C `refer` finding `refentry->visited` already set
1026                // (`macCore.c:895-904`): the reference is refused, NOT
1027                // resolved. The port used to emit the value verbatim,
1028                // which broke the cycle but left the operator with a
1029                // silently half-expanded `.db`.
1030                //
1031                // The seat is the entry whose raw value was being
1032                // translated when the cycle closed, and `name` is the
1033                // reference that closed it — C's `entry` and
1034                // `refentry`, in that order. Reached from
1035                // [`expand_table`] the seat is a table entry, so the
1036                // notice reads `macro A is recursive (expanding macro
1037                // B)`, which is what `softIoc R7.0.10` writes.
1038                let refkind = ctx.table.entries[idx].kind;
1039                ctx.seat.faults.raise(TableFault::Recursive(name.clone()));
1040                if !ctx.suppressed {
1041                    let recursive = if crate::runtime::log::errlog_console_paints() {
1042                        "\x1b[35;1mrecursive\x1b[0m"
1043                    } else {
1044                        "recursive"
1045                    };
1046                    crate::runtime::log::errlog_printf(&format!(
1047                        "macLib: {} {} is {recursive} (expanding {refkind} {name})\n",
1048                        ctx.seat.kind, ctx.seat.name
1049                    ));
1050                }
1051                out.push('$');
1052                out.push('(');
1053                out.push_str(&name);
1054                // Same knob, same two texts as the undefined arm
1055                // (`macCore.c:920-928`).
1056                if ctx.suppressed {
1057                    out.push(')');
1058                } else {
1059                    out.push_str(",recursive)");
1060                }
1061            } else if ctx.table.dirty {
1062                // No cached value can be trusted, so translate the raw
1063                // one under the CALLER's seat — C passes `entry`, not
1064                // `refentry` (`macCore.c:890`) — with the entry's
1065                // `visited` guard raised around it.
1066                let raw: Vec<char> = ctx.table.entries[idx].rawval.chars().collect();
1067                ctx.table.entries[idx].visited = true;
1068                trans(&raw, level + 1, ctx, out);
1069                ctx.table.entries[idx].visited = false;
1070            } else {
1071                // C `cpy2val( refentry->value, … )` plus `entry->error =
1072                // entry->error || refentry->error` (`macCore.c:882-886`).
1073                // The value is COPIED, not re-scanned: whatever the
1074                // table pass made of it — including the placeholder a
1075                // cycle left in it — is what the string gets, and the
1076                // fault that produced it comes across without being
1077                // raised a second time.
1078                let entry = &ctx.table.entries[idx];
1079                let value = entry.value.clone().unwrap_or_default();
1080                let mut faults = entry.faults.clone();
1081                faults.rename_recursive(&name);
1082                out.push_str(&value);
1083                ctx.seat.faults.merge(&faults);
1084            }
1085        }
1086        None => match default {
1087            Some(def_chars) => {
1088                // C `refer` translates the default at `level + 1`
1089                // (`macCore.c:909`), so every quote in it is discarded —
1090                // not just a surrounding pair.
1091                trans(def_chars, level + 1, ctx, out);
1092            }
1093            None => {
1094                // C `refer` (`macCore.c:913-917`), through `errlogPrintf`
1095                // and not `fprintf` — which is why the magenta on
1096                // `undefined` follows the console while the
1097                // `ERROR`/`WARNING` words of the `.db` loader do not:
1098                // errlog strips escapes when its console is not a
1099                // terminal (`errlog.c:672-681`) and a direct `fprintf`
1100                // never enters that pump.
1101                ctx.seat.faults.raise(TableFault::Undefined(name.clone()));
1102                if !ctx.suppressed {
1103                    let undefined = if crate::runtime::log::errlog_console_paints() {
1104                        "\x1b[35;1mundefined\x1b[0m"
1105                    } else {
1106                        "undefined"
1107                    };
1108                    crate::runtime::log::errlog_printf(&format!(
1109                        "macLib: macro {name} is {undefined} (expanding {} {})\n",
1110                        ctx.seat.kind, ctx.seat.name
1111                    ));
1112                }
1113                out.push('$');
1114                out.push('(');
1115                out.push_str(&name);
1116                // C writes the bare `)` under suppression and the
1117                // `,undefined)` tail otherwise (`macCore.c:920-928`), so
1118                // the knob changes the loader's own view of the value and
1119                // not just what the operator reads.
1120                if ctx.suppressed {
1121                    out.push(')');
1122                } else {
1123                    out.push_str(",undefined)");
1124                }
1125            }
1126        },
1127    }
1128
1129    if pop {
1130        ctx.table.pop_scope();
1131    }
1132    after
1133}
1134
1135/// Parse a `,key=val,key2=val2,...` scoped-definition tail into the
1136/// scope [`refer`] has already pushed. A bare `,key` with no `=`
1137/// defines nothing (C silently skips it).
1138///
1139/// Each definition lands in the table as the loop reaches it, so a later
1140/// one can reference an earlier one and not the other way round — C
1141/// `macPutValue` inside the `while (*r == ',')` loop (`macCore.c:850`).
1142/// Every definition also dirties the table, which is C's explicit
1143/// `handle->dirty = TRUE` on the next line and the reason the rest of
1144/// the enclosing string resolves from raw values.
1145///
1146/// Both halves of a definition are translated through
1147/// [`ExpandCtx::detached`], C's `MAC_ENTRY subs` (`macCore.c:820-826`):
1148/// a fault in a scoped name or value is neither warned about nor merged
1149/// into the enclosing expansion. `refname` is the seat name C uses for
1150/// the first half and the definition's own name the seat for the second.
1151fn parse_scoped(rest: &[char], level: usize, ctx: &mut ExpandCtx, refname: &str) {
1152    let mut k = 0;
1153    while k < rest.len() {
1154        if rest[k] != ',' {
1155            break;
1156        }
1157        k += 1; // step over ','
1158        // Scoped name: up to next top-level `=` or `,`.
1159        let seg = &rest[k..];
1160        let (name_part, tail) = match top_level_terminator(seg) {
1161            Some(t) => (&seg[..t], &seg[t..]),
1162            None => (seg, &seg[seg.len()..]),
1163        };
1164        let mut sname = String::new();
1165        ctx.detached(refname.to_string(), |ctx| {
1166            trans(name_part, level + 1, ctx, &mut sname);
1167        });
1168        k += name_part.len();
1169        if let Some('=') = tail.first() {
1170            let valseg = &tail[1..];
1171            let (val_part, _) = match top_level_comma(valseg) {
1172                Some(t) => (&valseg[..t], &valseg[t..]),
1173                None => (valseg, &valseg[valseg.len()..]),
1174            };
1175            let mut sval = String::new();
1176            ctx.detached(sname.clone(), |ctx| {
1177                trans(val_part, level + 1, ctx, &mut sval);
1178            });
1179            // C `macPutValue`, which types the new entry `"macro"` even
1180            // here (`macCore.c:283`) — `"scoped macro"` is the seat the
1181            // definition was translated under, not the entry's own type.
1182            ctx.table.put(&sname, KIND_MACRO, sval);
1183            k += 1 + val_part.len();
1184        }
1185        // else: bare `,name` — no value, defines nothing.
1186    }
1187}
1188
1189/// Index of the first top-level `=` or `,` in `body`, skipping any
1190/// `$(...)` / `${...}` nested reference.
1191fn top_level_terminator(body: &[char]) -> Option<usize> {
1192    let mut depth = 0usize;
1193    let mut i = 0;
1194    while i < body.len() {
1195        let c = body[i];
1196        if c == '$' && i + 1 < body.len() && (body[i + 1] == '(' || body[i + 1] == '{') {
1197            depth += 1;
1198            i += 2;
1199            continue;
1200        }
1201        if (c == ')' || c == '}') && depth > 0 {
1202            depth -= 1;
1203        } else if depth == 0 && (c == '=' || c == ',') {
1204            return Some(i);
1205        }
1206        i += 1;
1207    }
1208    None
1209}
1210
1211/// Index of the first top-level `,` in `body` (used to split a
1212/// default value from trailing scoped definitions).
1213fn top_level_comma(body: &[char]) -> Option<usize> {
1214    let mut depth = 0usize;
1215    let mut i = 0;
1216    while i < body.len() {
1217        let c = body[i];
1218        if c == '$' && i + 1 < body.len() && (body[i + 1] == '(' || body[i + 1] == '{') {
1219            depth += 1;
1220            i += 2;
1221            continue;
1222        }
1223        if (c == ')' || c == '}') && depth > 0 {
1224            depth -= 1;
1225        } else if depth == 0 && c == ',' {
1226            return Some(i);
1227        }
1228        i += 1;
1229    }
1230    None
1231}
1232
1233/// Split an IOC macro definition string into `(name, value)` pairs the
1234/// way libCom `macParseDefns` does (`macUtil.c:74-196`): commas separate
1235/// pairs and `=` separates a name from its value, but a separator inside
1236/// single/double quotes or escaped with a backslash is a literal, and
1237/// unquoted whitespace around names and values is trimmed. A name with
1238/// no `=` (e.g. `,FOO,`) is a deletion and yields `None`.
1239///
1240/// Quotes and escapes are stripped from both the name and the value. C
1241/// strips them from names in `macParseDefns` and from values later in
1242/// `macExpandString`; this port substitutes the value directly with no
1243/// second `macExpandString` pass, so both are stripped here to reach the
1244/// same observable substitution.
1245///
1246/// Exposed (re-exported as `iocsh::macro_defn_pairs`) so that other macLib
1247/// consumers — e.g. QSRV's `dbLoadGroup` macro parser — split definition
1248/// strings through this one owner of the `macParseDefns` grammar instead
1249/// of a second raw `split(',')` that would tear a quoted value on an
1250/// embedded comma. Callers that defer `$(...)` expansion to their own
1251/// `macExpandString` equivalent use these raw split pairs directly and do
1252/// NOT run `parse_macro_string`, which additionally substitutes the
1253/// environment eagerly.
1254pub fn macro_defn_pairs(s: &str) -> Vec<(String, Option<String>)> {
1255    #[derive(PartialEq, Clone, Copy)]
1256    enum St {
1257        PreName,
1258        InName,
1259        PreValue,
1260        InValue,
1261    }
1262    let mut out: Vec<(String, Option<String>)> = Vec::new();
1263    let mut state = St::PreName;
1264    let mut name = String::new();
1265    let mut value = String::new();
1266    // Unquoted whitespace seen mid-token: buffered so trailing whitespace
1267    // before a delimiter is dropped while interior whitespace is kept.
1268    let mut pending_ws = String::new();
1269    let mut quote: Option<char> = None;
1270
1271    // Enter the token from a "pre" state if needed and report whether it
1272    // is a VALUE: C removes quotes and escapes from names in place
1273    // "(unlike values, they will not be re-parsed)" (`macUtil.c:198-200`),
1274    // so a value keeps them for the expander's `discard` to strip.
1275    macro_rules! enter_token {
1276        () => {{
1277            match state {
1278                St::PreName => state = St::InName,
1279                St::PreValue => state = St::InValue,
1280                _ => {}
1281            }
1282            matches!(state, St::InValue)
1283        }};
1284    }
1285
1286    // Append a literal char to the token for the current state, entering
1287    // the token from a "pre" state if needed and flushing buffered ws.
1288    macro_rules! push_lit {
1289        ($c:expr) => {{
1290            match state {
1291                St::PreName => state = St::InName,
1292                St::PreValue => state = St::InValue,
1293                _ => {}
1294            }
1295            let target = if matches!(state, St::InName) {
1296                &mut name
1297            } else {
1298                &mut value
1299            };
1300            target.push_str(&pending_ws);
1301            pending_ws.clear();
1302            target.push($c);
1303        }};
1304    }
1305
1306    let chars: Vec<char> = s.chars().collect();
1307    let mut i = 0;
1308    while i < chars.len() {
1309        let c = chars[i];
1310
1311        // Escape: `\X` makes `X` a literal (and not a delimiter).
1312        // Quotes do not suppress escapes.
1313        if c == '\\' && i + 1 < chars.len() {
1314            if enter_token!() {
1315                push_lit!('\\');
1316            }
1317            push_lit!(chars[i + 1]);
1318            i += 2;
1319            continue;
1320        }
1321
1322        // Inside a quote: every char is literal until the matching quote.
1323        if let Some(q) = quote {
1324            if c == q {
1325                quote = None;
1326                if enter_token!() {
1327                    push_lit!(c);
1328                }
1329            } else {
1330                push_lit!(c);
1331            }
1332            i += 1;
1333            continue;
1334        }
1335        if c == '\'' || c == '"' {
1336            quote = Some(c);
1337            // An opening quote also begins the token (e.g. `=""`).
1338            if enter_token!() {
1339                push_lit!(c);
1340            }
1341            i += 1;
1342            continue;
1343        }
1344
1345        match state {
1346            St::PreName => {
1347                if c == '=' {
1348                    state = St::PreValue;
1349                } else if !(crate::runtime::stdlib::c_isspace(c) || c == ',') {
1350                    state = St::InName;
1351                    name.push(c);
1352                }
1353                // leading whitespace and bare commas: skip
1354            }
1355            St::InName => {
1356                if c == '=' {
1357                    pending_ws.clear();
1358                    state = St::PreValue;
1359                } else if c == ',' {
1360                    // name with no '=' → deletion
1361                    pending_ws.clear();
1362                    out.push((std::mem::take(&mut name), None));
1363                    state = St::PreName;
1364                } else if crate::runtime::stdlib::c_isspace(c) {
1365                    pending_ws.push(c);
1366                } else {
1367                    name.push_str(&pending_ws);
1368                    pending_ws.clear();
1369                    name.push(c);
1370                }
1371            }
1372            St::PreValue => {
1373                if c == ',' {
1374                    out.push((std::mem::take(&mut name), Some(String::new())));
1375                    state = St::PreName;
1376                } else if !crate::runtime::stdlib::c_isspace(c) {
1377                    state = St::InValue;
1378                    value.push(c);
1379                }
1380                // leading value whitespace: skip
1381            }
1382            St::InValue => {
1383                if c == ',' {
1384                    pending_ws.clear();
1385                    out.push((std::mem::take(&mut name), Some(std::mem::take(&mut value))));
1386                    state = St::PreName;
1387                } else if crate::runtime::stdlib::c_isspace(c) {
1388                    pending_ws.push(c);
1389                } else {
1390                    value.push_str(&pending_ws);
1391                    pending_ws.clear();
1392                    value.push(c);
1393                }
1394            }
1395        }
1396        i += 1;
1397    }
1398
1399    // Flush the token open at end of string.
1400    match state {
1401        St::PreName => {}
1402        St::InName => out.push((std::mem::take(&mut name), None)),
1403        St::PreValue => out.push((std::mem::take(&mut name), Some(String::new()))),
1404        St::InValue => out.push((std::mem::take(&mut name), Some(std::mem::take(&mut value)))),
1405    }
1406    out
1407}
1408
1409#[cfg(test)]
1410mod tests {
1411    use super::*;
1412
1413    // `expand_macros` reports every undefined macro so a hard-fail
1414    // caller (autosave) can surface it; the text still carries the
1415    // C `$(name,undefined)` placeholder for the no-fail callers.
1416    #[test]
1417    fn expand_macros_reports_undefined_names() {
1418        let macros = HashMap::new();
1419        let r = expand_macros("$(A)$(B=def)$(C)", &macros, MacroExpandOptions::default());
1420        assert_eq!(r.text, "$(A,undefined)def$(C,undefined)");
1421        // B had a default → not undefined; A and C are, in scan order.
1422        assert_eq!(r.faults.undefined, vec!["A".to_string(), "C".to_string()]);
1423    }
1424
1425    // The default options match `.db` parse semantics: no env fallback,
1426    // and `$$` is NOT an escape (macLib leaves it verbatim).
1427    #[test]
1428    fn expand_macros_default_opts_leave_dollar_dollar_verbatim() {
1429        let macros = HashMap::new();
1430        assert_eq!(substitute_macros("$$100", &macros), "$$100");
1431        let r = expand_macros("$$100", &macros, MacroExpandOptions::default());
1432        assert_eq!(r.text, "$$100");
1433        assert!(r.faults.undefined.is_empty());
1434    }
1435
1436    // `env_fallback` resolves an otherwise-unset name from the process
1437    // environment (C `macCreateHandle(&h, environ)`), at the same level
1438    // as a defined macro — before any default.
1439    #[test]
1440    fn expand_macros_env_fallback_opt_in() {
1441        let var = "_EPICS_LIBCOM_RS_MACRO_ENV_TEST";
1442        // Off by default: unset macro stays undefined even with the env set.
1443        unsafe { std::env::set_var(var, "FROM_ENV") };
1444        let macros = HashMap::new();
1445        let off = expand_macros(&format!("$({var})"), &macros, MacroExpandOptions::default());
1446        assert_eq!(off.text, format!("$({var},undefined)"));
1447        // On: resolves from the environment.
1448        let on = expand_macros(
1449            &format!("$({var})"),
1450            &macros,
1451            MacroExpandOptions {
1452                env_fallback: true,
1453                ..MacroExpandOptions::default()
1454            },
1455        );
1456        assert_eq!(on.text, "FROM_ENV");
1457        assert!(on.faults.undefined.is_empty());
1458        unsafe { std::env::remove_var(var) };
1459    }
1460}