1use crate::error::{IncludeError, Result};
30use crate::node::{Node, NodeMap};
31
32pub fn evaluate(source: &str) -> Result<Node> {
41 evaluate_with(source, &|name| std::env::var(name).ok())
42}
43
44pub 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
52pub fn evaluate_node(node: &Node) -> Result<Node> {
57 evaluate_node_with(node, &|name| std::env::var(name).ok())
58}
59
60pub 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#[derive(Debug, Clone, PartialEq)]
85enum Token {
86 Punct(&'static str),
88 Str(String),
90 Int(i64),
92 Float(f64),
94 Ident(String),
96 LooseEq(&'static str),
99 Unsupported(String),
101}
102
103fn 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 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 (
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
165fn 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 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 other => text.push(other),
213 }
214 }
215 None
216}
217
218fn 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
231fn 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 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 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
268fn 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
305enum Ast {
309 Literal(Node),
311 Platform,
313 Env(String),
315 Cwd,
317 Not(Box<Ast>),
319 Coalesce(Box<Ast>, Box<Ast>),
321 Or(Box<Ast>, Box<Ast>),
323 And(Box<Ast>, Box<Ast>),
325 StrictEq {
327 left: Box<Ast>,
328 right: Box<Ast>,
329 negated: bool,
330 },
331 Ternary {
333 condition: Box<Ast>,
334 then: Box<Ast>,
335 alternative: Box<Ast>,
336 },
337}
338
339#[derive(Default)]
342struct Mixing {
343 saw_coalesce: bool,
344 saw_logical: bool,
345}
346
347struct 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 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 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 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
441fn platform() -> &'static str {
443 match std::env::consts::OS {
444 "windows" => "win32",
445 "macos" => "darwin",
446 other => other,
447 }
448}
449
450fn 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
464fn 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
481struct 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 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 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 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 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 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 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 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 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 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 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 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 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 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
694fn 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 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 #[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 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 assert_eq!(
823 eval(source, &[("DSH_PERMISSION_MODE", "workspace-write")]),
824 Node::String("ask".to_owned())
825 );
826 }
827
828 #[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 assert_eq!(eval(r"'\uD83D\uDE00'", &[]), Node::String("😀".to_owned()));
840 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 assert_eq!(eval(r"'\x41' === 'A'", &[]), Node::Bool(true));
851 assert_eq!(eval(r"'\x4'", &[]), Node::String("x4".to_owned()));
853 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 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 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 assert_eq!(
926 eval("(process.env.X ?? 'a') || 'b'", &[]),
927 Node::String("a".to_owned())
928 );
929 }
930
931 #[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 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 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}