use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use kaish_types::{json_to_value_no_envelope, value_to_json};
use crate::ast::{Value, VarPath, VarSegment};
use super::eval::value_to_string;
use super::result::ExecResult;
#[derive(Debug, Clone, PartialEq)]
pub enum PathError {
UndefinedRoot(String),
Absence(String),
Shape(String),
}
fn type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Int(_) => "an integer",
Value::Float(_) => "a float",
Value::String(_) => "a string",
Value::Json(serde_json::Value::Array(_)) => "a list",
Value::Json(serde_json::Value::Object(_)) => "a record",
Value::Json(_) => "a scalar",
Value::Bytes(_) => "binary data",
}
}
fn value_as_index(value: &Value) -> Option<i64> {
match value {
Value::Int(i) => Some(*i),
Value::String(s) => s.parse::<i64>().ok(),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq)]
enum Step {
Index(usize),
Key(String),
Slice(usize, usize),
}
fn classify_index(json: &serde_json::Value, i: i64, path: &str) -> Result<Step, PathError> {
let arr = match json {
serde_json::Value::Array(a) => a,
serde_json::Value::Object(_) => {
return Err(PathError::Shape(format!(
"${{{path}[{i}]}}: integer index on a record — record keys are strings, use ${{{path}[\"{i}\"]}}"
)))
}
_ => unreachable!("resolve_step guards non-collection containers"),
};
let len = arr.len() as i64;
let idx = if i < 0 { len + i } else { i };
if idx < 0 || idx >= len {
return Err(PathError::Absence(format!(
"${{{path}[{i}]}}: index out of bounds (list length {len})"
)));
}
Ok(Step::Index(idx as usize))
}
fn classify_key(json: &serde_json::Value, key: &str, path: &str) -> Result<Step, PathError> {
match json {
serde_json::Value::Object(_) => Ok(Step::Key(key.to_string())),
serde_json::Value::Array(_) => Err(PathError::Shape(format!(
"${{{path}[{key}]}}: string key on a list — use an integer index"
))),
_ => unreachable!("resolve_step guards non-collection containers"),
}
}
fn classify_slice(
json: &serde_json::Value,
start: Option<i64>,
end: Option<i64>,
path: &str,
) -> Result<Step, PathError> {
let arr = match json {
serde_json::Value::Array(a) => a,
serde_json::Value::Object(_) => {
return Err(PathError::Shape(format!(
"${{{path}[..]}}: cannot slice a record"
)))
}
_ => unreachable!("resolve_step guards non-collection containers"),
};
let len = arr.len() as i64;
let norm = |b: i64| -> i64 {
let b = if b < 0 { len + b } else { b };
b.clamp(0, len)
};
let s = start.map(norm).unwrap_or(0);
let e = end.map(norm).unwrap_or(len);
let (s, e) = if s >= e {
(s as usize, s as usize)
} else {
(s as usize, e as usize)
};
Ok(Step::Slice(s, e))
}
fn dotted_access_error(path: &str, field: &str) -> PathError {
PathError::Shape(format!(
"${{{path}…}}: kaish uses bracket access, not dots — write the key as a subscript: [{field}]"
))
}
fn render_segment(seg: &VarSegment) -> String {
match seg {
VarSegment::Index(i) => format!("[{i}]"),
VarSegment::Key(k) => format!("[{k}]"),
VarSegment::Dynamic(v) => format!("[${v}]"),
VarSegment::Slice(a, b) => format!(
"[{}:{}]",
a.map(|n| n.to_string()).unwrap_or_default(),
b.map(|n| n.to_string()).unwrap_or_default()
),
VarSegment::Field(f) => format!(".{f}"),
}
}
fn resolve_step(
container: &serde_json::Value,
seg: &VarSegment,
scope: &Scope,
path: &str,
) -> Result<Step, PathError> {
if let VarSegment::Field(name) = seg {
return Err(dotted_access_error(path, name));
}
if !matches!(
container,
serde_json::Value::Array(_) | serde_json::Value::Object(_)
) {
return Err(PathError::Shape(format!(
"${{{path}…}}: cannot subscript {} — it is not a collection",
type_name(&json_to_value_no_envelope(container.clone()))
)));
}
match seg {
VarSegment::Index(i) => classify_index(container, *i, path),
VarSegment::Key(k) => classify_key(container, k, path),
VarSegment::Slice(start, end) => classify_slice(container, *start, *end, path),
VarSegment::Dynamic(var) => {
let key_val = scope.get(var).ok_or_else(|| {
PathError::UndefinedRoot(format!("${{{path}[${var}]}}: ${var} is not set"))
})?;
match container {
serde_json::Value::Array(_) => {
let idx = value_as_index(key_val).ok_or_else(|| {
PathError::Shape(format!(
"${{{path}[${var}]}}: a list index must be an integer, got \"{}\"",
value_to_string(key_val)
))
})?;
classify_index(container, idx, path)
}
serde_json::Value::Object(_) => Ok(Step::Key(value_to_string(key_val))),
_ => unreachable!("non-collection container guarded above"),
}
}
VarSegment::Field(_) => unreachable!("dotted segment handled above"),
}
}
fn descend<'a>(
current: Cow<'a, serde_json::Value>,
step: Step,
path: &str,
) -> Result<Cow<'a, serde_json::Value>, PathError> {
match step {
Step::Slice(s, e) => {
let Some(arr) = current.as_array() else {
unreachable!("slice classified against an array")
};
Ok(Cow::Owned(serde_json::Value::Array(arr[s..e].to_vec())))
}
Step::Index(i) => match current {
Cow::Borrowed(j) => {
let Some(arr) = j.as_array() else {
unreachable!("index classified against an array")
};
Ok(Cow::Borrowed(&arr[i]))
}
Cow::Owned(j) => {
let Some(arr) = j.as_array() else {
unreachable!("index classified against an array")
};
Ok(Cow::Owned(arr[i].clone()))
}
},
Step::Key(k) => match current {
Cow::Borrowed(j) => match j.as_object().and_then(|m| m.get(&k)) {
Some(child) => Ok(Cow::Borrowed(child)),
None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
},
Cow::Owned(j) => match j.as_object().and_then(|m| m.get(&k)) {
Some(child) => Ok(Cow::Owned(child.clone())),
None => Err(PathError::Absence(format!("${{{path}[{k}]}}: no such key"))),
},
},
}
}
fn descend_mut<'a>(
current: &'a mut serde_json::Value,
step: Step,
path: &str,
) -> Result<&'a mut serde_json::Value, PathError> {
match step {
Step::Slice(..) => Err(PathError::Shape(format!(
"${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
))),
Step::Index(i) => {
let Some(arr) = current.as_array_mut() else {
unreachable!("index classified against an array")
};
Ok(&mut arr[i])
}
Step::Key(k) => {
let Some(map) = current.as_object_mut() else {
unreachable!("key classified against an object")
};
match map.get_mut(&k) {
Some(child) => Ok(child),
None => Err(PathError::Absence(format!(
"${{{path}[{k}]}}: no such key — no autovivification, create it first (e.g. `{path}[{k}]={{}}`)"
))),
}
}
}
}
fn apply_leaf_write(
current: &mut serde_json::Value,
step: Step,
value: serde_json::Value,
path: &str,
) -> Result<(), PathError> {
match step {
Step::Slice(..) => Err(PathError::Shape(format!(
"${{{path}[..]}}: slice lvalues are not supported — index or key paths only"
))),
Step::Index(i) => {
let Some(arr) = current.as_array_mut() else {
unreachable!("index classified against an array")
};
arr[i] = value;
Ok(())
}
Step::Key(k) => {
let Some(map) = current.as_object_mut() else {
unreachable!("key classified against an object")
};
map.insert(k, value);
Ok(())
}
}
}
fn push_path_error_message(err: PathError, root_name: &str) -> String {
match err {
PathError::UndefinedRoot(msg) if msg.is_empty() => {
format!("push: {root_name} is not defined")
}
PathError::UndefinedRoot(msg) => format!("push: {msg}"),
PathError::Absence(msg) | PathError::Shape(msg) => msg,
}
}
#[derive(Debug, Clone)]
pub struct Scope {
frames: Arc<Vec<HashMap<String, Value>>>,
exported: HashSet<String>,
last_result: Box<ExecResult>,
script_name: String,
positional: Vec<String>,
error_exit: bool,
errexit_suppressed: usize,
show_ast: bool,
latch_enabled: bool,
trash_enabled: bool,
trash_max_size: u64,
glob_enabled: bool,
pid: u64,
}
impl Scope {
pub fn new() -> Self {
Self {
frames: Arc::new(vec![HashMap::new()]),
exported: HashSet::new(),
last_result: Box::new(ExecResult::default()),
script_name: String::new(),
positional: Vec::new(),
error_exit: false,
errexit_suppressed: 0,
show_ast: false,
latch_enabled: false,
trash_enabled: false,
trash_max_size: 10 * 1024 * 1024, glob_enabled: true,
pid: 0,
}
}
pub fn pid(&self) -> u64 {
self.pid
}
pub fn set_pid(&mut self, pid: u64) {
self.pid = pid;
}
pub fn push_frame(&mut self) {
Arc::make_mut(&mut self.frames).push(HashMap::new());
}
pub fn pop_frame(&mut self) {
if self.frames.len() > 1 {
Arc::make_mut(&mut self.frames).pop();
} else {
panic!("cannot pop the root scope frame");
}
}
pub fn set(&mut self, name: impl Into<String>, value: Value) {
if let Some(frame) = Arc::make_mut(&mut self.frames).last_mut() {
frame.insert(name.into(), value);
}
}
pub fn set_global(&mut self, name: impl Into<String>, value: Value) {
let name = name.into();
let frames = Arc::make_mut(&mut self.frames);
for frame in frames.iter_mut().rev() {
if let std::collections::hash_map::Entry::Occupied(mut e) = frame.entry(name.clone()) {
e.insert(value);
return;
}
}
if let Some(frame) = frames.first_mut() {
frame.insert(name, value);
}
}
pub fn get(&self, name: &str) -> Option<&Value> {
for frame in self.frames.iter().rev() {
if let Some(value) = frame.get(name) {
return Some(value);
}
}
None
}
pub fn remove(&mut self, name: &str) -> Option<Value> {
for frame in Arc::make_mut(&mut self.frames).iter_mut().rev() {
if let Some(value) = frame.remove(name) {
return Some(value);
}
}
None
}
pub fn set_last_result(&mut self, result: ExecResult) {
*self.last_result = result;
}
pub fn last_result(&self) -> &ExecResult {
&self.last_result
}
pub fn set_positional(&mut self, script_name: impl Into<String>, args: Vec<String>) {
self.script_name = script_name.into();
self.positional = args;
}
pub fn save_positional(&self) -> (String, Vec<String>) {
(self.script_name.clone(), self.positional.clone())
}
pub fn get_positional(&self, n: usize) -> Option<&str> {
if n == 0 {
if self.script_name.is_empty() {
None
} else {
Some(&self.script_name)
}
} else {
self.positional.get(n - 1).map(|s| s.as_str())
}
}
pub fn all_args(&self) -> &[String] {
&self.positional
}
pub fn arg_count(&self) -> usize {
self.positional.len()
}
pub fn error_exit_enabled(&self) -> bool {
self.error_exit && self.errexit_suppressed == 0
}
pub fn set_error_exit(&mut self, enabled: bool) {
self.error_exit = enabled;
}
pub fn suppress_errexit(&mut self) {
self.errexit_suppressed += 1;
}
pub fn unsuppress_errexit(&mut self) {
self.errexit_suppressed = self.errexit_suppressed.saturating_sub(1);
}
pub fn show_ast(&self) -> bool {
self.show_ast
}
pub fn set_show_ast(&mut self, enabled: bool) {
self.show_ast = enabled;
}
pub fn latch_enabled(&self) -> bool {
self.latch_enabled
}
pub fn set_latch_enabled(&mut self, enabled: bool) {
self.latch_enabled = enabled;
}
pub fn trash_enabled(&self) -> bool {
self.trash_enabled
}
pub fn set_trash_enabled(&mut self, enabled: bool) {
self.trash_enabled = enabled;
}
pub fn trash_max_size(&self) -> u64 {
self.trash_max_size
}
pub fn set_trash_max_size(&mut self, size: u64) {
self.trash_max_size = size;
}
pub fn glob_enabled(&self) -> bool {
self.glob_enabled
}
pub fn set_glob_enabled(&mut self, enabled: bool) {
self.glob_enabled = enabled;
}
pub fn export(&mut self, name: impl Into<String>) {
self.exported.insert(name.into());
}
pub fn is_exported(&self, name: &str) -> bool {
self.exported.contains(name)
}
pub fn set_exported(&mut self, name: impl Into<String>, value: Value) {
let name = name.into();
self.set(&name, value);
self.export(name);
}
pub fn set_exported_global(&mut self, name: impl Into<String>, value: Value) {
let name = name.into();
self.set_global(&name, value);
self.export(name);
}
pub fn unexport(&mut self, name: &str) {
self.exported.remove(name);
}
pub fn exported_vars(&self) -> Vec<(String, Value)> {
let mut result = Vec::new();
for name in &self.exported {
if let Some(value) = self.get(name) {
result.push((name.clone(), value.clone()));
}
}
result.sort_by(|(a, _), (b, _)| a.cmp(b));
result
}
pub fn exported_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self.exported.iter().map(|s| s.as_str()).collect();
names.sort();
names
}
pub fn resolve_path(&self, path: &VarPath) -> Result<Value, PathError> {
let Some(VarSegment::Field(root_name)) = path.segments.first() else {
return Err(PathError::UndefinedRoot(String::new()));
};
if root_name == "?" {
if path.segments.len() == 1 {
return Ok(Value::Int(self.last_result.code));
}
return Err(PathError::Shape(
"$? is the POSIX exit code, not a collection — use `kaish-last` for structured data"
.to_string(),
));
}
let root = self
.get(root_name)
.ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
let subscripts = &path.segments[1..];
if subscripts.is_empty() {
return Ok(root.clone());
}
if let Some(VarSegment::Field(name)) = subscripts.first() {
return Err(dotted_access_error(root_name, name));
}
let root_json = match root {
Value::Json(j) => j,
other => {
return Err(PathError::Shape(format!(
"${{{root_name}…}}: cannot subscript {} — it is not a collection",
type_name(other)
)))
}
};
let mut current = Cow::Borrowed(root_json);
let mut prefix = root_name.clone();
for seg in subscripts {
let step = resolve_step(¤t, seg, self, &prefix)?;
current = descend(current, step, &prefix)?;
prefix.push_str(&render_segment(seg));
}
Ok(json_to_value_no_envelope(current.into_owned()))
}
pub fn walk_write(&mut self, path: &VarPath, value: Value) -> Result<(), PathError> {
let Some(VarSegment::Field(root_name)) = path.segments.first() else {
return Err(PathError::UndefinedRoot(String::new()));
};
let root = self
.get(root_name)
.ok_or_else(|| PathError::UndefinedRoot(root_name.clone()))?;
let mut root_json = match root {
Value::Json(j) => j.clone(),
other => {
return Err(PathError::Shape(format!(
"${{{root_name}…}}: cannot subscript {} — it is not a collection",
type_name(other)
)))
}
};
let subscripts = &path.segments[1..];
let Some((last, intermediates)) = subscripts.split_last() else {
return Err(PathError::Shape(format!(
"{root_name}: assignment target has no subscript"
)));
};
let mut current = &mut root_json;
let mut prefix = root_name.clone();
for seg in intermediates {
let step = resolve_step(current, seg, self, &prefix)?;
current = descend_mut(current, step, &prefix)?;
prefix.push_str(&render_segment(seg));
}
let step = resolve_step(current, last, self, &prefix)?;
apply_leaf_write(current, step, value_to_json(&value), &prefix)?;
self.set_global(root_name.clone(), Value::Json(root_json));
Ok(())
}
pub fn walk_append(&mut self, path: &VarPath, values: Vec<Value>) -> Result<(), String> {
let Some(VarSegment::Field(root_name)) = path.segments.first() else {
return Err("push: target has no root".to_string());
};
let root_name = root_name.clone();
let current = self
.get(&root_name)
.ok_or_else(|| format!("push: {root_name} is not defined"))?
.clone();
let subscripts = &path.segments[1..];
if subscripts.is_empty() {
if !matches!(current, Value::Json(serde_json::Value::Array(_))) {
return Err(format!("push: {root_name} is not a list ({})", type_name(¤t)));
}
let Value::Json(serde_json::Value::Array(mut arr)) = current else {
unreachable!("checked above")
};
arr.extend(values.iter().map(value_to_json));
self.set_global(root_name, Value::Json(serde_json::Value::Array(arr)));
return Ok(());
}
let mut root_json = match current {
Value::Json(j) => j,
other => {
return Err(format!(
"push: {root_name}…: cannot subscript {} — it is not a collection",
type_name(&other)
))
}
};
let mut cur = &mut root_json;
let mut prefix = root_name.clone();
for seg in subscripts {
let step = resolve_step(cur, seg, self, &prefix)
.map_err(|e| push_path_error_message(e, &root_name))?;
cur = descend_mut(cur, step, &prefix)
.map_err(|e| push_path_error_message(e, &root_name))?;
prefix.push_str(&render_segment(seg));
}
let serde_json::Value::Array(arr) = cur else {
return Err(format!(
"push: {prefix} is not a list ({})",
type_name(&json_to_value_no_envelope(cur.clone()))
));
};
arr.extend(values.iter().map(value_to_json));
self.set_global(root_name, Value::Json(root_json));
Ok(())
}
pub fn contains(&self, name: &str) -> bool {
self.get(name).is_some()
}
pub fn all_names(&self) -> Vec<&str> {
let mut names: Vec<&str> = self
.frames
.iter()
.flat_map(|f| f.keys().map(|s| s.as_str()))
.collect();
names.sort();
names.dedup();
names
}
pub fn all(&self) -> Vec<(String, Value)> {
let mut result = std::collections::HashMap::new();
for frame in self.frames.iter() {
for (name, value) in frame {
result.insert(name.clone(), value.clone());
}
}
let mut pairs: Vec<_> = result.into_iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
pairs
}
}
impl Default for Scope {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_scope_has_one_frame() {
let scope = Scope::new();
assert_eq!(scope.frames.len(), 1);
}
#[test]
fn set_and_get_variable() {
let mut scope = Scope::new();
scope.set("X", Value::Int(42));
assert_eq!(scope.get("X"), Some(&Value::Int(42)));
}
#[test]
fn get_nonexistent_returns_none() {
let scope = Scope::new();
assert_eq!(scope.get("MISSING"), None);
}
#[test]
fn inner_frame_shadows_outer() {
let mut scope = Scope::new();
scope.set("X", Value::Int(1));
scope.push_frame();
scope.set("X", Value::Int(2));
assert_eq!(scope.get("X"), Some(&Value::Int(2)));
scope.pop_frame();
assert_eq!(scope.get("X"), Some(&Value::Int(1)));
}
#[test]
fn inner_frame_can_see_outer_vars() {
let mut scope = Scope::new();
scope.set("OUTER", Value::String("visible".into()));
scope.push_frame();
assert_eq!(scope.get("OUTER"), Some(&Value::String("visible".into())));
}
#[test]
fn resolve_simple_path() {
let mut scope = Scope::new();
scope.set("NAME", Value::String("Alice".into()));
let path = VarPath::simple("NAME");
assert_eq!(
scope.resolve_path(&path),
Ok(Value::String("Alice".into()))
);
}
#[test]
fn resolve_bare_last_result_returns_exit_code() {
let mut scope = Scope::new();
scope.set_last_result(ExecResult::failure(127, "not found"));
let path = VarPath {
segments: vec![VarSegment::Field("?".into())],
};
assert_eq!(scope.resolve_path(&path), Ok(Value::Int(127)));
}
#[test]
fn resolve_last_result_field_access_is_rejected() {
let mut scope = Scope::new();
scope.set_last_result(ExecResult::success_with_data(
"1",
Value::Json(serde_json::json!({"count": 5})),
));
let path = VarPath {
segments: vec![
VarSegment::Field("?".into()),
VarSegment::Field("data".into()),
],
};
assert!(matches!(
scope.resolve_path(&path),
Err(PathError::Shape(_))
));
}
#[test]
fn resolve_dotted_access_on_scalar_is_a_loud_error() {
let mut scope = Scope::new();
scope.set("X", Value::Int(42));
let path = VarPath {
segments: vec![
VarSegment::Field("X".into()),
VarSegment::Field("invalid".into()),
],
};
assert!(matches!(
scope.resolve_path(&path),
Err(PathError::Shape(_))
));
}
#[test]
fn resolve_undefined_root_is_soft() {
let scope = Scope::new();
let path = VarPath::simple("NOPE");
assert!(matches!(
scope.resolve_path(&path),
Err(PathError::UndefinedRoot(_))
));
}
fn subscripted(scope: &mut Scope, root: &str, value: serde_json::Value, seg: VarSegment) -> Result<Value, PathError> {
scope.set(root, Value::Json(value));
let path = VarPath {
segments: vec![VarSegment::Field(root.into()), seg],
};
scope.resolve_path(&path)
}
#[test]
fn out_of_bounds_index_is_absence() {
let mut scope = Scope::new();
let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Index(9));
assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
}
#[test]
fn missing_record_key_is_absence() {
let mut scope = Scope::new();
let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Key("nope".into()));
assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
}
#[test]
fn string_key_on_a_list_is_shape() {
let mut scope = Scope::new();
let r = subscripted(&mut scope, "xs", serde_json::json!([1, 2]), VarSegment::Key("web".into()));
assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
}
#[test]
fn integer_index_on_a_record_is_shape() {
let mut scope = Scope::new();
let r = subscripted(&mut scope, "u", serde_json::json!({"name": "amy"}), VarSegment::Index(0));
assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
}
#[test]
fn subscripting_a_scalar_is_shape() {
let mut scope = Scope::new();
scope.set("s", Value::String("hello".into()));
let path = VarPath {
segments: vec![VarSegment::Field("s".into()), VarSegment::Index(0)],
};
assert!(matches!(scope.resolve_path(&path), Err(PathError::Shape(_))));
}
#[test]
fn unset_dynamic_key_is_undefined_root_not_absence() {
let mut scope = Scope::new();
let r = subscripted(
&mut scope,
"r",
serde_json::json!({"name": "amy"}),
VarSegment::Dynamic("k".into()),
);
assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
}
#[test]
fn contains_finds_variable() {
let mut scope = Scope::new();
scope.set("EXISTS", Value::Bool(true));
assert!(scope.contains("EXISTS"));
assert!(!scope.contains("MISSING"));
}
#[test]
fn all_names_lists_variables() {
let mut scope = Scope::new();
scope.set("A", Value::Int(1));
scope.set("B", Value::Int(2));
scope.push_frame();
scope.set("C", Value::Int(3));
let names = scope.all_names();
assert!(names.contains(&"A"));
assert!(names.contains(&"B"));
assert!(names.contains(&"C"));
}
#[test]
#[should_panic(expected = "cannot pop the root scope frame")]
fn pop_root_frame_panics() {
let mut scope = Scope::new();
scope.pop_frame();
}
#[test]
fn positional_params_basic() {
let mut scope = Scope::new();
scope.set_positional("my_tool", vec!["arg1".into(), "arg2".into(), "arg3".into()]);
assert_eq!(scope.get_positional(0), Some("my_tool"));
assert_eq!(scope.get_positional(1), Some("arg1"));
assert_eq!(scope.get_positional(2), Some("arg2"));
assert_eq!(scope.get_positional(3), Some("arg3"));
assert_eq!(scope.get_positional(4), None);
}
#[test]
fn positional_params_empty() {
let scope = Scope::new();
assert_eq!(scope.get_positional(0), None);
assert_eq!(scope.get_positional(1), None);
assert_eq!(scope.arg_count(), 0);
assert!(scope.all_args().is_empty());
}
#[test]
fn all_args_returns_slice() {
let mut scope = Scope::new();
scope.set_positional("test", vec!["a".into(), "b".into(), "c".into()]);
let args = scope.all_args();
assert_eq!(args, &["a", "b", "c"]);
}
#[test]
fn arg_count_returns_count() {
let mut scope = Scope::new();
scope.set_positional("test", vec!["one".into(), "two".into()]);
assert_eq!(scope.arg_count(), 2);
}
#[test]
fn export_marks_variable() {
let mut scope = Scope::new();
scope.set("X", Value::Int(42));
assert!(!scope.is_exported("X"));
scope.export("X");
assert!(scope.is_exported("X"));
}
#[test]
fn set_exported_sets_and_exports() {
let mut scope = Scope::new();
scope.set_exported("PATH", Value::String("/usr/bin".into()));
assert!(scope.is_exported("PATH"));
assert_eq!(scope.get("PATH"), Some(&Value::String("/usr/bin".into())));
}
#[test]
fn unexport_removes_export_marker() {
let mut scope = Scope::new();
scope.set_exported("VAR", Value::Int(1));
assert!(scope.is_exported("VAR"));
scope.unexport("VAR");
assert!(!scope.is_exported("VAR"));
assert!(scope.get("VAR").is_some());
}
#[test]
fn exported_vars_returns_only_exported_with_values() {
let mut scope = Scope::new();
scope.set_exported("A", Value::Int(1));
scope.set_exported("B", Value::Int(2));
scope.set("C", Value::Int(3)); scope.export("D");
let exported = scope.exported_vars();
assert_eq!(exported.len(), 2);
assert_eq!(exported[0], ("A".to_string(), Value::Int(1)));
assert_eq!(exported[1], ("B".to_string(), Value::Int(2)));
}
#[test]
fn exported_names_returns_sorted_names() {
let mut scope = Scope::new();
scope.export("Z");
scope.export("A");
scope.export("M");
let names = scope.exported_names();
assert_eq!(names, vec!["A", "M", "Z"]);
}
fn write_at(
scope: &mut Scope,
root: &str,
segs: Vec<VarSegment>,
) -> Result<(), PathError> {
let mut segments = vec![VarSegment::Field(root.into())];
segments.extend(segs);
scope.walk_write(&VarPath { segments }, Value::Int(0))
}
#[test]
fn walk_write_list_index_update() {
let mut scope = Scope::new();
scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
let path = VarPath {
segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(0)],
};
scope.walk_write(&path, Value::Int(9)).expect("write should succeed");
assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([9, 2, 3]))));
}
#[test]
fn walk_write_negative_index() {
let mut scope = Scope::new();
scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
let path = VarPath {
segments: vec![VarSegment::Field("xs".into()), VarSegment::Index(-1)],
};
scope.walk_write(&path, Value::Int(7)).expect("write should succeed");
assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!([1, 2, 7]))));
}
#[test]
fn walk_write_inserts_a_new_record_key() {
let mut scope = Scope::new();
scope.set("u", Value::Json(serde_json::json!({"port": 8080})));
let path = VarPath {
segments: vec![VarSegment::Field("u".into()), VarSegment::Key("host".into())],
};
scope
.walk_write(&path, Value::String("localhost".into()))
.expect("write should succeed");
assert_eq!(
scope.get("u"),
Some(&Value::Json(serde_json::json!({"port": 8080, "host": "localhost"})))
);
}
#[test]
fn walk_write_deep_path_updates_nested_key() {
let mut scope = Scope::new();
scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
let path = VarPath {
segments: vec![
VarSegment::Field("s".into()),
VarSegment::Key("web".into()),
VarSegment::Key("port".into()),
],
};
scope.walk_write(&path, Value::Int(9000)).expect("write should succeed");
assert_eq!(
scope.get("s"),
Some(&Value::Json(serde_json::json!({"web": {"port": 9000}})))
);
}
#[test]
fn walk_write_out_of_bounds_index_is_absence() {
let mut scope = Scope::new();
scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
let r = write_at(&mut scope, "xs", vec![VarSegment::Index(9)]);
assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
}
#[test]
fn walk_write_missing_intermediate_is_absence_no_autoviv() {
let mut scope = Scope::new();
scope.set("s", Value::Json(serde_json::json!({"web": {"port": 8080}})));
let r = write_at(
&mut scope,
"s",
vec![VarSegment::Key("api".into()), VarSegment::Key("port".into())],
);
assert!(matches!(r, Err(PathError::Absence(_))), "got: {r:?}");
assert_eq!(
scope.get("s"),
Some(&Value::Json(serde_json::json!({"web": {"port": 8080}})))
);
}
#[test]
fn walk_write_scalar_root_is_shape() {
let mut scope = Scope::new();
scope.set("y", Value::String("hi".into()));
let r = write_at(&mut scope, "y", vec![VarSegment::Index(0)]);
assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
}
#[test]
fn walk_write_undefined_root_is_undefined_root() {
let mut scope = Scope::new();
let r = write_at(&mut scope, "z", vec![VarSegment::Index(0)]);
assert!(matches!(r, Err(PathError::UndefinedRoot(_))), "got: {r:?}");
}
#[test]
fn walk_write_slice_lvalue_is_shape() {
let mut scope = Scope::new();
scope.set("xs", Value::Json(serde_json::json!([1, 2, 3])));
let r = write_at(&mut scope, "xs", vec![VarSegment::Slice(Some(0), Some(2))]);
assert!(matches!(r, Err(PathError::Shape(_))), "got: {r:?}");
}
#[test]
fn walk_append_extends_a_list_in_place() {
let mut scope = Scope::new();
scope.set("xs", Value::Json(serde_json::json!(["a", "b"])));
scope
.walk_append(&VarPath::simple("xs"), vec![Value::String("c".into())])
.expect("push should succeed");
assert_eq!(scope.get("xs"), Some(&Value::Json(serde_json::json!(["a", "b", "c"]))));
}
#[test]
fn walk_append_undefined_target_is_a_loud_error() {
let mut scope = Scope::new();
let r = scope.walk_append(&VarPath::simple("nope"), vec![Value::Int(1)]);
assert!(r.is_err(), "expected a loud error for an undefined target");
}
#[test]
fn walk_append_non_list_target_is_a_loud_error() {
let mut scope = Scope::new();
scope.set("y", Value::String("hi".into()));
let r = scope.walk_append(&VarPath::simple("y"), vec![Value::Int(1)]);
assert!(r.is_err(), "expected a loud error for a non-list target");
}
#[test]
fn walk_append_bracket_path_extends_a_nested_list_in_place() {
let mut scope = Scope::new();
scope.set(
"services",
Value::Json(serde_json::json!({"web": {"tags": ["a"]}})),
);
let path = VarPath {
segments: vec![
VarSegment::Field("services".into()),
VarSegment::Key("web".into()),
VarSegment::Key("tags".into()),
],
};
scope
.walk_append(&path, vec![Value::String("b".into())])
.expect("bracket-path push should succeed");
assert_eq!(
scope.get("services"),
Some(&Value::Json(serde_json::json!({"web": {"tags": ["a", "b"]}})))
);
}
#[test]
fn walk_append_bracket_path_missing_intermediate_is_a_loud_error() {
let mut scope = Scope::new();
scope.set("services", Value::Json(serde_json::json!({})));
let path = VarPath {
segments: vec![
VarSegment::Field("services".into()),
VarSegment::Key("web".into()),
VarSegment::Key("tags".into()),
],
};
let r = scope.walk_append(&path, vec![Value::String("x".into())]);
assert!(r.is_err(), "expected a loud error for a missing intermediate");
}
#[test]
fn walk_append_bracket_path_non_list_leaf_is_a_loud_error() {
let mut scope = Scope::new();
scope.set(
"services",
Value::Json(serde_json::json!({"web": {"port": 8080}})),
);
let path = VarPath {
segments: vec![
VarSegment::Field("services".into()),
VarSegment::Key("web".into()),
VarSegment::Key("port".into()),
],
};
let r = scope.walk_append(&path, vec![Value::Int(1)]);
assert!(r.is_err(), "expected a loud error for a non-list leaf");
}
}