1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
//! push — append value(s) to a list variable, in place.
//!
//! See `docs/LANGUAGE.md`, "Assignment — bracket-path lvalues + `push`". The
//! spelling is frozen: kaish has one append idiom, not two. `push`
//! mutates the named list in place, so it takes the variable **name**
//! (bareword, like `read`/`unset`), not `$name` — `push xs date`, not
//! `push $xs date`. The pure functional `append` builtin was deliberately
//! never added: its only niche (build a new list with an extra element) is
//! already covered by `...` spread (`new=[...$xs date]`), and a return-value
//! `append` re-imports the silent-discard trap the design doc's evidence #6
//! called out (a model wrote `append $colors purple` and threw the result
//! away, then reported the OLD length).
//!
//! `push` to an undefined target, or a target that isn't a list, is a loud
//! runtime error — never a silent create (see `Scope::walk_append`).
//!
//! **Bracket-path targets.** `push services[web][tags] item` walks the
//! nested path the same way an assignment lvalue does — a missing
//! intermediate key or a non-list leaf is a loud error, never autoviv (see
//! `Scope::walk_append`). The lexer recognizes `push`'s target with its own
//! trigger (independent of the `=`-followed lvalue trigger an assignment
//! uses — the target has no trailing `=` to key off) so
//! `services[web][tags]` fuses verbatim into a path to walk instead of
//! glob-expanding against the filesystem (GH #183); see
//! `lexer::PushTarget`.
//!
//! # Examples
//!
//! ```kaish
//! xs=[a b]
//! push xs c # xs is now [a b c]
//! push xs $rec # values are read as typed Values, not stringified
//! push services[web][tags] canary # bracket-path target
//! ```
use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use crate::ast::Value;
use crate::interpreter::ExecResult;
use crate::tools::{schema_from_clap, validate_against_schema, ExecContext, GlobalFlags, Tool, ToolArgs, ToolCtx, ToolSchema};
use crate::validator::ValidationIssue;
/// push tool: append value(s) to a list variable, in place.
pub struct Push;
/// clap-derived argv layer for push.
#[derive(Parser, Debug)]
#[command(name = "push", about = "Append value(s) to a list variable, in place")]
struct PushArgs {
#[command(flatten)]
global: GlobalFlags,
/// Target name, then the values to append.
// Hidden sink: the real values are read off `args.positional` as typed
// `Value`s, not stringified.
#[arg(hide = true)]
rest: Vec<String>,
}
#[async_trait]
impl Tool for Push {
fn name(&self) -> &str {
"push"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&PushArgs::command(),
"push",
"Append value(s) to a list variable, in place",
[
("Append one element", "push xs date"),
("Append a record value", "push xs $rec"),
("Append to a nested list", "push services[web][tags] canary"),
],
)
}
fn validate(&self, args: &ToolArgs) -> Vec<ValidationIssue> {
let mut issues = validate_against_schema(args, &self.schema());
// Only the first positional is a name; the rest are values, and a
// subscript is data the author chose (same split `execute` makes).
if let Some(Value::String(target)) = args.positional.first() {
let root = target.split('[').next().unwrap_or(target);
issues.extend(super::mixed_script_issue(root));
}
issues
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let argv = match args.to_argv() {
Ok(v) => v,
Err(e) => return ExecResult::failure(2, format!("push: {e}")),
};
let parsed = match PushArgs::try_parse_from(
std::iter::once("push".to_string()).chain(argv),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("push: {e}")),
};
parsed.global.apply(ctx);
// Values MUST come from `args.positional` (typed), not the clap
// struct — `to_argv()` stringifies, so `push xs $rec` would push the
// stringified record instead of the record itself.
let Some(target) = args.positional.first() else {
return ExecResult::failure(2, "push: usage: push NAME VALUE...");
};
let name = match target {
Value::String(s) => s.clone(),
other => {
return ExecResult::failure(
2,
format!(
"push: target must be a bareword variable name (e.g. `push xs val`), \
not {other:?}"
),
)
}
};
// The lexer hands a bareword (`xs`) and a bracket path
// (`services[web][tags]`) through identically — a fused `Ident`
// token, verbatim — so both are parsed the same way here via the
// shared `${...}` path grammar (`services[web][tags]` round-trips
// through it exactly like `${services[web][tags]}`'s interior).
// Only the root is a name — a subscript is data the author chose, and
// its bytes are its own. Checked before the append so `push` refuses a
// root that `$x` would refuse to read back.
let root = name.split('[').next().unwrap_or(name.as_str());
if let Err(bad) = crate::name::validate(root) {
return ExecResult::failure(2, format!("push: `{root}': {bad}"));
}
let path = crate::parser::parse_varpath(&format!("${{{name}}}"));
let values: Vec<Value> = args.positional[1..].to_vec();
if values.is_empty() {
return ExecResult::failure(2, "push: usage: push NAME VALUE...");
}
match ctx.scope.walk_append(&path, values) {
Ok(()) => ExecResult::success(""),
Err(msg) => ExecResult::failure(1, msg),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;
fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn push_appends_to_list_in_place() {
let mut ctx = make_ctx();
ctx.scope.set(
"xs",
Value::Json(serde_json::json!(["a", "b"])),
);
let mut args = ToolArgs::new();
args.positional.push(Value::String("xs".into()));
args.positional.push(Value::String("c".into()));
let result = Push.execute(args, &mut ctx).await;
assert!(result.ok(), "push failed: {}", result.err);
assert_eq!(
ctx.scope.get("xs"),
Some(&Value::Json(serde_json::json!(["a", "b", "c"])))
);
}
#[tokio::test]
async fn push_pushes_a_record_value_untouched() {
let mut ctx = make_ctx();
ctx.scope.set("xs", Value::Json(serde_json::json!([])));
let mut args = ToolArgs::new();
args.positional.push(Value::String("xs".into()));
args.positional
.push(Value::Json(serde_json::json!({"k": "v"})));
let result = Push.execute(args, &mut ctx).await;
assert!(result.ok(), "push failed: {}", result.err);
assert_eq!(
ctx.scope.get("xs"),
Some(&Value::Json(serde_json::json!([{"k": "v"}])))
);
}
#[tokio::test]
async fn push_undefined_target_is_a_loud_error() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.positional.push(Value::String("nope".into()));
args.positional.push(Value::String("x".into()));
let result = Push.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("not defined"), "got: {}", result.err);
}
#[tokio::test]
async fn push_non_list_target_is_a_loud_error() {
let mut ctx = make_ctx();
ctx.scope.set("y", Value::String("hi".into()));
let mut args = ToolArgs::new();
args.positional.push(Value::String("y".into()));
args.positional.push(Value::String("z".into()));
let result = Push.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("not a list"), "got: {}", result.err);
}
/// Bracket-path target (GH #183): the target string arrives already
/// fused verbatim by the lexer (`lexer::PushTarget`) — this pins the
/// Tool→`Scope::walk_append` wiring directly, independent of lexing.
#[tokio::test]
async fn push_bracket_path_target_extends_the_nested_list() {
let mut ctx = make_ctx();
ctx.scope.set(
"services",
Value::Json(serde_json::json!({"web": {"tags": ["a"]}})),
);
let mut args = ToolArgs::new();
args.positional.push(Value::String("services[web][tags]".into()));
args.positional.push(Value::String("b".into()));
let result = Push.execute(args, &mut ctx).await;
assert!(result.ok(), "push failed: {}", result.err);
assert_eq!(
ctx.scope.get("services"),
Some(&Value::Json(serde_json::json!({"web": {"tags": ["a", "b"]}})))
);
}
}