epics-base-rs 0.20.2

Pure Rust EPICS IOC core — record system, database, iocsh, calc engine
Documentation
use std::collections::HashMap;

use super::error::{AutosaveError, AutosaveResult};

/// Macro expansion context for `$(KEY)` and `${KEY}` patterns.
#[derive(Debug, Clone, Default)]
pub struct MacroContext {
    macros: HashMap<String, String>,
}

impl MacroContext {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn from_map(macros: HashMap<String, String>) -> Self {
        Self { macros }
    }

    /// Parse inline macro definitions like `"P=IOC:,M=m1"`.
    pub fn parse_inline(s: &str) -> HashMap<String, String> {
        let mut map = HashMap::new();
        if s.trim().is_empty() {
            return map;
        }
        for pair in s.split(',') {
            let pair = pair.trim();
            if let Some(eq_pos) = pair.find('=') {
                let key = pair[..eq_pos].trim().to_string();
                let val = pair[eq_pos + 1..].trim().to_string();
                if !key.is_empty() {
                    map.insert(key, val);
                }
            }
        }
        map
    }

    /// Create a child context by merging additional macros (child overrides parent).
    pub fn with_overrides(&self, overrides: &HashMap<String, String>) -> Self {
        let mut merged = self.macros.clone();
        merged.extend(overrides.iter().map(|(k, v)| (k.clone(), v.clone())));
        Self { macros: merged }
    }

    /// Expand `$(...)` / `${...}` macro references in `input` using the
    /// crate's single macLib engine
    /// ([`crate::server::db_loader::expand_macros`]), so autosave `.req`
    /// expansion gets the full language — nested defaults
    /// (`$(BAR=$(FOO))`), scoped definitions (`$(X,X=$(Y))`),
    /// name-of-name (`$($(WHICH))`), chained expansion, single-quote
    /// suppression — not just the flat `$(KEY)` / `$(KEY=default)` subset.
    ///
    /// Autosave-specific options: `env_fallback` (C `macEnvExpand`) and
    /// `dollar_escape` (`$$` → literal `$`, a `.req` convenience). An
    /// undefined macro with no default is a hard error (the engine
    /// records it; the placeholder text is discarded).
    pub fn expand(&self, input: &str, source: &str, line: usize) -> AutosaveResult<String> {
        let result = crate::server::db_loader::expand_macros(
            input,
            &self.macros,
            crate::server::db_loader::MacroExpandOptions {
                env_fallback: true,
                dollar_escape: true,
            },
        );
        if let Some(key) = result.undefined.first() {
            return Err(AutosaveError::UndefinedMacro {
                key: key.clone(),
                source: source.to_string(),
                line,
            });
        }
        Ok(result.text)
    }

    pub fn get(&self, key: &str) -> Option<&str> {
        self.macros.get(key).map(|s| s.as_str())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_simple_expand() {
        let ctx = MacroContext::from_map([("P".into(), "IOC:".into())].into());
        assert_eq!(ctx.expand("$(P)temp", "test", 1).unwrap(), "IOC:temp");
    }

    #[test]
    fn test_default_value() {
        let ctx = MacroContext::new();
        assert_eq!(ctx.expand("$(P=DEFAULT)", "test", 1).unwrap(), "DEFAULT");
    }

    #[test]
    fn test_undefined_error() {
        let ctx = MacroContext::new();
        let err = ctx.expand("$(UNDEF)", "test.req", 5).unwrap_err();
        match err {
            AutosaveError::UndefinedMacro { key, source, line } => {
                assert_eq!(key, "UNDEF");
                assert_eq!(source, "test.req");
                assert_eq!(line, 5);
            }
            _ => panic!("expected UndefinedMacro"),
        }
    }

    #[test]
    fn test_parse_inline() {
        let map = MacroContext::parse_inline("P=IOC:,M=m1");
        assert_eq!(map.get("P").unwrap(), "IOC:");
        assert_eq!(map.get("M").unwrap(), "m1");
    }

    #[test]
    fn test_dollar_literal() {
        let ctx = MacroContext::new();
        assert_eq!(ctx.expand("$$100", "test", 1).unwrap(), "$100");
    }

    #[test]
    fn test_both_pv_and_path() {
        let ctx = MacroContext::from_map(
            [
                ("P".into(), "IOC:".into()),
                ("FILE".into(), "settings".into()),
            ]
            .into(),
        );
        assert_eq!(
            ctx.expand("${FILE}/$(P)temp", "test", 1).unwrap(),
            "settings/IOC:temp"
        );
    }

    // The full macLib language now reaches autosave .req expansion via
    // the shared engine — previously `expand` only did the flat
    // `$(KEY)` / `$(KEY=default)` subset.

    #[test]
    fn nested_default_is_expanded() {
        // `${BAR=${FOO}}`: BAR unset, default is itself a macro ref.
        let ctx = MacroContext::from_map([("FOO".into(), "fromfoo".into())].into());
        assert_eq!(ctx.expand("${BAR=${FOO}}", "test", 1).unwrap(), "fromfoo");
    }

    #[test]
    fn scoped_definition_is_honored() {
        // `$(INNER,A=$(FOO))`: A is defined only for this reference and
        // its value is itself expanded.
        let ctx = MacroContext::from_map(
            [
                ("INNER".into(), "$(A)".into()),
                ("FOO".into(), "scoped".into()),
            ]
            .into(),
        );
        assert_eq!(
            ctx.expand("$(INNER,A=$(FOO))", "test", 1).unwrap(),
            "scoped"
        );
    }

    #[test]
    fn resolved_value_is_chained() {
        // P=$(Q), Q=IOC: → $(P) expands through to IOC:.
        let ctx = MacroContext::from_map(
            [("P".into(), "$(Q)".into()), ("Q".into(), "IOC:".into())].into(),
        );
        assert_eq!(ctx.expand("$(P)TEMP", "test", 1).unwrap(), "IOC:TEMP");
    }

    #[test]
    fn undefined_without_default_still_errors() {
        let ctx = MacroContext::new();
        let err = ctx.expand("$(NOPE)", "f.req", 7).unwrap_err();
        match err {
            AutosaveError::UndefinedMacro { key, source, line } => {
                assert_eq!(key, "NOPE");
                assert_eq!(source, "f.req");
                assert_eq!(line, 7);
            }
            other => panic!("expected UndefinedMacro, got {other:?}"),
        }
    }
}