Skip to main content

cordis_include/
expr.rs

1//! The `!!js` expression subset evaluated at config hand-off.
2//!
3//! Upstream evaluates `!!js` scalars with real JavaScript against the
4//! loader context. The expressions that actually appear in the shipped
5//! bundles only reference `process.*`, so this module evaluates that
6//! subset synchronously — at the same hand-off point as the
7//! `${{ env.NAME }}` interpolation ([`crate::interpolate`]) — with a
8//! hand-written lexer and parser. Expressions referencing injected
9//! context (`ctx.*`, `dshHomePath(…)`) are deferred to the owning
10//! plugin's lazy evaluation and fail here with a clear subset error.
11//!
12//! Supported syntax (JavaScript precedence):
13//!
14//! - the ternary `cond ? then : else`
15//! - `??`, `||`, `&&` with JavaScript truthiness (empty strings and zero
16//!   are falsy); like JavaScript, `??` may not be mixed with `||`/`&&`
17//!   without parentheses
18//! - strict equality `===` / `!==` (loose `==`/`!=` is outside the subset)
19//! - unary `!`
20//! - string literals in single or double quotes, decimal integers and
21//!   floats, `true`/`false`/`null`/`undefined`
22//! - member access limited to `process.platform`, `process.env.NAME`
23//!   (unset variables are `undefined`), and `process.cwd()`
24//!
25//! Values are [`Node`]s directly; `null` and `undefined` both map to
26//! [`Node::Null`], which is also what `??` triggers on.
27
28use crate::error::{IncludeError, Result};
29use crate::node::{Node, NodeMap};
30
31/// Evaluate one `!!js` expression, reading environment variables from the
32/// process environment.
33///
34/// # Errors
35///
36/// Syntax errors, operators or references outside the supported subset,
37/// and an unreadable working directory (for `process.cwd()`) fail with
38/// [`IncludeError::JsExpression`].
39pub fn evaluate(source: &str) -> Result<Node> {
40    evaluate_with(source, &|name| std::env::var(name).ok())
41}
42
43/// Evaluate one `!!js` expression against a caller-supplied environment
44/// lookup, which maps a variable name to its value (`None` when unset).
45pub fn evaluate_with(source: &str, env: &dyn Fn(&str) -> Option<String>) -> Result<Node> {
46    let mut evaluator = Evaluator { source, env };
47    let ast = evaluator.parse()?;
48    evaluator.value(&ast)
49}
50
51/// Recursively evaluate every [`Node::Expr`] in a value tree, leaving all
52/// other nodes untouched — the expression twin of
53/// [`crate::interpolate::interpolate_node`], applied when config is
54/// handed to a plugin.
55pub fn evaluate_node(node: &Node) -> Result<Node> {
56    evaluate_node_with(node, &|name| std::env::var(name).ok())
57}
58
59/// [`evaluate_node`] with a caller-supplied environment lookup.
60pub fn evaluate_node_with(node: &Node, env: &dyn Fn(&str) -> Option<String>) -> Result<Node> {
61    match node {
62        Node::Expr(source) => evaluate_with(source, env),
63        Node::Array(items) => items
64            .iter()
65            .map(|item| evaluate_node_with(item, env))
66            .collect::<Result<Vec<_>>>()
67            .map(Node::Array),
68        Node::Object(map) => {
69            let mut evaluated = NodeMap::new();
70            for (key, value) in map {
71                evaluated.insert(key.clone(), evaluate_node_with(value, env)?);
72            }
73            Ok(Node::Object(evaluated))
74        }
75        other => Ok(other.clone()),
76    }
77}
78
79// ---------------------------------------------------------------- lexer ---
80
81/// One lexical token; spans are unnecessary because errors name the
82/// offending text.
83#[derive(Debug, Clone, PartialEq)]
84enum Token {
85    /// An operator or punctuation this subset supports (`??`, `===`, `(`…).
86    Punct(&'static str),
87    /// A quoted string literal, unescaped.
88    Str(String),
89    /// A decimal integer literal.
90    Int(i64),
91    /// A decimal float literal.
92    Float(f64),
93    /// An identifier or keyword.
94    Ident(String),
95    /// Loose equality (`==` / `!=`), rejected by the parser with a
96    /// targeted message.
97    LooseEq(&'static str),
98    /// An operator JavaScript has and this subset does not (`+`, `<`…).
99    Unsupported(String),
100}
101
102/// Split `source` into tokens.
103fn lex(source: &str) -> Vec<Token> {
104    let mut tokens = Vec::new();
105    let mut rest = source;
106    while let Some(character) = rest.chars().next() {
107        if character.is_whitespace() {
108            rest = &rest[character.len_utf8()..];
109            continue;
110        }
111        // Multi-character operators must win over their prefixes (`===`
112        // before `==` before `!`).
113        let (token, width): (Token, usize) = if rest.starts_with("===") {
114            (Token::Punct("==="), 3)
115        } else if rest.starts_with("!==") {
116            (Token::Punct("!=="), 3)
117        } else if rest.starts_with("??") {
118            (Token::Punct("??"), 2)
119        } else if rest.starts_with("||") {
120            (Token::Punct("||"), 2)
121        } else if rest.starts_with("&&") {
122            (Token::Punct("&&"), 2)
123        } else if rest.starts_with("==") {
124            (Token::LooseEq("=="), 2)
125        } else if rest.starts_with("!=") {
126            (Token::LooseEq("!="), 2)
127        } else if matches!(character, '?' | ':' | '!' | '(' | ')' | '.') {
128            let punct = match character {
129                '?' => "?",
130                ':' => ":",
131                '!' => "!",
132                '(' => "(",
133                ')' => ")",
134                _ => ".",
135            };
136            (Token::Punct(punct), punct.len())
137        } else if character == '\'' || character == '"' {
138            match lex_string(rest) {
139                Some((text, width)) => (Token::Str(text), width),
140                None => (Token::Unsupported(rest.to_owned()), rest.len()),
141            }
142        } else if character.is_ascii_digit() {
143            lex_number(rest)
144        } else if character.is_ascii_alphabetic() || character == '_' || character == '$' {
145            let ident = rest
146                .chars()
147                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '$')
148                .collect::<String>();
149            let width = ident.len();
150            (Token::Ident(ident), width)
151        } else {
152            // Anything else is JavaScript this subset does not take.
153            (
154                Token::Unsupported(character.to_string()),
155                character.len_utf8(),
156            )
157        };
158        rest = &rest[width..];
159        tokens.push(token);
160    }
161    tokens
162}
163
164/// One quoted string literal with JS-style escapes; `None` when the
165/// string is unterminated. Returns the unescaped text and the consumed
166/// width.
167fn lex_string(rest: &str) -> Option<(String, usize)> {
168    let quote = rest.chars().next()?;
169    let mut text = String::new();
170    let mut width = quote.len_utf8();
171    let mut chars = rest[width..].chars();
172    while let Some(character) = chars.next() {
173        width += character.len_utf8();
174        if character == quote {
175            return Some((text, width));
176        }
177        if character == '\n' {
178            return None;
179        }
180        if character == '\\' {
181            let escaped = chars.next()?;
182            width += escaped.len_utf8();
183            text.push(match escaped {
184                'n' => '\n',
185                't' => '\t',
186                'r' => '\r',
187                '0' => '\0',
188                // Unknown escapes collapse to the escaped character, like
189                // JavaScript string literals.
190                other => other,
191            });
192        } else {
193            text.push(character);
194        }
195    }
196    None
197}
198
199/// One decimal number: integer when it fits `i64` and has no fraction or
200/// exponent, float otherwise.
201fn lex_number(rest: &str) -> (Token, usize) {
202    fn digits(slice: &str) -> usize {
203        slice
204            .chars()
205            .take_while(|c| c.is_ascii_digit())
206            .map(char::len_utf8)
207            .sum()
208    }
209    let mut width = digits(rest);
210    let mut is_float = false;
211    if rest[width..].starts_with('.') && rest[width + 1..].starts_with(|c: char| c.is_ascii_digit())
212    {
213        is_float = true;
214        width += 1 + digits(&rest[width + 1..]);
215    }
216    if let Some(tail) = rest[width..].strip_prefix(['e', 'E']) {
217        let signed = tail.strip_prefix(['+', '-']).unwrap_or(tail);
218        let exponent = digits(signed);
219        if exponent > 0 {
220            is_float = true;
221            width += 1 + (tail.len() - signed.len()) + exponent;
222        }
223    }
224    let text = &rest[..width];
225    if !is_float {
226        if let Ok(int) = text.parse::<i64>() {
227            return (Token::Int(int), width);
228        }
229    }
230    match text.parse::<f64>() {
231        Ok(float) => (Token::Float(float), width),
232        Err(_) => (Token::Unsupported(text.to_owned()), width),
233    }
234}
235
236// ----------------------------------------------------------------- ast ---
237
238/// One parsed expression.
239enum Ast {
240    /// A literal value.
241    Literal(Node),
242    /// `process.platform`.
243    Platform,
244    /// `process.env.NAME`.
245    Env(String),
246    /// `process.cwd()`.
247    Cwd,
248    /// `!expr`.
249    Not(Box<Ast>),
250    /// `left ?? right`.
251    Coalesce(Box<Ast>, Box<Ast>),
252    /// `left || right`.
253    Or(Box<Ast>, Box<Ast>),
254    /// `left && right`.
255    And(Box<Ast>, Box<Ast>),
256    /// `left === right` (or `!==` with `negated`).
257    StrictEq {
258        left: Box<Ast>,
259        right: Box<Ast>,
260        negated: bool,
261    },
262    /// `condition ? then : alternative`.
263    Ternary {
264        condition: Box<Ast>,
265        then: Box<Ast>,
266        alternative: Box<Ast>,
267    },
268}
269
270/// Which coalescing/logical operators one parenthesized region used, to
271/// reject JavaScript's illegal `??` / `||` / `&&` mixes.
272#[derive(Default)]
273struct Mixing {
274    saw_coalesce: bool,
275    saw_logical: bool,
276}
277
278/// Parser plus evaluator state: the raw source (for error context) and
279/// the environment lookup.
280struct Evaluator<'a> {
281    source: &'a str,
282    env: &'a dyn Fn(&str) -> Option<String>,
283}
284
285impl Evaluator<'_> {
286    fn error(&self, message: impl Into<String>) -> IncludeError {
287        IncludeError::JsExpression {
288            expression: self.source.to_owned(),
289            message: message.into(),
290        }
291    }
292
293    /// Parse the whole source into one expression.
294    fn parse(&mut self) -> Result<Ast> {
295        let tokens = lex(self.source);
296        let mut parser = Parser {
297            tokens: &tokens,
298            position: 0,
299            evaluator: self,
300        };
301        let mut mixing = Mixing::default();
302        let ast = parser.ternary(&mut mixing)?;
303        match parser.peek() {
304            None => Ok(ast),
305            Some(token) => Err(parser.unexpected(token)),
306        }
307    }
308
309    /// Evaluate a parsed expression to a node.
310    fn value(&self, ast: &Ast) -> Result<Node> {
311        match ast {
312            Ast::Literal(node) => Ok(node.clone()),
313            Ast::Platform => Ok(Node::String(platform().to_owned())),
314            Ast::Env(name) => Ok(match (self.env)(name) {
315                Some(value) => Node::String(value),
316                // `undefined`, like `null`, maps to the null node — the
317                // value `??` triggers on.
318                None => Node::Null,
319            }),
320            Ast::Cwd => match std::env::current_dir() {
321                Ok(dir) => Ok(Node::String(dir.to_string_lossy().into_owned())),
322                Err(error) => Err(self.error(format!("process.cwd() failed: {error}"))),
323            },
324            Ast::Not(inner) => Ok(Node::Bool(!truthy(&self.value(inner)?))),
325            Ast::Coalesce(left, right) => {
326                let left = self.value(left)?;
327                if left.is_null() {
328                    self.value(right)
329                } else {
330                    Ok(left)
331                }
332            }
333            Ast::Or(left, right) => {
334                let left = self.value(left)?;
335                if truthy(&left) {
336                    Ok(left)
337                } else {
338                    self.value(right)
339                }
340            }
341            Ast::And(left, right) => {
342                let left = self.value(left)?;
343                if truthy(&left) {
344                    self.value(right)
345                } else {
346                    Ok(left)
347                }
348            }
349            Ast::StrictEq {
350                left,
351                right,
352                negated,
353            } => {
354                let equal = strict_eq(&self.value(left)?, &self.value(right)?);
355                Ok(Node::Bool(if *negated { !equal } else { equal }))
356            }
357            Ast::Ternary {
358                condition,
359                then,
360                alternative,
361            } => {
362                if truthy(&self.value(condition)?) {
363                    self.value(then)
364                } else {
365                    self.value(alternative)
366                }
367            }
368        }
369    }
370}
371
372/// Node's platform name, matching `process.platform` in JavaScript.
373fn platform() -> &'static str {
374    match std::env::consts::OS {
375        "windows" => "win32",
376        "macos" => "darwin",
377        other => other,
378    }
379}
380
381/// JavaScript truthiness over the value domain: `null`, booleans, zero
382/// numbers, and empty strings are falsy.
383fn truthy(node: &Node) -> bool {
384    match node {
385        Node::Null => false,
386        Node::Bool(value) => *value,
387        Node::Int(value) => *value != 0,
388        Node::UInt(value) => *value != 0,
389        Node::Float(value) => *value != 0.0 && !value.is_nan(),
390        Node::String(value) => !value.is_empty(),
391        Node::Expr(_) | Node::Array(_) | Node::Object(_) => true,
392    }
393}
394
395/// JavaScript `===` over the value domain: numbers compare numerically
396/// across the tree's Int/Float split, everything else only within its
397/// own kind.
398fn strict_eq(left: &Node, right: &Node) -> bool {
399    let numeric = |node: &Node| match node {
400        Node::Int(value) => Some(*value as f64),
401        Node::UInt(value) => Some(*value as f64),
402        Node::Float(value) => Some(*value),
403        _ => None,
404    };
405    match (numeric(left), numeric(right)) {
406        (Some(left), Some(right)) => left == right,
407        (None, None) => left == right,
408        _ => false,
409    }
410}
411
412// --------------------------------------------------------------- parser ---
413
414/// Cursor over the token slice; errors carry the evaluator's source.
415struct Parser<'a, 'b> {
416    tokens: &'a [Token],
417    position: usize,
418    evaluator: &'b Evaluator<'a>,
419}
420
421impl Parser<'_, '_> {
422    fn peek(&self) -> Option<&Token> {
423        self.tokens.get(self.position)
424    }
425
426    /// Consume `punct` when it is next, reporting whether it was.
427    fn eat(&mut self, punct: &str) -> bool {
428        if matches!(self.peek(), Some(Token::Punct(found)) if *found == punct) {
429            self.position += 1;
430            true
431        } else {
432            false
433        }
434    }
435
436    /// Consume `punct` or fail with a `found`-aware message.
437    fn expect(&mut self, punct: &str) -> Result<()> {
438        if self.eat(punct) {
439            Ok(())
440        } else {
441            Err(self
442                .evaluator
443                .error(format!("expected `{punct}`{}", self.found_suffix())))
444        }
445    }
446
447    /// Consume one identifier, or fail.
448    fn expect_ident(&mut self) -> Result<String> {
449        match self.peek() {
450            Some(Token::Ident(name)) => {
451                let name = name.clone();
452                self.position += 1;
453                Ok(name)
454            }
455            _ => Err(self
456                .evaluator
457                .error(format!("expected a name{}", self.found_suffix()))),
458        }
459    }
460
461    /// ``, found `??`'' style context for error messages.
462    fn found_suffix(&self) -> String {
463        match self.peek() {
464            Some(token) => format!(", found {}", describe(token)),
465            None => String::new(),
466        }
467    }
468
469    fn unexpected(&self, token: &Token) -> IncludeError {
470        match token {
471            Token::LooseEq(op) => self.evaluator.error(format!(
472                "loose equality `{op}` is outside the supported expression subset (use {})",
473                if *op == "==" { "`===`" } else { "`!==`" }
474            )),
475            Token::Unsupported(text) => self.evaluator.error(format!(
476                "`{text}` is outside the supported expression subset"
477            )),
478            other => self
479                .evaluator
480                .error(format!("unexpected {}", describe(other))),
481        }
482    }
483
484    /// `cond ? then : alternative` — the lowest precedence, right-
485    /// associative in both branches.
486    fn ternary(&mut self, mixing: &mut Mixing) -> Result<Ast> {
487        let condition = self.logical(mixing)?;
488        if !self.eat("?") {
489            return Ok(condition);
490        }
491        // The branches are fresh mixing regions, like parenthesized
492        // subexpressions.
493        let then = self.ternary(&mut Mixing::default())?;
494        self.expect(":")?;
495        let alternative = self.ternary(&mut Mixing::default())?;
496        Ok(Ast::Ternary {
497            condition: Box::new(condition),
498            then: Box::new(then),
499            alternative: Box::new(alternative),
500        })
501    }
502
503    /// `??` / `||` (same precedence, left-associative). JavaScript makes
504    /// mixing `??` with `||`/`&&` in one region a syntax error; so does
505    /// the subset.
506    fn logical(&mut self, mixing: &mut Mixing) -> Result<Ast> {
507        let mut left = self.and_(mixing)?;
508        loop {
509            if self.eat("??") {
510                mixing.saw_coalesce = true;
511                left = Ast::Coalesce(Box::new(left), Box::new(self.and_(mixing)?));
512            } else if self.eat("||") {
513                mixing.saw_logical = true;
514                left = Ast::Or(Box::new(left), Box::new(self.and_(mixing)?));
515            } else {
516                if mixing.saw_coalesce && mixing.saw_logical {
517                    return Err(self.evaluator.error(
518                        "cannot mix `??` with `||`/`&&` without parentheses (JavaScript syntax error)",
519                    ));
520                }
521                return Ok(left);
522            }
523        }
524    }
525
526    /// `&&`, above `||`/`??` like JavaScript.
527    fn and_(&mut self, mixing: &mut Mixing) -> Result<Ast> {
528        let mut left = self.equality()?;
529        while self.eat("&&") {
530            mixing.saw_logical = true;
531            left = Ast::And(Box::new(left), Box::new(self.equality()?));
532        }
533        Ok(left)
534    }
535
536    /// `===` / `!==`.
537    fn equality(&mut self) -> Result<Ast> {
538        let mut left = self.unary()?;
539        loop {
540            let negated = if self.eat("===") {
541                false
542            } else if self.eat("!==") {
543                true
544            } else {
545                return Ok(left);
546            };
547            left = Ast::StrictEq {
548                left: Box::new(left),
549                right: Box::new(self.unary()?),
550                negated,
551            };
552        }
553    }
554
555    /// Unary `!`.
556    fn unary(&mut self) -> Result<Ast> {
557        if self.eat("!") {
558            return Ok(Ast::Not(Box::new(self.unary()?)));
559        }
560        self.primary()
561    }
562
563    /// Literals, parenthesized expressions, and `process.*` references.
564    fn primary(&mut self) -> Result<Ast> {
565        let Some(token) = self.peek().cloned() else {
566            return Err(self.evaluator.error("unexpected end of expression"));
567        };
568        self.position += 1;
569        match token {
570            Token::Punct("(") => {
571                let inner = self.ternary(&mut Mixing::default())?;
572                self.expect(")")?;
573                Ok(inner)
574            }
575            Token::Str(text) => Ok(Ast::Literal(Node::String(text))),
576            Token::Int(value) => Ok(Ast::Literal(Node::Int(value))),
577            Token::Float(value) => Ok(Ast::Literal(Node::Float(value))),
578            Token::Ident(name) => self.ident(name),
579            other => Err(self.unexpected(&other)),
580        }
581    }
582
583    /// Keywords, then the `process.*` member subset; every other
584    /// identifier is outside the subset (injected-context expressions
585    /// evaluate lazily in the owning plugin, not here).
586    fn ident(&mut self, name: String) -> Result<Ast> {
587        match name.as_str() {
588            "true" => Ok(Ast::Literal(Node::Bool(true))),
589            "false" => Ok(Ast::Literal(Node::Bool(false))),
590            "null" | "undefined" => Ok(Ast::Literal(Node::Null)),
591            "process" => self.process_member(),
592            other => Err(self.evaluator.error(format!(
593                "`{other}` is outside the supported expression subset: at config hand-off only \
594                 `process.platform`, `process.env.NAME`, and `process.cwd()` are available \
595                 (injected-context expressions such as `ctx.*` or `dshHomePath(…)` evaluate \
596                 lazily in the owning plugin)"
597            ))),
598        }
599    }
600
601    /// `process.platform` / `process.env.NAME` / `process.cwd()` — the
602    /// only member chains the subset carries.
603    fn process_member(&mut self) -> Result<Ast> {
604        self.expect(".")?;
605        let member = self.expect_ident()?;
606        match member.as_str() {
607            "platform" => Ok(Ast::Platform),
608            "cwd" => {
609                self.expect("(")?;
610                self.expect(")")?;
611                Ok(Ast::Cwd)
612            }
613            "env" => {
614                self.expect(".")?;
615                let name = self.expect_ident()?;
616                Ok(Ast::Env(name))
617            }
618            other => Err(self.evaluator.error(format!(
619                "`process.{other}` is outside the supported expression subset"
620            ))),
621        }
622    }
623}
624
625/// A token's name for error messages.
626fn describe(token: &Token) -> String {
627    match token {
628        Token::Punct(punct) => format!("`{punct}`"),
629        Token::Str(_) => "a string literal".to_owned(),
630        Token::Int(_) => "an integer".to_owned(),
631        Token::Float(_) => "a float".to_owned(),
632        Token::Ident(name) => format!("`{name}`"),
633        Token::LooseEq(op) => format!("`{op}`"),
634        Token::Unsupported(text) => format!("`{text}`"),
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    /// An environment lookup over fixed pairs.
643    fn env(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> + use<> {
644        let pairs = pairs
645            .iter()
646            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
647            .collect::<Vec<_>>();
648        move |name| {
649            pairs
650                .iter()
651                .find(|(key, _)| key == name)
652                .map(|(_, value)| value.clone())
653        }
654    }
655
656    fn eval(source: &str, pairs: &[(&str, &str)]) -> Node {
657        evaluate_with(source, &env(pairs)).expect("evaluation")
658    }
659
660    fn error(source: &str) -> String {
661        evaluate_with(source, &|_| None)
662            .expect_err("evaluation must fail")
663            .to_string()
664    }
665
666    // --- the shipped environment-conditioned samples, one by one ---
667
668    #[test]
669    fn bare_env_fetch_yields_the_string_or_null() {
670        assert_eq!(
671            eval(
672                "process.env.DSH_TOOLS_MODE",
673                &[("DSH_TOOLS_MODE", "bundled")]
674            ),
675            Node::String("bundled".to_owned())
676        );
677        assert_eq!(eval("process.env.DSH_TOOLS_MODE", &[]), Node::Null);
678    }
679
680    #[test]
681    fn platform_comparisons_follow_the_host() {
682        let expected = std::env::consts::OS == "windows";
683        assert_eq!(
684            eval("process.platform === 'win32'", &[]),
685            Node::Bool(expected)
686        );
687        assert_eq!(
688            eval("process.platform !== 'win32'", &[]),
689            Node::Bool(!expected)
690        );
691        assert_eq!(
692            eval("process.platform", &[]),
693            Node::String(platform().to_owned())
694        );
695    }
696
697    #[test]
698    fn cwd_is_the_process_working_directory() {
699        let expected = std::env::current_dir()
700            .unwrap()
701            .to_string_lossy()
702            .into_owned();
703        assert_eq!(eval("process.cwd()", &[]), Node::String(expected));
704    }
705
706    #[test]
707    fn coalesce_falls_back_on_unset_only() {
708        let url = "https://otlp.invalid/v1/logs";
709        let source = "process.env.DSH_TELEMETRY_OTLP_URL ?? 'https://otlp.invalid/v1/logs'";
710        assert_eq!(
711            eval(source, &[("DSH_TELEMETRY_OTLP_URL", url)]),
712            Node::String(url.to_owned())
713        );
714        assert_eq!(eval(source, &[]), Node::String(url.to_owned()));
715        // `??` triggers on null/undefined only — an empty string passes.
716        assert_eq!(
717            eval("process.env.X ?? 'fallback'", &[("X", "")]),
718            Node::String(String::new())
719        );
720    }
721
722    #[test]
723    fn or_falls_back_on_all_falsy_values() {
724        assert_eq!(
725            eval("process.env.DSH_TELEMETRY_MODE || 'DISABLED'", &[]),
726            Node::String("DISABLED".to_owned())
727        );
728        assert_eq!(
729            eval(
730                "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
731                &[("DSH_TELEMETRY_MODE", "")]
732            ),
733            Node::String("DISABLED".to_owned())
734        );
735        assert_eq!(
736            eval(
737                "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
738                &[("DSH_TELEMETRY_MODE", "full")]
739            ),
740            Node::String("full".to_owned())
741        );
742    }
743
744    #[test]
745    fn permission_mode_sample_through_the_ternary() {
746        let source = "(process.env.DSH_PERMISSION_MODE ?? 'workspace-write') === 'danger-full-access' ? 'never' : 'ask'";
747        assert_eq!(eval(source, &[]), Node::String("ask".to_owned()));
748        assert_eq!(
749            eval(source, &[("DSH_PERMISSION_MODE", "danger-full-access")]),
750            Node::String("never".to_owned())
751        );
752        // The coalesce fallback feeds the comparison.
753        assert_eq!(
754            eval(source, &[("DSH_PERMISSION_MODE", "workspace-write")]),
755            Node::String("ask".to_owned())
756        );
757    }
758
759    // --- operator semantics ---
760
761    #[test]
762    fn numbers_evaluate_across_the_int_float_split() {
763        assert_eq!(eval("1 === 1.0", &[]), Node::Bool(true));
764        assert_eq!(eval("'1' === 1", &[]), Node::Bool(false));
765        assert_eq!(eval("null === undefined", &[]), Node::Bool(true));
766        assert_eq!(eval("3080", &[]), Node::Int(3080));
767        assert_eq!(eval("1.5", &[]), Node::Float(1.5));
768        assert_eq!(eval("1e3", &[]), Node::Float(1000.0));
769    }
770
771    #[test]
772    fn unary_not_uses_javascript_truthiness() {
773        assert_eq!(eval("!''", &[]), Node::Bool(true));
774        assert_eq!(eval("!0", &[]), Node::Bool(true));
775        assert_eq!(eval("!null", &[]), Node::Bool(true));
776        assert_eq!(eval("!undefined", &[]), Node::Bool(true));
777        assert_eq!(eval("!'x'", &[]), Node::Bool(false));
778        assert_eq!(eval("!process.env.MISSING", &[]), Node::Bool(true));
779    }
780
781    #[test]
782    fn logical_operators_keep_value_semantics() {
783        assert_eq!(eval("false && 'x'", &[]), Node::Bool(false));
784        assert_eq!(eval("'' && 'x'", &[]), Node::String(String::new()));
785        assert_eq!(eval("true && 'x'", &[]), Node::String("x".to_owned()));
786        assert_eq!(eval("'a' || 'b'", &[]), Node::String("a".to_owned()));
787        // && binds tighter than ||, like JavaScript.
788        assert_eq!(
789            eval("false || 'yes' && 'no'", &[]),
790            Node::String("no".to_owned())
791        );
792    }
793
794    #[test]
795    fn nested_ternaries_and_parens() {
796        assert_eq!(
797            eval("true ? false ? 'a' : 'b' : 'c'", &[]),
798            Node::String("b".to_owned())
799        );
800        assert_eq!(
801            eval("(true ? false : true) ? 'a' : 'b'", &[]),
802            Node::String("b".to_owned())
803        );
804    }
805
806    #[test]
807    fn double_quoted_strings_and_escapes() {
808        assert_eq!(
809            eval(r#""double 'quoted'""#, &[]),
810            Node::String("double 'quoted'".to_owned())
811        );
812        assert_eq!(
813            eval(r"'line\nbreak'", &[]),
814            Node::String("line\nbreak".to_owned())
815        );
816        assert_eq!(
817            eval(r#""tab\there""#, &[]),
818            Node::String("tab\there".to_owned())
819        );
820    }
821
822    #[test]
823    fn coalesce_may_not_mix_with_logical_operators() {
824        // JavaScript rejects these without parentheses; so does the subset.
825        assert!(error("process.env.X ?? 'a' || 'b'").contains("mix"));
826        assert!(error("process.env.X ?? 'a' && 'b'").contains("mix"));
827        assert!(error("true || false ?? null").contains("mix"));
828        // Parenthesized regions are separate and fine.
829        assert_eq!(
830            eval("(process.env.X ?? 'a') || 'b'", &[]),
831            Node::String("a".to_owned())
832        );
833    }
834
835    // --- out-of-subset references fail with a clear message ---
836
837    #[test]
838    fn injected_context_references_are_outside_the_subset() {
839        for source in [
840            "ctx.webStartup.trustedHosts",
841            "ctx.webRuntime.trustedHosts",
842            "ctx.headlessStartup.task",
843            "ctx.webStartup.port ?? 3080",
844            "ctx.webStartup.host ?? '127.0.0.1'",
845            "dshHomePath('storages')",
846            "dshHomePath('sessions')",
847        ] {
848            let message = error(source);
849            assert!(message.contains("subset"), "{source}: {message}");
850            assert!(message.contains("process.cwd"), "{source}: {message}");
851        }
852    }
853
854    #[test]
855    fn other_javascript_is_outside_the_subset() {
856        assert!(error("process.foo").contains("subset"));
857        assert!(error("process.env").contains("expected"));
858        assert!(error("process.cwd").contains("expected"));
859        assert!(error("1 == 1").contains("loose equality"));
860        assert!(error("1 != 1").contains("loose equality"));
861        assert!(error("'a' + 'b'").contains("subset"));
862        assert!(error("1 < 2").contains("subset"));
863        assert!(error("typeof 'x'").contains("subset"));
864        assert!(error("env.HOME").contains("subset"));
865    }
866
867    #[test]
868    fn syntax_errors_are_reported() {
869        assert!(error("'unterminated").contains("subset"));
870        assert!(error("process.platform ===").contains("unexpected end"));
871        assert!(error("true ? 'a'").contains("expected `:`"));
872        assert!(error("(true").contains("expected `)`"));
873        assert!(error("true false").contains("unexpected"));
874    }
875
876    #[test]
877    fn evaluate_reads_the_process_environment() {
878        // A name nothing sets: `undefined`, which `??` replaces.
879        let source = "process.env.CORDIS_EXPR_TEST_UNSET_7f3a ?? 'fallback'";
880        assert_eq!(
881            evaluate(source).unwrap(),
882            Node::String("fallback".to_owned())
883        );
884        // The working directory through the public entry point.
885        let cwd = std::env::current_dir()
886            .unwrap()
887            .to_string_lossy()
888            .into_owned();
889        assert_eq!(evaluate("process.cwd()").unwrap(), Node::String(cwd));
890    }
891
892    #[test]
893    fn evaluate_node_recurses_the_tree() {
894        let node = Node::from_iter([
895            ("mode".to_owned(), Node::Expr("process.env.MODE".to_owned())),
896            (
897                "nested".to_owned(),
898                Node::Array(vec![
899                    Node::Expr("process.platform === 'win32'".to_owned()),
900                    Node::String("kept".to_owned()),
901                ]),
902            ),
903        ]);
904        let evaluated =
905            evaluate_node_with(&node, &|name| (name == "MODE").then(|| "fast".to_owned())).unwrap();
906        let map = evaluated.as_object().unwrap();
907        assert_eq!(map["mode"], Node::String("fast".to_owned()));
908        assert_eq!(
909            map["nested"].as_array().unwrap()[0],
910            Node::Bool(std::env::consts::OS == "windows")
911        );
912        assert_eq!(
913            map["nested"].as_array().unwrap()[1],
914            Node::String("kept".to_owned())
915        );
916    }
917}