1use std::borrow::Cow;
9use std::collections::{HashMap, HashSet};
10use std::sync::Arc;
11
12use kaish_types::{json_to_value_no_envelope, value_to_json};
13
14use crate::ast::{Value, VarPath, VarSegment};
15
16use super::eval::value_to_string;
17use super::result::ExecResult;
18
19#[derive(Debug, Clone, PartialEq)]
39pub enum PathError {
40 UndefinedRoot(String),
42 Absence(String),
44 Shape(String),
46}
47
48fn type_name(value: &Value) -> &'static str {
50 match value {
51 Value::Null => "null",
52 Value::Bool(_) => "a boolean",
53 Value::Int(_) => "an integer",
54 Value::Float(_) => "a float",
55 Value::String(_) => "a string",
56 Value::Json(serde_json::Value::Array(_)) => "a list",
57 Value::Json(serde_json::Value::Object(_)) => "a record",
58 Value::Json(_) => "a scalar",
59 Value::Bytes(_) => "binary data",
60 }
61}
62
63fn value_as_index(value: &Value) -> Option<i64> {
66 match value {
67 Value::Int(i) => Some(*i),
68 Value::String(s) => s.parse::<i64>().ok(),
69 _ => None,
70 }
71}
72
73#[derive(Debug, Clone, PartialEq)]
80enum Step {
81 Index(usize),
83 Key(String),
85 Slice(usize, usize),
87}
88
89fn classify_index(json: &serde_json::Value, i: i64, path: &str) -> Result<Step, PathError> {
95 let arr = match json {
96 serde_json::Value::Array(a) => a,
97 serde_json::Value::Object(_) => {
98 return Err(PathError::Shape(format!(
99 "${{{path}[{i}]}}: integer index on a record — record keys are strings, use ${{{path}[\"{i}\"]}}"
100 )))
101 }
102 _ => unreachable!("resolve_step guards non-collection containers"),
103 };
104 let len = arr.len() as i64;
105 let idx = if i < 0 { len + i } else { i };
106 if idx < 0 || idx >= len {
107 return Err(PathError::Absence(format!(
108 "${{{path}[{i}]}}: index out of bounds (list length {len})"
109 )));
110 }
111 Ok(Step::Index(idx as usize))
112}
113
114fn classify_key(json: &serde_json::Value, key: &str, path: &str) -> Result<Step, PathError> {
118 match json {
119 serde_json::Value::Object(_) => Ok(Step::Key(key.to_string())),
120 serde_json::Value::Array(_) => Err(PathError::Shape(format!(
121 "${{{path}[{key}]}}: string key on a list — use an integer index"
122 ))),
123 _ => unreachable!("resolve_step guards non-collection containers"),
124 }
125}
126
127fn classify_slice(
131 json: &serde_json::Value,
132 start: Option<i64>,
133 end: Option<i64>,
134 path: &str,
135) -> Result<Step, PathError> {
136 let len = match json {
139 serde_json::Value::Array(a) => a.len() as i64,
140 serde_json::Value::String(s) => s.chars().count() as i64,
141 serde_json::Value::Object(_) => {
142 return Err(PathError::Shape(format!(
143 "${{{path}[..]}}: cannot slice a record"
144 )))
145 }
146 _ => unreachable!("resolve_step guards non-sliceable containers"),
147 };
148 let norm = |b: i64| -> i64 {
149 let b = if b < 0 { len + b } else { b };
150 b.clamp(0, len)
151 };
152 let s = start.map(norm).unwrap_or(0);
153 let e = end.map(norm).unwrap_or(len);
154 let (s, e) = if s >= e {
155 (s as usize, s as usize)
156 } else {
157 (s as usize, e as usize)
158 };
159 Ok(Step::Slice(s, e))
160}
161
162fn dotted_access_error(path: &str, field: &str) -> PathError {
166 PathError::Shape(format!(
167 "${{{path}…}}: kaish uses bracket access, not dots — write the key as a subscript: [{field}]"
168 ))
169}
170
171fn render_segment(seg: &VarSegment) -> String {
175 match seg {
176 VarSegment::Index(i) => format!("[{i}]"),
177 VarSegment::Key(k) => format!("[{k}]"),
178 VarSegment::Dynamic(v) => format!("[${v}]"),
179 VarSegment::Slice(a, b) => format!(
180 "[{}:{}]",
181 a.map(|n| n.to_string()).unwrap_or_default(),
182 b.map(|n| n.to_string()).unwrap_or_default()
183 ),
184 VarSegment::Field(f) => format!(".{f}"),
185 }
186}
187
188fn resolve_step(
194 container: &serde_json::Value,
195 seg: &VarSegment,
196 scope: &Scope,
197 path: &str,
198) -> Result<Step, PathError> {
199 if let VarSegment::Field(name) = seg {
203 return Err(dotted_access_error(path, name));
204 }
205
206 if matches!(container, serde_json::Value::String(_)) {
211 return match seg {
212 VarSegment::Slice(start, end) => classify_slice(container, *start, *end, path),
213 _ => Err(PathError::Shape(format!(
214 "${{{path}…}}: cannot subscript a string — slice it instead, \
215 e.g. ${{{path}[0:5]}} for the first five characters"
216 ))),
217 };
218 }
219
220 if !matches!(
222 container,
223 serde_json::Value::Array(_) | serde_json::Value::Object(_)
224 ) {
225 return Err(PathError::Shape(format!(
226 "${{{path}…}}: cannot subscript {} — it is not a collection",
227 type_name(&json_to_value_no_envelope(container.clone()))
228 )));
229 }
230
231 match seg {
232 VarSegment::Index(i) => classify_index(container, *i, path),
233 VarSegment::Key(k) => classify_key(container, k, path),
234 VarSegment::Slice(start, end) => classify_slice(container, *start, *end, path),
235 VarSegment::Dynamic(var) => {
236 let key_val = scope.get(var).ok_or_else(|| {
240 PathError::UndefinedRoot(format!("${{{path}[${var}]}}: ${var} is not set"))
241 })?;
242 match container {
243 serde_json::Value::Array(_) => {
244 let idx = value_as_index(key_val).ok_or_else(|| {
245 PathError::Shape(format!(
246 "${{{path}[${var}]}}: a list index must be an integer, got \"{}\"",
247 value_to_string(key_val)
248 ))
249 })?;
250 classify_index(container, idx, path)
251 }
252 serde_json::Value::Object(_) => Ok(Step::Key(value_to_string(key_val))),
253 _ => unreachable!("non-collection container guarded above"),
254 }
255 }
256 VarSegment::Field(_) => unreachable!("dotted segment handled above"),
257 }
258}
259
260fn descend<'a>(
266 current: Cow<'a, serde_json::Value>,
267 step: Step,
268 path: &str,
269) -> Result<Cow<'a, serde_json::Value>, PathError> {
270 match step {
271 Step::Slice(s, e) => match current.as_ref() {
272 serde_json::Value::Array(arr) => {
273 Ok(Cow::Owned(serde_json::Value::Array(arr[s..e].to_vec())))
274 }
275 serde_json::Value::String(text) => Ok(Cow::Owned(serde_json::Value::String(
277 text.chars().skip(s).take(e - s).collect(),
278 ))),
279 _ => unreachable!("slice classified against an array or string"),
280 },
281 Step::Index(i) => match current {
282 Cow::Borrowed(j) => {
283 let Some(arr) = j.as_array() else {
284 unreachable!("index classified against an array")
285 };
286 Ok(Cow::Borrowed(&arr[i]))
287 }
288 Cow::Owned(j) => {
289 let Some(arr) = j.as_array() else {
290 unreachable!("index classified against an array")
291 };
292 Ok(Cow::Owned(arr[i].clone()))
293 }
294 },
295 Step::Key(k) => match current {
296 Cow::Borrowed(j) => match j.as_object().and_then(|m| m.get(&k)) {
297 Some(child) => Ok(Cow::Borrowed(child)),
298 None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
299 },
300 Cow::Owned(j) => match j.as_object().and_then(|m| m.get(&k)) {
301 Some(child) => Ok(Cow::Owned(child.clone())),
302 None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
303 },
304 },
305 }
306}
307
308fn descend_mut<'a>(
318 current: &'a mut serde_json::Value,
319 step: Step,
320 path: &str,
321) -> Result<&'a mut serde_json::Value, PathError> {
322 match step {
323 Step::Slice(..) => Err(PathError::Shape(format!(
324 "${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
325 ))),
326 Step::Index(i) => {
327 let Some(arr) = current.as_array_mut() else {
328 unreachable!("index classified against an array")
329 };
330 Ok(&mut arr[i])
331 }
332 Step::Key(k) => {
333 let Some(map) = current.as_object_mut() else {
334 unreachable!("key classified against an object")
335 };
336 match map.get_mut(&k) {
337 Some(child) => Ok(child),
338 None => Err(PathError::Absence(format!(
339 "${{{path}[{k}]}}: no such key — no autovivification, create it first (e.g. `{path}[{k}]={{}}`)"
340 ))),
341 }
342 }
343 }
344}
345
346fn apply_leaf_write(
353 current: &mut serde_json::Value,
354 step: Step,
355 value: serde_json::Value,
356 path: &str,
357) -> Result<(), PathError> {
358 match step {
359 Step::Slice(..) => Err(PathError::Shape(format!(
360 "${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
361 ))),
362 Step::Index(i) => {
363 let Some(arr) = current.as_array_mut() else {
364 unreachable!("index classified against an array")
365 };
366 arr[i] = value;
367 Ok(())
368 }
369 Step::Key(k) => {
370 let Some(map) = current.as_object_mut() else {
371 unreachable!("key classified against an object")
372 };
373 map.insert(k, value);
374 Ok(())
375 }
376 }
377}
378
379fn push_path_error_message(err: PathError, root_name: &str) -> String {
386 match err {
387 PathError::UndefinedRoot(msg) if msg.is_empty() => {
388 format!("push: {root_name} is not defined")
389 }
390 PathError::UndefinedRoot(msg) => format!("push: {msg}"),
391 PathError::Absence(msg) | PathError::Shape(msg) => msg,
392 }
393}
394
395#[derive(Debug, Clone)]
406pub struct Scope {
407 frames: Arc<Vec<HashMap<String, Value>>>,
410 exported: HashSet<String>,
412 last_result: Box<ExecResult>,
420 last_cmdsubst_code: Option<i64>,
427 script_name: String,
429 positional: Vec<String>,
431 error_exit: bool,
433 errexit_suppressed: usize,
436 show_ast: bool,
438 trash_enabled: bool,
440 trash_max_size: u64,
443 glob_enabled: bool,
445 pid: u64,
451}
452
453impl Scope {
454 pub fn new() -> Self {
459 Self {
460 frames: Arc::new(vec![HashMap::new()]),
461 exported: HashSet::new(),
462 last_result: Box::new(ExecResult::default()),
463 last_cmdsubst_code: None,
464 script_name: String::new(),
465 positional: Vec::new(),
466 error_exit: false,
467 errexit_suppressed: 0,
468 show_ast: false,
469 trash_enabled: false,
470 trash_max_size: 10 * 1024 * 1024, glob_enabled: true,
472 pid: 0,
473 }
474 }
475
476 pub fn pid(&self) -> u64 {
478 self.pid
479 }
480
481 pub fn set_pid(&mut self, pid: u64) {
485 self.pid = pid;
486 }
487
488 pub fn push_frame(&mut self) {
490 Arc::make_mut(&mut self.frames).push(HashMap::new());
491 }
492
493 pub fn pop_frame(&mut self) {
497 if self.frames.len() > 1 {
498 Arc::make_mut(&mut self.frames).pop();
499 } else {
500 panic!("cannot pop the root scope frame");
501 }
502 }
503
504 pub fn set(&mut self, name: impl Into<String>, value: Value) {
514 let name = crate::ast::normalize_name(name.into());
515 if let Some(frame) = Arc::make_mut(&mut self.frames).last_mut() {
516 frame.insert(name, value);
517 }
518 }
519
520 pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
526 let name = crate::ast::normalize_name(name.into());
527
528 let frames = Arc::make_mut(&mut self.frames);
530 for frame in frames.iter_mut().rev() {
531 if let std::collections::hash_map::Entry::Occupied(mut e) = frame.entry(name.clone()) {
532 e.insert(value);
533 return;
534 }
535 }
536
537 if let Some(frame) = frames.first_mut() {
539 frame.insert(name, value);
540 }
541 }
542
543 pub fn get(&self, name: &str) -> Option<&Value> {
545 let normalized;
546 let name = if name.is_ascii() {
547 name
548 } else {
549 normalized = crate::ast::normalize_name(name.to_string());
550 normalized.as_str()
551 };
552 for frame in self.frames.iter().rev() {
553 if let Some(value) = frame.get(name) {
554 return Some(value);
555 }
556 }
557 None
558 }
559
560 pub fn remove(&mut self, name: &str) -> Option<Value> {
564 let normalized;
565 let name = if name.is_ascii() {
566 name
567 } else {
568 normalized = crate::ast::normalize_name(name.to_string());
569 normalized.as_str()
570 };
571 for frame in Arc::make_mut(&mut self.frames).iter_mut().rev() {
572 if let Some(value) = frame.remove(name) {
573 return Some(value);
574 }
575 }
576 None
577 }
578
579 pub fn set_last_result(&mut self, result: ExecResult) {
581 *self.last_result = result;
583 }
584
585 pub fn last_result(&self) -> &ExecResult {
587 &self.last_result
588 }
589
590 pub fn note_cmdsubst_code(&mut self, code: i64) {
594 self.last_cmdsubst_code = Some(code);
595 }
596
597 pub fn clear_cmdsubst_code(&mut self) {
601 self.last_cmdsubst_code = None;
602 }
603
604 pub fn take_cmdsubst_code(&mut self) -> Option<i64> {
606 self.last_cmdsubst_code.take()
607 }
608
609 pub fn set_positional(&mut self, script_name: impl Into<String>, args: Vec<String>) {
613 self.script_name = script_name.into();
614 self.positional = args;
615 }
616
617 pub fn save_positional(&self) -> (String, Vec<String>) {
621 (self.script_name.clone(), self.positional.clone())
622 }
623
624 pub fn get_positional(&self, n: usize) -> Option<&str> {
628 if n == 0 {
629 if self.script_name.is_empty() {
630 None
631 } else {
632 Some(&self.script_name)
633 }
634 } else {
635 self.positional.get(n - 1).map(|s| s.as_str())
636 }
637 }
638
639 pub fn all_args(&self) -> &[String] {
641 &self.positional
642 }
643
644 pub fn arg_count(&self) -> usize {
646 self.positional.len()
647 }
648
649 pub fn error_exit_enabled(&self) -> bool {
654 self.error_exit && self.errexit_suppressed == 0
655 }
656
657 pub fn set_error_exit(&mut self, enabled: bool) {
659 self.error_exit = enabled;
660 }
661
662 pub fn suppress_errexit(&mut self) {
664 self.errexit_suppressed += 1;
665 }
666
667 pub fn unsuppress_errexit(&mut self) {
669 self.errexit_suppressed = self.errexit_suppressed.saturating_sub(1);
670 }
671
672 pub fn show_ast(&self) -> bool {
674 self.show_ast
675 }
676
677 pub fn set_show_ast(&mut self, enabled: bool) {
679 self.show_ast = enabled;
680 }
681
682 pub fn trash_enabled(&self) -> bool {
684 self.trash_enabled
685 }
686
687 pub fn set_trash_enabled(&mut self, enabled: bool) {
689 self.trash_enabled = enabled;
690 }
691
692 pub fn trash_max_size(&self) -> u64 {
694 self.trash_max_size
695 }
696
697 pub fn set_trash_max_size(&mut self, size: u64) {
699 self.trash_max_size = size;
700 }
701
702 pub fn glob_enabled(&self) -> bool {
704 self.glob_enabled
705 }
706
707 pub fn set_glob_enabled(&mut self, enabled: bool) {
709 self.glob_enabled = enabled;
710 }
711
712 pub fn export(&mut self, name: impl Into<String>) {
716 self.exported.insert(name.into());
717 }
718
719 pub fn is_exported(&self, name: &str) -> bool {
721 self.exported.contains(name)
722 }
723
724 pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
731 let name = name.into();
732 self.set(&name, value);
733 self.export(name);
734 }
735
736 pub fn set_exported_global(&mut self, name: impl Into<String>, value: Value) {
742 let name = name.into();
743 self.set_global(&name, value);
744 self.export(name);
745 }
746
747 pub fn unexport(&mut self, name: &str) {
749 self.exported.remove(name);
750 }
751
752 pub fn exported_vars(&self) -> Vec<(String, Value)> {
756 let mut result = Vec::new();
757 for name in &self.exported {
758 if let Some(value) = self.get(name) {
759 result.push((name.clone(), value.clone()));
760 }
761 }
762 result.sort_by(|(a, _), (b, _)| a.cmp(b));
763 result
764 }
765
766 pub fn exported_names(&self) -> Vec<&str> {
768 let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
769 names.sort();
770 names
771 }
772
773 pub fn resolve_path(&self, path: &VarPath) -> Result<Value, PathError> {
790 let Some(VarSegment::Field(root_name)) = path.segments.first() else {
791 return Err(PathError::UndefinedRoot(String::new()));
793 };
794
795 if root_name == "?" {
797 if path.segments.len() == 1 {
798 return Ok(Value::Int(self.last_result.code));
799 }
800 return Err(PathError::Shape(
801 "$? is the POSIX exit code, not a collection — use `kaish-last` for structured data"
802 .to_string(),
803 ));
804 }
805
806 let root = self
807 .get(root_name)
808 .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
809
810 let subscripts = &path.segments[1..];
813 if subscripts.is_empty() {
814 return Ok(root.clone());
815 }
816
817 if let Some(VarSegment::Field(name)) = subscripts.first() {
822 return Err(dotted_access_error(root_name, name));
823 }
824
825 let lifted;
832 let root_json = match root {
833 Value::Json(j) => j,
834 Value::String(s) => {
835 lifted = serde_json::Value::String(s.clone());
836 &lifted
837 }
838 other => {
839 return Err(PathError::Shape(format!(
840 "${{{root_name}…}}: cannot subscript {} — it is not a collection",
841 type_name(other)
842 )))
843 }
844 };
845
846 let mut current = Cow::Borrowed(root_json);
851 let mut prefix = root_name.clone();
852 for seg in subscripts {
853 let step = resolve_step(¤t, seg, self, &prefix)?;
854 current = descend(current, step, &prefix)?;
855 prefix.push_str(&render_segment(seg));
856 }
857 Ok(json_to_value_no_envelope(current.into_owned()))
858 }
859
860 pub fn walk_write(&mut self, path: &VarPath, value: Value) -> Result<(), PathError> {
880 let Some(VarSegment::Field(root_name)) = path.segments.first() else {
881 return Err(PathError::UndefinedRoot(String::new()));
882 };
883
884 let root = self
885 .get(root_name)
886 .ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
887
888 let mut root_json = match root {
889 Value::Json(j) => j.clone(),
890 other => {
891 return Err(PathError::Shape(format!(
892 "${{{root_name}…}}: cannot subscript {} — it is not a collection",
893 type_name(other)
894 )))
895 }
896 };
897
898 let subscripts = &path.segments[1..];
899 let Some((last, intermediates)) = subscripts.split_last() else {
900 return Err(PathError::Shape(format!(
904 "{root_name}: assignment target has no subscript"
905 )));
906 };
907
908 let mut current = &mut root_json;
909 let mut prefix = root_name.clone();
910 for seg in intermediates {
911 let step = resolve_step(current, seg, self, &prefix)?;
912 current = descend_mut(current, step, &prefix)?;
913 prefix.push_str(&render_segment(seg));
914 }
915
916 let step = resolve_step(current, last, self, &prefix)?;
917 apply_leaf_write(current, step, value_to_json(&value), &prefix)?;
918
919 self.set_global(root_name.clone(), Value::Json(root_json));
920 Ok(())
921 }
922
923 pub fn walk_append(&mut self, path: &VarPath, values: Vec<Value>) -> Result<(), String> {
935 let Some(VarSegment::Field(root_name)) = path.segments.first() else {
936 return Err("push: target has no root".to_string());
937 };
938 let root_name = root_name.clone();
939 let current = self
940 .get(&root_name)
941 .ok_or_else(|| format!("push: {root_name} is not defined"))?
942 .clone();
943
944 let subscripts = &path.segments[1..];
945 if subscripts.is_empty() {
946 if !matches!(current, Value::Json(serde_json::Value::Array(_))) {
947 return Err(format!("push: {root_name} is not a list ({})", type_name(¤t)));
948 }
949 let Value::Json(serde_json::Value::Array(mut arr)) = current else {
950 unreachable!("checked above")
951 };
952 arr.extend(values.iter().map(value_to_json));
953 self.set_global(root_name, Value::Json(serde_json::Value::Array(arr)));
954 return Ok(());
955 }
956
957 let mut root_json = match current {
958 Value::Json(j) => j,
959 other => {
960 return Err(format!(
961 "push: {root_name}…: cannot subscript {} — it is not a collection",
962 type_name(&other)
963 ))
964 }
965 };
966
967 let mut cur = &mut root_json;
971 let mut prefix = root_name.clone();
972 for seg in subscripts {
973 let step = resolve_step(cur, seg, self, &prefix)
974 .map_err(|e| push_path_error_message(e, &root_name))?;
975 cur = descend_mut(cur, step, &prefix)
976 .map_err(|e| push_path_error_message(e, &root_name))?;
977 prefix.push_str(&render_segment(seg));
978 }
979
980 let serde_json::Value::Array(arr) = cur else {
981 return Err(format!(
982 "push: {prefix} is not a list ({})",
983 type_name(&json_to_value_no_envelope(cur.clone()))
984 ));
985 };
986 arr.extend(values.iter().map(value_to_json));
987 self.set_global(root_name, Value::Json(root_json));
988 Ok(())
989 }
990
991 pub fn contains(&self, name: &str) -> bool {
993 self.get(name).is_some()
994 }
995
996 pub fn all_names(&self) -> Vec<&str> {
998 let mut names: Vec<&str> = self
999 .frames
1000 .iter()
1001 .flat_map(|f| f.keys().map(|s| s.as_str()))
1002 .collect();
1003 names.sort();
1004 names.dedup();
1005 names
1006 }
1007
1008 pub fn all(&self) -> Vec<(String, Value)> {
1012 let mut result = std::collections::HashMap::new();
1013 for frame in self.frames.iter() {
1015 for (name, value) in frame {
1016 result.insert(name.clone(), value.clone());
1017 }
1018 }
1019 let mut pairs: Vec<_> = result.into_iter().collect();
1020 pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
1021 pairs
1022 }
1023}
1024
1025impl Default for Scope {
1026 fn default() -> Self {
1027 Self::new()
1028 }
1029}
1030
1031#[cfg(test)]
1032mod tests {
1033 use super::*;
1034
1035 #[test]
1036 fn new_scope_has_one_frame() {
1037 let scope = Scope::new();
1038 assert_eq!(scope.frames.len(), 1);
1039 }
1040
1041 #[test]
1042 fn set_and_get_variable() {
1043 let mut scope = Scope::new();
1044 scope.set("X", Value::Int(42));
1045 assert_eq!(scope.get("X"), Some(&Value::Int(42)));
1046 }
1047
1048 #[test]
1049 fn get_nonexistent_returns_none() {
1050 let scope = Scope::new();
1051 assert_eq!(scope.get("MISSING"), None);
1052 }
1053
1054 #[test]
1055 fn inner_frame_shadows_outer() {
1056 let mut scope = Scope::new();
1057 scope.set("X", Value::Int(1));
1058 scope.push_frame();
1059 scope.set("X", Value::Int(2));
1060 assert_eq!(scope.get("X"), Some(&Value::Int(2)));
1061 scope.pop_frame();
1062 assert_eq!(scope.get("X"), Some(&Value::Int(1)));
1063 }
1064
1065 #[test]
1066 fn inner_frame_can_see_outer_vars() {
1067 let mut scope = Scope::new();
1068 scope.set("OUTER", Value::String("visible".into()));
1069 scope.push_frame();
1070 assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
1071 }
1072
1073 #[test]
1074 fn resolve_simple_path() {
1075 let mut scope = Scope::new();
1076 scope.set("NAME", Value::String("Alice".into()));
1077
1078 let path = VarPath::simple("NAME");
1079 assert_eq!(
1080 scope.resolve_path(&path),
1081 Ok(Value::String("Alice".into()))
1082 );
1083 }
1084
1085 #[test]
1086 fn resolve_bare_last_result_returns_exit_code() {
1087 let mut scope = Scope::new();
1088 scope.set_last_result(ExecResult::failure(127, "not found"));
1089
1090 let path = VarPath {
1091 segments: vec![VarSegment::Field("?".into())],
1092 };
1093 assert_eq!(scope.resolve_path(&path), Ok(Value::Int(127)));
1094 }
1095
1096 #[test]
1097 fn resolve_last_result_field_access_is_rejected() {
1098 let mut scope = Scope::new();
1102 scope.set_last_result(ExecResult::success_with_data(
1103 "1",
1104 Value::Json(serde_json::json!({"count": 5})),
1105 ));
1106
1107 let path = VarPath {
1108 segments: vec![
1109 VarSegment::Field("?".into()),
1110 VarSegment::Field("data".into()),
1111 ],
1112 };
1113 assert!(matches!(
1114 scope.resolve_path(&path),
1115 Err(PathError::Shape(_))
1116 ));
1117 }
1118
1119 #[test]
1120 fn resolve_dotted_access_on_scalar_is_a_loud_error() {
1121 let mut scope = Scope::new();
1122 scope.set("X", Value::Int(42));
1123
1124 let path = VarPath {
1126 segments: vec![
1127 VarSegment::Field("X".into()),
1128 VarSegment::Field("invalid".into()),
1129 ],
1130 };
1131 assert!(matches!(
1132 scope.resolve_path(&path),
1133 Err(PathError::Shape(_))
1134 ));
1135 }
1136
1137 #[test]
1138 fn resolve_undefined_root_is_soft() {
1139 let scope = Scope::new();
1140 let path = VarPath::simple("NOPE");
1141 assert!(matches!(
1142 scope.resolve_path(&path),
1143 Err(PathError::UndefinedRoot(_))
1144 ));
1145 }
1146
1147 fn subscripted(scope: &mut Scope, root: &str, value: serde_json::Value, seg: VarSegment) -> Result<Value, PathError> {
1154 scope.set(root, Value::Json(value));
1155 let path = VarPath {
1156 segments: vec![VarSegment::Field(root.into()), seg],
1157 };
1158 scope.resolve_path(&path)
1159 }
1160
1161 #[test]
1162 fn out_of_bounds_index_is_absence() {
1163 let mut scope = Scope::new();
1164 let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Index(9));
1165 assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1166 }
1167
1168 #[test]
1169 fn missing_record_key_is_absence() {
1170 let mut scope = Scope::new();
1171 let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Key("nope".into()));
1172 assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1173 }
1174
1175 #[test]
1176 fn string_key_on_a_list_is_shape() {
1177 let mut scope = Scope::new();
1178 let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Key("web".into()));
1179 assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1180 }
1181
1182 #[test]
1183 fn integer_index_on_a_record_is_shape() {
1184 let mut scope = Scope::new();
1185 let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Index(0));
1186 assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1187 }
1188
1189 #[test]
1190 fn subscripting_a_scalar_is_shape() {
1191 let mut scope = Scope::new();
1192 scope.set("s", Value::String("hello".into()));
1193 let path = VarPath {
1194 segments: vec![VarSegment::Field("s".into()), VarSegment::Index(0)],
1195 };
1196 assert!(matches!(scope.resolve_path(&path), Err(PathError::Shape(_))));
1197 }
1198
1199 #[test]
1200 fn unset_dynamic_key_is_undefined_root_not_absence() {
1201 let mut scope = Scope::new();
1204 let r = subscripted(
1205 &mut scope,
1206 "r",
1207 serde_json::json!({"name": "amy"}),
1208 VarSegment::Dynamic("k".into()),
1209 );
1210 assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1211 }
1212
1213 #[test]
1214 fn contains_finds_variable() {
1215 let mut scope = Scope::new();
1216 scope.set("EXISTS", Value::Bool(true));
1217 assert!(scope.contains("EXISTS"));
1218 assert!(!scope.contains("MISSING"));
1219 }
1220
1221 #[test]
1222 fn all_names_lists_variables() {
1223 let mut scope = Scope::new();
1224 scope.set("A", Value::Int(1));
1225 scope.set("B", Value::Int(2));
1226 scope.push_frame();
1227 scope.set("C", Value::Int(3));
1228
1229 let names = scope.all_names();
1230 assert!(names.contains(&"A"));
1231 assert!(names.contains(&"B"));
1232 assert!(names.contains(&"C"));
1233 }
1234
1235 #[test]
1236 #[should_panic(expected = "cannot pop the root scope frame")]
1237 fn pop_root_frame_panics() {
1238 let mut scope = Scope::new();
1239 scope.pop_frame();
1240 }
1241
1242 #[test]
1243 fn positional_params_basic() {
1244 let mut scope = Scope::new();
1245 scope.set_positional("my_tool", vec!["arg1".into(), "arg2".into(), "arg3".into()]);
1246
1247 assert_eq!(scope.get_positional(0), Some("my_tool"));
1249 assert_eq!(scope.get_positional(1), Some("arg1"));
1251 assert_eq!(scope.get_positional(2), Some("arg2"));
1252 assert_eq!(scope.get_positional(3), Some("arg3"));
1253 assert_eq!(scope.get_positional(4), None);
1255 }
1256
1257 #[test]
1258 fn positional_params_empty() {
1259 let scope = Scope::new();
1260 assert_eq!(scope.get_positional(0), None);
1262 assert_eq!(scope.get_positional(1), None);
1263 assert_eq!(scope.arg_count(), 0);
1264 assert!(scope.all_args().is_empty());
1265 }
1266
1267 #[test]
1268 fn all_args_returns_slice() {
1269 let mut scope = Scope::new();
1270 scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
1271
1272 let args = scope.all_args();
1273 assert_eq!(args, &["a", "b", "c"]);
1274 }
1275
1276 #[test]
1277 fn arg_count_returns_count() {
1278 let mut scope = Scope::new();
1279 scope.set_positional("test", vec!["one".into(), "two".into()]);
1280
1281 assert_eq!(scope.arg_count(), 2);
1282 }
1283
1284 #[test]
1285 fn export_marks_variable() {
1286 let mut scope = Scope::new();
1287 scope.set("X", Value::Int(42));
1288
1289 assert!(!scope.is_exported("X"));
1290 scope.export("X");
1291 assert!(scope.is_exported("X"));
1292 }
1293
1294 #[test]
1295 fn set_exported_sets_and_exports() {
1296 let mut scope = Scope::new();
1297 scope.set_exported("PATH", Value::String("/usr/bin".into()));
1298
1299 assert!(scope.is_exported("PATH"));
1300 assert_eq!(scope.get("PATH"), Some(&Value::String("/usr/bin".into())));
1301 }
1302
1303 #[test]
1304 fn unexport_removes_export_marker() {
1305 let mut scope = Scope::new();
1306 scope.set_exported("VAR", Value::Int(1));
1307 assert!(scope.is_exported("VAR"));
1308
1309 scope.unexport("VAR");
1310 assert!(!scope.is_exported("VAR"));
1311 assert!(scope.get("VAR").is_some());
1313 }
1314
1315 #[test]
1316 fn exported_vars_returns_only_exported_with_values() {
1317 let mut scope = Scope::new();
1318 scope.set_exported("A", Value::Int(1));
1319 scope.set_exported("B", Value::Int(2));
1320 scope.set("C", Value::Int(3)); scope.export("D"); let exported = scope.exported_vars();
1324 assert_eq!(exported.len(), 2);
1325 assert_eq!(exported[0], ("A".to_string(), Value::Int(1)));
1326 assert_eq!(exported[1], ("B".to_string(), Value::Int(2)));
1327 }
1328
1329 #[test]
1330 fn exported_names_returns_sorted_names() {
1331 let mut scope = Scope::new();
1332 scope.export("Z");
1333 scope.export("A");
1334 scope.export("M");
1335
1336 let names = scope.exported_names();
1337 assert_eq!(names, vec!["A", "M", "Z"]);
1338 }
1339
1340 fn write_at(
1344 scope: &mut Scope,
1345 root: &str,
1346 segs: Vec<VarSegment>,
1347 ) -> Result<(), PathError> {
1348 let mut segments = vec![VarSegment::Field(root.into())];
1349 segments.extend(segs);
1350 scope.walk_write(&VarPath { segments }, Value::Int(0))
1351 }
1352
1353 #[test]
1354 fn walk_write_list_index_update() {
1355 let mut scope = Scope::new();
1356 scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1357 let path = VarPath {
1358 segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(0)],
1359 };
1360 scope.walk_write(&path, Value::Int(9)).expect("write should succeed");
1361 assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([9, 2, 3]))));
1362 }
1363
1364 #[test]
1365 fn walk_write_negative_index() {
1366 let mut scope = Scope::new();
1367 scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1368 let path = VarPath {
1369 segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(-1)],
1370 };
1371 scope.walk_write(&path, Value::Int(7)).expect("write should succeed");
1372 assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([1, 2, 7]))));
1373 }
1374
1375 #[test]
1376 fn walk_write_inserts_a_new_record_key() {
1377 let mut scope = Scope::new();
1378 scope.set("u", Value::Json(serde_json::json!({"port": 8080})));
1379 let path = VarPath {
1380 segments: vec![VarSegment::Field("u".into()), VarSegment::Key("host".into())],
1381 };
1382 scope
1383 .walk_write(&path, Value::String("localhost".into()))
1384 .expect("write should succeed");
1385 assert_eq!(
1386 scope.get("u"),
1387 Some(&Value::Json(serde_json::json!({"port": 8080, "host": "localhost"})))
1388 );
1389 }
1390
1391 #[test]
1392 fn walk_write_deep_path_updates_nested_key() {
1393 let mut scope = Scope::new();
1394 scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1395 let path = VarPath {
1396 segments: vec![
1397 VarSegment::Field("s".into()),
1398 VarSegment::Key("web".into()),
1399 VarSegment::Key("port".into()),
1400 ],
1401 };
1402 scope.walk_write(&path, Value::Int(9000)).expect("write should succeed");
1403 assert_eq!(
1404 scope.get("s"),
1405 Some(&Value::Json(serde_json::json!({"web": {"port": 9000}})))
1406 );
1407 }
1408
1409 #[test]
1410 fn walk_write_out_of_bounds_index_is_absence() {
1411 let mut scope = Scope::new();
1412 scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1413 let r = write_at(&mut scope, "xs", vec![VarSegment::Index(9)]);
1414 assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1415 }
1416
1417 #[test]
1418 fn walk_write_missing_intermediate_is_absence_no_autoviv() {
1419 let mut scope = Scope::new();
1420 scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
1421 let r = write_at(
1422 &mut scope,
1423 "s",
1424 vec![VarSegment::Key("api".into()), VarSegment::Key("port".into())],
1425 );
1426 assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
1427 assert_eq!(
1429 scope.get("s"),
1430 Some(&Value::Json(serde_json::json!({"web": {"port": 8080}})))
1431 );
1432 }
1433
1434 #[test]
1435 fn walk_write_scalar_root_is_shape() {
1436 let mut scope = Scope::new();
1437 scope.set("y", Value::String("hi".into()));
1438 let r = write_at(&mut scope, "y", vec![VarSegment::Index(0)]);
1439 assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1440 }
1441
1442 #[test]
1443 fn walk_write_undefined_root_is_undefined_root() {
1444 let mut scope = Scope::new();
1445 let r = write_at(&mut scope, "z", vec![VarSegment::Index(0)]);
1446 assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
1447 }
1448
1449 #[test]
1450 fn walk_write_slice_lvalue_is_shape() {
1451 let mut scope = Scope::new();
1452 scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
1453 let r = write_at(&mut scope, "xs", vec![VarSegment::Slice(Some(0), Some(2))]);
1454 assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
1455 }
1456
1457 #[test]
1460 fn walk_append_extends_a_list_in_place() {
1461 let mut scope = Scope::new();
1462 scope.set("xs", Value::Json(serde_json::json!(["a", "b"])));
1463 scope
1464 .walk_append(&VarPath::simple("xs"), vec![Value::String("c".into())])
1465 .expect("push should succeed");
1466 assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!(["a", "b", "c"]))));
1467 }
1468
1469 #[test]
1470 fn walk_append_undefined_target_is_a_loud_error() {
1471 let mut scope = Scope::new();
1472 let r = scope.walk_append(&VarPath::simple("nope"), vec![Value::Int(1)]);
1473 assert!(r.is_err(), "expected a loud error for an undefined target");
1474 }
1475
1476 #[test]
1477 fn walk_append_non_list_target_is_a_loud_error() {
1478 let mut scope = Scope::new();
1479 scope.set("y", Value::String("hi".into()));
1480 let r = scope.walk_append(&VarPath::simple("y"), vec![Value::Int(1)]);
1481 assert!(r.is_err(), "expected a loud error for a non-list target");
1482 }
1483
1484 #[test]
1485 fn walk_append_bracket_path_extends_a_nested_list_in_place() {
1486 let mut scope = Scope::new();
1487 scope.set(
1488 "services",
1489 Value::Json(serde_json::json!({"web": {"tags": ["a"]}})),
1490 );
1491 let path = VarPath {
1492 segments: vec![
1493 VarSegment::Field("services".into()),
1494 VarSegment::Key("web".into()),
1495 VarSegment::Key("tags".into()),
1496 ],
1497 };
1498 scope
1499 .walk_append(&path, vec![Value::String("b".into())])
1500 .expect("bracket-path push should succeed");
1501 assert_eq!(
1502 scope.get("services"),
1503 Some(&Value::Json(serde_json::json!({"web": {"tags": ["a", "b"]}})))
1504 );
1505 }
1506
1507 #[test]
1508 fn walk_append_bracket_path_missing_intermediate_is_a_loud_error() {
1509 let mut scope = Scope::new();
1510 scope.set("services", Value::Json(serde_json::json!({})));
1511 let path = VarPath {
1512 segments: vec![
1513 VarSegment::Field("services".into()),
1514 VarSegment::Key("web".into()),
1515 VarSegment::Key("tags".into()),
1516 ],
1517 };
1518 let r = scope.walk_append(&path, vec![Value::String("x".into())]);
1519 assert!(r.is_err(), "expected a loud error for a missing intermediate");
1520 }
1521
1522 #[test]
1523 fn walk_append_bracket_path_non_list_leaf_is_a_loud_error() {
1524 let mut scope = Scope::new();
1525 scope.set(
1526 "services",
1527 Value::Json(serde_json::json!({"web": {"port": 8080}})),
1528 );
1529 let path = VarPath {
1530 segments: vec![
1531 VarSegment::Field("services".into()),
1532 VarSegment::Key("web".into()),
1533 VarSegment::Key("port".into()),
1534 ],
1535 };
1536 let r = scope.walk_append(&path, vec![Value::Int(1)]);
1537 assert!(r.is_err(), "expected a loud error for a non-list leaf");
1538 }
1539}