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