main
Drew Owens 0.9.3 (93ef8bb): attached is not ready (readiness gate, host_app_gone, lock files); a declined quit on a never-had-a-do… 8483ed6 20d ago
//! The tiny expression grammar behind `swx_com_eval`.
//!
//! The PowerShell sidecar's escape hatch was `Invoke-Expression` — a whole
//! script host, with `$sw` bound. There is no PowerShell in this bridge any
//! more, and Rust has no eval, so the escape hatch is now exactly the shape
//! that prototyping a verb actually needs and nothing more:
//!
//! ```text
//! $sw.RevisionNumber()
//! $sw.ActiveDoc.GetTitle()
//! $doc.Extension.CustomPropertyManager("").GetNames()
//! $sw.OpenDoc6("C:\\a.sldprt", 1, 1, "", 0, 0)
//! $doc.Extension.SelectByID2("Front Plane", "PLANE", 0, 0, 0, false, 0, null, 0)
//! ```
//!
//! A dotted chain of members, rooted at `$sw` (the application) or `$doc` (the
//! active document), with string / number / boolean literal arguments. That is
//! strictly less power than PowerShell had — and strictly more auditable, which
//! for a verb that drives a live CAD application is the better trade.
//!
//! Parsing lives here, outside `cfg(windows)`, so it is testable on any host.

use super::host::HostError;

#[derive(Debug, PartialEq, Clone)]
pub enum Literal {
    Str(String),
    Int(i32),
    Num(f64),
    Bool(bool),
    /// `null` / `nothing` — a NULL DISPATCH variant (`VT_DISPATCH` with a null
    /// `pdispVal`), which is how a late-bound caller passes `Nothing` for an
    /// `[in]` object parameter. SOLIDWORKS uses those for callout and
    /// import-data arguments; without it, half the interesting signatures are
    /// unreachable from the escape hatch.
    ///
    /// **This used to say VT_EMPTY, and that was wrong.** Measured live
    /// 2026-09-17: `LoadFile4`'s `importData` passed as `VT_EMPTY` answers
    /// `Type mismatch. (0x80020005)` on that argument. "Omitted" and "an object
    /// that is Nothing" are different things to IDispatch, and the documented use
    /// here — `SelectByID2`'s callout parameter — is the second kind.
    Null,
}

#[derive(Debug, PartialEq, Clone)]
pub struct Segment {
    pub name: String,
    /// Empty for a bare member access (`.ActiveDoc`); the parser does not
    /// distinguish `.Foo` from `.Foo()` because IDispatch does not either.
    pub args: Vec<Literal>,
}

/// `$sw.Extension.Foo("bar", 3, true)` -> `("sw", [Extension, Foo(...)])`.
pub fn parse_chain(expr: &str) -> Result<(String, Vec<Segment>), HostError> {
    let expr = expr.trim();
    let rest = expr.strip_prefix('$').ok_or_else(|| {
        HostError::msg("expression must start with $sw or $doc, e.g. \"$sw.RevisionNumber()\"")
    })?;
    let mut chars = rest.chars().peekable();

    let mut root = String::new();
    while let Some(&c) = chars.peek() {
        if c == '.' {
            break;
        }
        root.push(c);
        chars.next();
    }
    if root.is_empty() {
        return Err(HostError::msg("expression must name a root: $sw or $doc"));
    }

    let mut segments = Vec::new();
    while chars.peek() == Some(&'.') {
        chars.next();
        let mut name = String::new();
        while let Some(&c) = chars.peek() {
            if c.is_alphanumeric() || c == '_' {
                name.push(c);
                chars.next();
            } else {
                break;
            }
        }
        if name.is_empty() {
            return Err(HostError::msg("expected a member name after '.'"));
        }
        let mut args = Vec::new();
        if chars.peek() == Some(&'(') {
            chars.next();
            loop {
                while chars.peek().is_some_and(|c| c.is_whitespace() || *c == ',') {
                    chars.next();
                }
                match chars.peek().copied() {
                    None => return Err(HostError::msg("unclosed '(' in expression")),
                    Some(')') => {
                        chars.next();
                        break;
                    }
                    Some(quote @ ('"' | '\'')) => {
                        chars.next();
                        let mut s = String::new();
                        loop {
                            match chars.next() {
                                None => return Err(HostError::msg("unterminated string literal")),
                                // \\ and \" survive the JSON transport as one char each;
                                // inside the expression a backslash is literal, which is
                                // what a Windows path needs.
                                Some(c) if c == quote => break,
                                Some(c) => s.push(c),
                            }
                        }
                        args.push(Literal::Str(s));
                    }
                    Some(_) => {
                        let mut tok = String::new();
                        while let Some(&c) = chars.peek() {
                            if c == ',' || c == ')' {
                                break;
                            }
                            tok.push(c);
                            chars.next();
                        }
                        let tok = tok.trim();
                        args.push(match tok.to_ascii_lowercase().as_str() {
                            "true" => Literal::Bool(true),
                            "false" => Literal::Bool(false),
                            "null" | "nothing" | "empty" => Literal::Null,
                            _ => {
                                if let Ok(i) = tok.parse::<i32>() {
                                    Literal::Int(i)
                                } else if let Ok(f) = tok.parse::<f64>() {
                                    Literal::Num(f)
                                } else {
                                    return Err(HostError::msg(format!(
                                        "unsupported literal '{tok}' — arguments may be quoted \
                                         strings, numbers or true/false"
                                    )));
                                }
                            }
                        });
                    }
                }
            }
        }
        segments.push(Segment { name, args });
    }
    if segments.is_empty() {
        return Err(HostError::msg(
            "expression must call at least one member, e.g. $sw.RevisionNumber()",
        ));
    }
    Ok((root, segments))
}

/// Which COM members `swx_com_eval` will evaluate — an ALLOWLIST.
///
/// The escape hatch exists to PROVE OUT a capability against a real install
/// before it becomes a verb, which in practice means READS: version strings,
/// property queries, enumeration, signature discovery. Every finding in this
/// bridge's parity report was made that way.
///
/// It started as a denylist of the members that write, and that was the wrong
/// shape. A denylist has to anticipate every dangerous member of an API with
/// thousands of them, and it did not: `SendMsgToUser2` puts a MODAL on the
/// user's screen and wedges the com host until the watchdog kills it — the
/// exact disruption the whole effort exists to prevent — while
/// `SetUserPreferenceToggle` permanently changes their settings and
/// `ForceRebuild3` rebuilds their model. None were on the list, because nobody
/// thought of them. An allowlist fails the other way: a member nobody thought
/// of is REFUSED, with a message saying how to add it.
///
/// Two rules, both conservative:
///   * a member whose name begins `Get`, `Is`, `Has`, `Can` or `Enum` reads;
///   * anything else must be named in [`READ_MEMBERS`].
pub const READ_MEMBERS: &[&str] = &[
    // application + document navigation (properties that return objects)
    "activedoc", "extension", "frame", "sketchmanager", "featuremanager",
    "configurationmanager", "activeconfiguration", "interferencedetectionmanager",
    "custompropertymanager", "createmassproperty", "firstfeature", "featurebyname",
    "parameter", "visible", "commandinprogress", "framestate", "revisionnumber",
    // names and scalar reads
    "name", "name2", "value", "systemvalue", "count", "typename", "equation",
    // mass / geometry reads
    "mass", "volume", "surfacearea", "density", "centerofmass", "components",
    "referencedconfiguration",
    // rebuild / health reads
    "needsrebuild", "needsrebuild2",
    // startup: has SOLIDWORKS finished starting (add-ins included)? A read, and
    // the signal the readiness gate polls — measured on 2026-10-01 to be needed:
    // document commands sent 8 s after COM attach crashed SOLIDWORKS while its
    // 3DEXPERIENCE add-in was still initialising.
    "startupprocesscompleted",
];

/// Members explicitly called out as REFUSED, so the error can say why rather
/// than just "not on the list". Everything not allowed is refused regardless;
/// this is only for the ones worth naming.
pub const NAMED_HAZARDS: &[(&str, &str)] = &[
    ("saveas", "writes a file, and SaveAs3 picks its format from the destination extension"),
    ("saveas2", "writes a file"),
    ("saveas3", "writes a file, bypassing the blocked-extension check and Copy mode"),
    ("saveas4", "writes a file"),
    ("save", "writes the user's file"),
    ("save2", "writes the user's file"),
    ("save3", "writes the user's file"),
    ("savepackandgo", "writes files"),
    ("closedoc", "discards unsaved work"),
    ("closealldocuments", "discards unsaved work in every open document"),
    ("exitapp", "quits SOLIDWORKS — which on a Connected edition this bridge cannot restart"),
    ("sendmsgtouser", "puts a MODAL DIALOG on the user's screen and blocks until someone answers it"),
    ("sendmsgtouser2", "puts a MODAL DIALOG on the user's screen and blocks until someone answers it"),
    ("setuserpreferencetoggle", "permanently changes the user's SOLIDWORKS preferences"),
    ("setuserpreferenceinteger", "permanently changes the user's SOLIDWORKS preferences"),
    ("forcerebuild3", "rebuilds the user's model"),
    ("editrebuild3", "rebuilds the user's model"),
    ("setsaveflag", "marks the user's document dirty"),
    ("deleteselection", "deletes geometry"),
    ("deleteselection2", "deletes geometry"),
    ("editdelete", "deletes geometry"),
];

/// Is this member a read? See [`READ_MEMBERS`].
pub fn is_read_member(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    if READ_MEMBERS.contains(&lower.as_str()) {
        return true;
    }
    ["get", "is", "has", "can", "enum"].iter().any(|p| lower.starts_with(p))
}

/// The first member of `expr` this bridge will not evaluate, with the reason.
/// Case-insensitive, because IDispatch name resolution is.
pub fn refused_member(expr: &str) -> Option<(String, String)> {
    let (_, segments) = parse_chain(expr).ok()?;
    segments.iter().find(|s| !is_read_member(&s.name)).map(|s| {
        let lower = s.name.to_ascii_lowercase();
        let why = NAMED_HAZARDS
            .iter()
            .find(|(n, _)| *n == lower)
            .map(|(_, w)| (*w).to_string())
            .unwrap_or_else(|| {
                "it is not on the read allowlist, and this verb evaluates reads only".to_string()
            });
        (s.name.clone(), why)
    })
}

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

    fn seg(name: &str, args: Vec<Literal>) -> Segment {
        Segment { name: name.to_string(), args }
    }

    #[test]
    fn parses_a_bare_call() {
        let (root, segs) = parse_chain("$sw.RevisionNumber()").unwrap();
        assert_eq!(root, "sw");
        assert_eq!(segs, vec![seg("RevisionNumber", vec![])]);
    }

    #[test]
    fn parses_a_chain_with_mixed_literals() {
        let (root, segs) =
            parse_chain("$doc.Extension.CustomPropertyManager(\"\").Add3('PartNo', 30, 1.5, true)")
                .unwrap();
        assert_eq!(root, "doc");
        assert_eq!(segs.len(), 3);
        assert_eq!(segs[1], seg("CustomPropertyManager", vec![Literal::Str(String::new())]));
        assert_eq!(
            segs[2].args,
            vec![
                Literal::Str("PartNo".into()),
                Literal::Int(30),
                Literal::Num(1.5),
                Literal::Bool(true)
            ]
        );
    }

    #[test]
    fn keeps_backslashes_in_windows_paths() {
        let (_, segs) = parse_chain(r#"$sw.OpenDoc6("C:\parts\a.sldprt", 1, 1, "", 0, 0)"#).unwrap();
        assert_eq!(segs[0].args[0], Literal::Str(r"C:\parts\a.sldprt".into()));
        assert_eq!(segs[0].args.len(), 6);
    }

    #[test]
    fn bare_member_access_needs_no_parens() {
        let (_, segs) = parse_chain("$sw.ActiveDoc.GetTitle()").unwrap();
        assert_eq!(segs[0], seg("ActiveDoc", vec![]));
        assert_eq!(segs[1].name, "GetTitle");
    }

    #[test]
    fn parses_null_for_omitted_object_arguments() {
        let (_, segs) =
            parse_chain(r#"$doc.Extension.SelectByID2("Front Plane","PLANE",0,0,0,false,0,null,0)"#)
                .unwrap();
        assert_eq!(segs[1].args.len(), 9);
        assert_eq!(segs[1].args[7], Literal::Null);
        // the spellings a caller actually reaches for, case-insensitively
        for spelling in ["null", "NULL", "Nothing", "empty"] {
            let (_, s) = parse_chain(&format!("$sw.Foo({spelling})")).unwrap();
            assert_eq!(s[0].args[0], Literal::Null, "{spelling}");
        }
    }

    #[test]
    fn the_escape_hatch_evaluates_reads_and_nothing_else() {
        // A denylist had to anticipate every dangerous member of an API with
        // thousands; it missed the ones that raise a modal on the user's screen,
        // change their preferences, and rebuild their model. An allowlist fails
        // the other way.
        for bad in [
            r#"$doc.SaveAs3("C:\x.wrl", 0, 1)"#,
            r#"$doc.Extension.SaveAs3("C:\x.step", 0, 1)"#,
            "$doc.Save3(1, 0, 0)",
            "$sw.CloseAllDocuments(true)",
            "$sw.ExitApp()",
            r#"$sw.CloseDoc("Part1")"#,
            "$doc.EditDelete()",
            // the four a denylist missed
            r#"$sw.SendMsgToUser2("hi", 1, 1)"#,
            "$sw.SetUserPreferenceToggle(1, true)",
            "$doc.SetSaveFlag()",
            "$doc.ForceRebuild3(false)",
            "$doc.EditRebuild3()",
            // ...and one nobody has thought of yet, which is the point
            "$doc.SomeFutureDestructiveMember()",
        ] {
            assert!(refused_member(bad).is_some(), "should be refused: {bad}");
        }
        // Reads — what the hatch is actually for — still go through.
        for ok in [
            "$sw.RevisionNumber()",
            "$doc.Extension.CustomPropertyManager(\"\").GetNames()",
            "$doc.GetBodies2(0, true)",
            "$sw.Frame().GetHWnd()",
            "$doc.FirstFeature().GetNextFeature().Name()",
            "$doc.Extension.CreateMassProperty().Mass()",
            "$sw.IsPLMConnected()",
            "$doc.GetEquationMgr().GetCount()",
        ] {
            assert!(refused_member(ok).is_none(), "should be allowed: {ok}");
        }
        // The refusal explains ITSELF for the hazards worth naming.
        let (m, why) = refused_member(r#"$sw.SendMsgToUser2("x", 1, 1)"#).unwrap();
        assert_eq!(m, "SendMsgToUser2");
        assert!(why.contains("MODAL"), "{why}");
        // An unparseable expression names no member; the parser rejects it later.
        assert!(refused_member("not an expression").is_none());
    }


    #[test]
    fn rejects_what_it_cannot_honestly_run() {
        for bad in [
            "sw.RevisionNumber()",                 // no $
            "$",                                   // no root
            "$sw",                                 // no member
            "$sw.Foo(",                            // unclosed
            "$sw.Foo(\"unterminated)",             // unterminated string
            "$sw.Foo($bar)",                       // no variables
            "$sw.",                                // no member name
            "Remove-Item C:\\",                    // not an expression at all
        ] {
            assert!(parse_chain(bad).is_err(), "should have been rejected: {bad}");
        }
    }
}