@tishlang/tish-format 2.37.0 → 2.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tish-format +0 -0
- package/crates/tish_compile/src/codegen.rs +10 -1
- package/crates/tish_compile/src/infer.rs +521 -17
- package/crates/tish_compile/src/resolve.rs +3 -1
- package/crates/tish_compile/src/types.rs +13 -4
- package/crates/tish_compile/tests/regr_484_obj_field_param.rs +18 -0
- package/crates/tish_compile/tests/regr_e0382_typed_struct.rs +26 -0
- package/crates/tish_compile/tests/regr_numeric_arg_whitelist.rs +29 -0
- package/crates/tish_compile/tests/regr_serve_closure.rs +22 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/crates/tish_native/src/build.rs +1 -0
- package/crates/tish_runtime/Cargo.toml +5 -0
- package/crates/tish_runtime/src/lib.rs +5 -0
- package/crates/tish_runtime/src/pty.rs +451 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
package/bin/tish-format
CHANGED
|
Binary file
|
|
@@ -1714,6 +1714,15 @@ impl Codegen {
|
|
|
1714
1714
|
"readLine" => Some("Value::native(|args: &[Value]| tishlang_runtime::tty_read_line(args))"),
|
|
1715
1715
|
_ => None,
|
|
1716
1716
|
},
|
|
1717
|
+
"tish:pty" if self.has_feature("pty") => match export_name {
|
|
1718
|
+
"spawn" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_spawn(args))"),
|
|
1719
|
+
"read" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_read(args))"),
|
|
1720
|
+
"write" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_write(args))"),
|
|
1721
|
+
"resize" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_resize(args))"),
|
|
1722
|
+
"kill" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_kill(args))"),
|
|
1723
|
+
"pid" => Some("Value::native(|args: &[Value]| tishlang_runtime::pty_pid(args))"),
|
|
1724
|
+
_ => None,
|
|
1725
|
+
},
|
|
1717
1726
|
_ => return None,
|
|
1718
1727
|
};
|
|
1719
1728
|
init.map(String::from)
|
|
@@ -1723,7 +1732,7 @@ impl Codegen {
|
|
|
1723
1732
|
if self.features.contains("full") {
|
|
1724
1733
|
matches!(
|
|
1725
1734
|
name,
|
|
1726
|
-
"http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty"
|
|
1735
|
+
"http" | "timers" | "fs" | "process" | "regex" | "ws" | "tty" | "pty"
|
|
1727
1736
|
)
|
|
1728
1737
|
} else {
|
|
1729
1738
|
self.features.contains(name)
|
|
@@ -283,7 +283,7 @@ fn param_infer_program(program: Program) -> Program {
|
|
|
283
283
|
// numeric" (`return SHARED`) yet the caller passes e.g. `"arg"`, which the f64 coercion would
|
|
284
284
|
// panic on / turn to NaN where JS keeps the value. Skip those fns (they stay fully boxed). Same
|
|
285
285
|
// set `collect_native_fns` uses to refuse the M5 native form.
|
|
286
|
-
let nonnumeric =
|
|
286
|
+
let nonnumeric = fns_with_unsafe_numeric_arg(&program.statements);
|
|
287
287
|
// Soundness (#477): a fn referenced as a first-class VALUE — passed as a call ARGUMENT
|
|
288
288
|
// (`serve(port, handler)`), assigned, returned, or stored, i.e. any use that is NOT a direct
|
|
289
289
|
// `f(...)` call — escapes into the boxed `Value::Function` path and is later invoked via
|
|
@@ -322,6 +322,522 @@ fn escaping_fn_names(statements: &[Statement]) -> HashSet<String> {
|
|
|
322
322
|
out
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/// Shared candidate predicate: would param `pname` (unannotated, default-less) of a fn with `body`
|
|
326
|
+
/// be M4-promoted to `: number` on the basis of its BODY uses (the #172 optimistic per-param
|
|
327
|
+
/// fixpoint)? Used by BOTH pi_stmt (the promotion) and `numeric_candidate_positions` (the call-arg
|
|
328
|
+
/// whitelist) so the two agree on exactly which positions are numeric candidates.
|
|
329
|
+
fn param_is_body_numeric_candidate(body: &Statement, base_nums: &HashSet<String>, pname: &str) -> bool {
|
|
330
|
+
let mut nums = base_nums.clone();
|
|
331
|
+
nums.insert(pname.to_string());
|
|
332
|
+
collect_numeric_locals_fixpoint(body, &mut nums);
|
|
333
|
+
let copy_descendants: Vec<&String> = nums
|
|
334
|
+
.iter()
|
|
335
|
+
.filter(|x| x.as_str() != pname && !base_nums.contains(*x))
|
|
336
|
+
.collect();
|
|
337
|
+
nus_stmt(body, pname, &nums)
|
|
338
|
+
&& copy_descendants.iter().all(|x| lns_stmt(body, x.as_str(), &nums))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/// analyze_aggregate's S-0 numeric-param set for one fn: the optimistic `pus_stmt` fixpoint (a param
|
|
342
|
+
/// used only as an object-field VALUE / numeric operand is numeric). Shared with the call-arg
|
|
343
|
+
/// whitelist so its candidate set covers what S-0 (via `stamp_numeric_params`) actually promotes.
|
|
344
|
+
fn s0_numeric_candidates(params: &[FunParam], body: &Statement) -> HashSet<String> {
|
|
345
|
+
let mut nums = HashSet::new();
|
|
346
|
+
collect_numeric_locals_fixpoint(body, &mut nums);
|
|
347
|
+
let mut numeric_params: HashSet<String> = params
|
|
348
|
+
.iter()
|
|
349
|
+
.filter_map(|p| match p {
|
|
350
|
+
FunParam::Simple(tp) if tp.type_ann.is_none() && tp.default.is_none() => {
|
|
351
|
+
Some(tp.name.to_string())
|
|
352
|
+
}
|
|
353
|
+
_ => None,
|
|
354
|
+
})
|
|
355
|
+
.collect();
|
|
356
|
+
loop {
|
|
357
|
+
let mut local_nums = nums.clone();
|
|
358
|
+
for n in &numeric_params {
|
|
359
|
+
local_nums.insert(n.clone());
|
|
360
|
+
}
|
|
361
|
+
let drop: Vec<String> = numeric_params
|
|
362
|
+
.iter()
|
|
363
|
+
.filter(|n| !pus_stmt(body, n.as_str(), &local_nums))
|
|
364
|
+
.cloned()
|
|
365
|
+
.collect();
|
|
366
|
+
if drop.is_empty() {
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
for n in drop {
|
|
370
|
+
numeric_params.remove(&n);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
numeric_params
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/// Per fn (top-level and nested), the parameter POSITIONS that are numeric candidates — i.e. either
|
|
377
|
+
/// promotion path would M4-promote them to f64. The call-arg whitelist (#485/#477/#484) checks ONLY
|
|
378
|
+
/// these positions, so array/object params (e.g. native-vec devirt) never disqualify a fn.
|
|
379
|
+
pub(crate) fn numeric_candidate_positions(
|
|
380
|
+
statements: &[Statement],
|
|
381
|
+
) -> std::collections::HashMap<String, Vec<(usize, String)>> {
|
|
382
|
+
let mut out = std::collections::HashMap::new();
|
|
383
|
+
ncp_stmt_list(statements, &mut out);
|
|
384
|
+
out
|
|
385
|
+
}
|
|
386
|
+
fn ncp_stmt_list(statements: &[Statement], out: &mut std::collections::HashMap<String, Vec<(usize, String)>>) {
|
|
387
|
+
for s in statements {
|
|
388
|
+
ncp_stmt(s, out);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
fn ncp_stmt(s: &Statement, out: &mut std::collections::HashMap<String, Vec<(usize, String)>>) {
|
|
392
|
+
use Statement::*;
|
|
393
|
+
match s {
|
|
394
|
+
FunDecl { name, params, body, .. } => {
|
|
395
|
+
let mut base_nums = HashSet::new();
|
|
396
|
+
collect_numeric_locals_fixpoint(body, &mut base_nums);
|
|
397
|
+
// A position is a candidate if EITHER promotion path would take it: pi_stmt (nus_stmt) OR
|
|
398
|
+
// analyze_aggregate's S-0 (pus_stmt — which also accepts a param used as an object-field
|
|
399
|
+
// VALUE, e.g. `{ v: s }`). The whitelist must cover BOTH or a param S-0 promotes but
|
|
400
|
+
// pi_stmt doesn't (tishlang/tish#484) gets an f64 unbox with no call-arg check.
|
|
401
|
+
let s0 = s0_numeric_candidates(params, body);
|
|
402
|
+
let mut positions: Vec<(usize, String)> = Vec::new();
|
|
403
|
+
for (i, p) in params.iter().enumerate() {
|
|
404
|
+
if let FunParam::Simple(tp) = p {
|
|
405
|
+
if tp.type_ann.is_none()
|
|
406
|
+
&& tp.default.is_none()
|
|
407
|
+
&& (param_is_body_numeric_candidate(body, &base_nums, tp.name.as_ref())
|
|
408
|
+
|| s0.contains(tp.name.as_ref()))
|
|
409
|
+
{
|
|
410
|
+
positions.push((i, tp.name.to_string()));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if !positions.is_empty() {
|
|
415
|
+
out.insert(name.to_string(), positions);
|
|
416
|
+
}
|
|
417
|
+
ncp_stmt(body, out); // nested fns
|
|
418
|
+
}
|
|
419
|
+
Block { statements, .. } | Multi { statements, .. } => ncp_stmt_list(statements, out),
|
|
420
|
+
If { then_branch, else_branch, .. } => {
|
|
421
|
+
ncp_stmt(then_branch, out);
|
|
422
|
+
if let Some(e) = else_branch {
|
|
423
|
+
ncp_stmt(e, out);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
While { body, .. }
|
|
427
|
+
| DoWhile { body, .. }
|
|
428
|
+
| For { body, .. }
|
|
429
|
+
| ForOf { body, .. }
|
|
430
|
+
| ForIn { body, .. } => ncp_stmt(body, out),
|
|
431
|
+
Try { body, catch_body, finally_body, .. } => {
|
|
432
|
+
ncp_stmt(body, out);
|
|
433
|
+
if let Some(c) = catch_body {
|
|
434
|
+
ncp_stmt(c, out);
|
|
435
|
+
}
|
|
436
|
+
if let Some(f) = finally_body {
|
|
437
|
+
ncp_stmt(f, out);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
Switch { cases, default_body, .. } => {
|
|
441
|
+
for (_, b) in cases {
|
|
442
|
+
ncp_stmt_list(b, out);
|
|
443
|
+
}
|
|
444
|
+
if let Some(d) = default_body {
|
|
445
|
+
ncp_stmt_list(d, out);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
Export { declaration, .. } => {
|
|
449
|
+
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
|
|
450
|
+
ncp_stmt(inner, out);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
_ => {}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---- interprocedural numeric-arg soundness (tishlang/tish#485/#477/#484) ----
|
|
458
|
+
//
|
|
459
|
+
// M4 promotes an unannotated param to native f64 and unboxes it at the (boxed) closure entry, which
|
|
460
|
+
// panics `"expected number"` if a caller ever passes a non-number. Instead of BLACKLISTING the known
|
|
461
|
+
// ways that happens (a non-numeric literal / an escaping fn / an await), compute — soundly, by
|
|
462
|
+
// construction — which parameter POSITIONS provably receive only numbers, then disqualify any fn with
|
|
463
|
+
// a body-numeric CANDIDATE position that is NOT provably numeric. An optimistic fixpoint (assume all
|
|
464
|
+
// positions numeric, then drop any that a call site violates) makes pass-through params transitive:
|
|
465
|
+
// `f(n)` where `n` is a numeric param of the caller stays numeric down the chain, back to a literal.
|
|
466
|
+
|
|
467
|
+
#[derive(Clone)]
|
|
468
|
+
enum ArgClass {
|
|
469
|
+
Num, // provably a number regardless of context (literal, arithmetic, Math)
|
|
470
|
+
NotNum, // provably NOT reliably a number (string/object/unknown-return call)
|
|
471
|
+
NeedsNumeric(Vec<String>), // a number iff every listed ident is numeric in the caller's context
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
struct CallSite {
|
|
475
|
+
caller: Option<String>, // enclosing fn (None = top level)
|
|
476
|
+
callee: String,
|
|
477
|
+
args: Vec<ArgClass>,
|
|
478
|
+
has_spread: bool,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
fn arg_number_producing_call(callee: &Expr) -> bool {
|
|
482
|
+
match callee {
|
|
483
|
+
Expr::Ident { name, .. } => name.as_ref() == "Number",
|
|
484
|
+
Expr::Member { object, prop: tishlang_ast::MemberProp::Name { name: m, .. }, .. } => {
|
|
485
|
+
matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
|
|
486
|
+
&& matches!(
|
|
487
|
+
m.as_ref(),
|
|
488
|
+
"sqrt" | "sin" | "cos" | "tan" | "abs" | "floor" | "ceil" | "exp" | "trunc"
|
|
489
|
+
| "log" | "log2" | "log10" | "round" | "pow" | "min" | "max" | "sign"
|
|
490
|
+
| "cbrt" | "atan" | "atan2" | "asin" | "acos" | "hypot" | "random"
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
_ => false,
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
fn combine_add(l: ArgClass, r: ArgClass) -> ArgClass {
|
|
498
|
+
match (l, r) {
|
|
499
|
+
(ArgClass::NotNum, _) | (_, ArgClass::NotNum) => ArgClass::NotNum,
|
|
500
|
+
(ArgClass::Num, ArgClass::Num) => ArgClass::Num,
|
|
501
|
+
(ArgClass::Num, x) | (x, ArgClass::Num) => x,
|
|
502
|
+
(ArgClass::NeedsNumeric(mut a), ArgClass::NeedsNumeric(b)) => {
|
|
503
|
+
a.extend(b);
|
|
504
|
+
ArgClass::NeedsNumeric(a)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
fn classify_arg(e: &Expr) -> ArgClass {
|
|
510
|
+
use tishlang_ast::BinOp::*;
|
|
511
|
+
match e {
|
|
512
|
+
Expr::Literal { value: Literal::Number(_), .. } => ArgClass::Num,
|
|
513
|
+
Expr::Ident { name, .. } => ArgClass::NeedsNumeric(vec![name.to_string()]),
|
|
514
|
+
// `-`,`*`,`/`,`%`,`**`, bitwise/shift JS-ToNumber unconditionally → always a number.
|
|
515
|
+
Expr::Binary {
|
|
516
|
+
op: Sub | Mul | Div | Mod | Pow | BitAnd | BitOr | BitXor | Shl | Shr | UShr,
|
|
517
|
+
..
|
|
518
|
+
} => ArgClass::Num,
|
|
519
|
+
// `+` is a number only if BOTH sides are (else string concat).
|
|
520
|
+
Expr::Binary { op: Add, left, right, .. } => {
|
|
521
|
+
combine_add(classify_arg(left), classify_arg(right))
|
|
522
|
+
}
|
|
523
|
+
Expr::Unary {
|
|
524
|
+
op:
|
|
525
|
+
tishlang_ast::UnaryOp::Neg
|
|
526
|
+
| tishlang_ast::UnaryOp::Pos
|
|
527
|
+
| tishlang_ast::UnaryOp::BitNot,
|
|
528
|
+
..
|
|
529
|
+
} => ArgClass::Num,
|
|
530
|
+
Expr::Call { callee, .. } | Expr::New { callee, .. } => {
|
|
531
|
+
if arg_number_producing_call(callee) {
|
|
532
|
+
ArgClass::Num
|
|
533
|
+
} else {
|
|
534
|
+
ArgClass::NotNum
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
_ => ArgClass::NotNum,
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
fn arg_ok(ac: &ArgClass, ctx: &HashSet<String>) -> bool {
|
|
542
|
+
match ac {
|
|
543
|
+
ArgClass::Num => true,
|
|
544
|
+
ArgClass::NotNum => false,
|
|
545
|
+
ArgClass::NeedsNumeric(idents) => idents.iter().all(|n| ctx.contains(n)),
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Collect (param-name-list, numeric-locals) for every fn (top-level and nested).
|
|
550
|
+
fn cfm_stmt(
|
|
551
|
+
s: &Statement,
|
|
552
|
+
params: &mut std::collections::HashMap<String, Vec<String>>,
|
|
553
|
+
locals: &mut std::collections::HashMap<String, HashSet<String>>,
|
|
554
|
+
) {
|
|
555
|
+
use Statement::*;
|
|
556
|
+
match s {
|
|
557
|
+
FunDecl { name, params: ps, body, .. } => {
|
|
558
|
+
let pnames: Vec<String> = ps
|
|
559
|
+
.iter()
|
|
560
|
+
.map(|p| match p {
|
|
561
|
+
FunParam::Simple(tp) => tp.name.to_string(),
|
|
562
|
+
_ => String::new(),
|
|
563
|
+
})
|
|
564
|
+
.collect();
|
|
565
|
+
params.insert(name.to_string(), pnames);
|
|
566
|
+
let mut nums = HashSet::new();
|
|
567
|
+
collect_numeric_locals_fixpoint(body, &mut nums);
|
|
568
|
+
locals.insert(name.to_string(), nums);
|
|
569
|
+
cfm_stmt(body, params, locals);
|
|
570
|
+
}
|
|
571
|
+
Block { statements, .. } | Multi { statements, .. } => {
|
|
572
|
+
for x in statements {
|
|
573
|
+
cfm_stmt(x, params, locals);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
If { then_branch, else_branch, .. } => {
|
|
577
|
+
cfm_stmt(then_branch, params, locals);
|
|
578
|
+
if let Some(e) = else_branch {
|
|
579
|
+
cfm_stmt(e, params, locals);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. }
|
|
583
|
+
| ForIn { body, .. } => cfm_stmt(body, params, locals),
|
|
584
|
+
Try { body, catch_body, finally_body, .. } => {
|
|
585
|
+
cfm_stmt(body, params, locals);
|
|
586
|
+
if let Some(c) = catch_body {
|
|
587
|
+
cfm_stmt(c, params, locals);
|
|
588
|
+
}
|
|
589
|
+
if let Some(f) = finally_body {
|
|
590
|
+
cfm_stmt(f, params, locals);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
Switch { cases, default_body, .. } => {
|
|
594
|
+
for (_, b) in cases {
|
|
595
|
+
for x in b {
|
|
596
|
+
cfm_stmt(x, params, locals);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if let Some(d) = default_body {
|
|
600
|
+
for x in d {
|
|
601
|
+
cfm_stmt(x, params, locals);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
Export { declaration, .. } => {
|
|
606
|
+
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
|
|
607
|
+
cfm_stmt(inner, params, locals);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
_ => {}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Record every direct `f(...)` call in `e`, tagged with the enclosing fn; recurse into sub-exprs.
|
|
615
|
+
fn collect_calls_expr(e: &Expr, encl: &Option<String>, out: &mut Vec<CallSite>) {
|
|
616
|
+
if let Expr::Call { callee, args, .. } | Expr::New { callee, args, .. } = e {
|
|
617
|
+
if let Expr::Ident { name, .. } = callee.as_ref() {
|
|
618
|
+
let mut classes = Vec::new();
|
|
619
|
+
let mut has_spread = false;
|
|
620
|
+
for a in args {
|
|
621
|
+
match a {
|
|
622
|
+
CallArg::Expr(x) => classes.push(classify_arg(x)),
|
|
623
|
+
CallArg::Spread(_) => has_spread = true,
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
out.push(CallSite {
|
|
627
|
+
caller: encl.clone(),
|
|
628
|
+
callee: name.to_string(),
|
|
629
|
+
args: classes,
|
|
630
|
+
has_spread,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
walk_exprs_expr(e, &mut |sub| {
|
|
635
|
+
if let Expr::Call { callee, args, .. } | Expr::New { callee, args, .. } = sub {
|
|
636
|
+
if !std::ptr::eq(sub, e) {
|
|
637
|
+
if let Expr::Ident { name, .. } = callee.as_ref() {
|
|
638
|
+
let mut classes = Vec::new();
|
|
639
|
+
let mut has_spread = false;
|
|
640
|
+
for a in args {
|
|
641
|
+
match a {
|
|
642
|
+
CallArg::Expr(x) => classes.push(classify_arg(x)),
|
|
643
|
+
CallArg::Spread(_) => has_spread = true,
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
out.push(CallSite {
|
|
647
|
+
caller: encl.clone(),
|
|
648
|
+
callee: name.to_string(),
|
|
649
|
+
args: classes,
|
|
650
|
+
has_spread,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
fn collect_calls_stmt(s: &Statement, encl: &Option<String>, out: &mut Vec<CallSite>) {
|
|
659
|
+
use Statement::*;
|
|
660
|
+
match s {
|
|
661
|
+
FunDecl { name, body, .. } => {
|
|
662
|
+
let inner = Some(name.to_string());
|
|
663
|
+
collect_calls_stmt(body, &inner, out);
|
|
664
|
+
}
|
|
665
|
+
Block { statements, .. } | Multi { statements, .. } => {
|
|
666
|
+
for x in statements {
|
|
667
|
+
collect_calls_stmt(x, encl, out);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
VarDecl { init: Some(e), .. } => collect_calls_expr(e, encl, out),
|
|
671
|
+
VarDeclDestructure { init, .. } => collect_calls_expr(init, encl, out),
|
|
672
|
+
ExprStmt { expr, .. } => collect_calls_expr(expr, encl, out),
|
|
673
|
+
Return { value: Some(e), .. } | Throw { value: e, .. } => collect_calls_expr(e, encl, out),
|
|
674
|
+
If { cond, then_branch, else_branch, .. } => {
|
|
675
|
+
collect_calls_expr(cond, encl, out);
|
|
676
|
+
collect_calls_stmt(then_branch, encl, out);
|
|
677
|
+
if let Some(e) = else_branch {
|
|
678
|
+
collect_calls_stmt(e, encl, out);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
While { cond, body, .. } | DoWhile { cond, body, .. } => {
|
|
682
|
+
collect_calls_expr(cond, encl, out);
|
|
683
|
+
collect_calls_stmt(body, encl, out);
|
|
684
|
+
}
|
|
685
|
+
For { init, cond, update, body, .. } => {
|
|
686
|
+
if let Some(i) = init {
|
|
687
|
+
collect_calls_stmt(i, encl, out);
|
|
688
|
+
}
|
|
689
|
+
if let Some(c) = cond {
|
|
690
|
+
collect_calls_expr(c, encl, out);
|
|
691
|
+
}
|
|
692
|
+
if let Some(u) = update {
|
|
693
|
+
collect_calls_expr(u, encl, out);
|
|
694
|
+
}
|
|
695
|
+
collect_calls_stmt(body, encl, out);
|
|
696
|
+
}
|
|
697
|
+
ForOf { iterable, body, .. } | ForIn { object: iterable, body, .. } => {
|
|
698
|
+
collect_calls_expr(iterable, encl, out);
|
|
699
|
+
collect_calls_stmt(body, encl, out);
|
|
700
|
+
}
|
|
701
|
+
Switch { expr, cases, default_body, .. } => {
|
|
702
|
+
collect_calls_expr(expr, encl, out);
|
|
703
|
+
for (g, b) in cases {
|
|
704
|
+
if let Some(ge) = g {
|
|
705
|
+
collect_calls_expr(ge, encl, out);
|
|
706
|
+
}
|
|
707
|
+
for x in b {
|
|
708
|
+
collect_calls_stmt(x, encl, out);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if let Some(d) = default_body {
|
|
712
|
+
for x in d {
|
|
713
|
+
collect_calls_stmt(x, encl, out);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
Try { body, catch_body, finally_body, .. } => {
|
|
718
|
+
collect_calls_stmt(body, encl, out);
|
|
719
|
+
if let Some(c) = catch_body {
|
|
720
|
+
collect_calls_stmt(c, encl, out);
|
|
721
|
+
}
|
|
722
|
+
if let Some(f) = finally_body {
|
|
723
|
+
collect_calls_stmt(f, encl, out);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
Export { declaration, .. } => {
|
|
727
|
+
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
|
|
728
|
+
collect_calls_stmt(inner, encl, out);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
_ => {}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/// SOUND replacement for the "nonnumeric arg" blacklist (tishlang/tish#485/#477/#484): a per-position
|
|
736
|
+
/// interprocedural fixpoint. Returns the set of fns that have a body-numeric CANDIDATE param position
|
|
737
|
+
/// which is NOT provably fed numbers at every call site — those must stay boxed (not `: number`).
|
|
738
|
+
pub(crate) fn fns_with_unsafe_numeric_arg(statements: &[Statement]) -> HashSet<String> {
|
|
739
|
+
let cand = numeric_candidate_positions(statements);
|
|
740
|
+
if cand.is_empty() {
|
|
741
|
+
return HashSet::new();
|
|
742
|
+
}
|
|
743
|
+
let mut all_params: std::collections::HashMap<String, Vec<String>> =
|
|
744
|
+
std::collections::HashMap::new();
|
|
745
|
+
let mut fn_locals: std::collections::HashMap<String, HashSet<String>> =
|
|
746
|
+
std::collections::HashMap::new();
|
|
747
|
+
for s in statements {
|
|
748
|
+
cfm_stmt(s, &mut all_params, &mut fn_locals);
|
|
749
|
+
}
|
|
750
|
+
// Top-level numeric locals — run a fixpoint ACROSS statements so a chain propagates
|
|
751
|
+
// (`let PI = …; let SOLAR_MASS = 4 * PI * PI` ⇒ both numeric, so `body(…, SOLAR_MASS)` is safe).
|
|
752
|
+
let mut top_nums = HashSet::new();
|
|
753
|
+
loop {
|
|
754
|
+
let before = top_nums.len();
|
|
755
|
+
for s in statements {
|
|
756
|
+
collect_numeric_locals_fixpoint(s, &mut top_nums);
|
|
757
|
+
}
|
|
758
|
+
if top_nums.len() == before {
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
let mut calls = Vec::new();
|
|
763
|
+
for s in statements {
|
|
764
|
+
collect_calls_stmt(s, &None, &mut calls);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// numeric_pos[f] = positions currently believed to receive only numbers. Start optimistic (all).
|
|
768
|
+
let mut numeric_pos: std::collections::HashMap<String, HashSet<usize>> = all_params
|
|
769
|
+
.iter()
|
|
770
|
+
.map(|(f, ps)| (f.clone(), (0..ps.len()).collect()))
|
|
771
|
+
.collect();
|
|
772
|
+
|
|
773
|
+
loop {
|
|
774
|
+
let mut changed = false;
|
|
775
|
+
for cs in &calls {
|
|
776
|
+
if !numeric_pos.contains_key(&cs.callee) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
// Caller context: its numeric locals + params whose position is still numeric.
|
|
780
|
+
let ctx: HashSet<String> = match &cs.caller {
|
|
781
|
+
None => top_nums.clone(),
|
|
782
|
+
Some(c) => {
|
|
783
|
+
// Top-level numeric globals (`SOLAR_MASS`, `PI`, …) are numeric inside every fn,
|
|
784
|
+
// plus this fn's numeric locals + its still-numeric params.
|
|
785
|
+
let mut ctx = top_nums.clone();
|
|
786
|
+
ctx.extend(fn_locals.get(c).cloned().unwrap_or_default());
|
|
787
|
+
if let (Some(ps), Some(np)) = (all_params.get(c), numeric_pos.get(c)) {
|
|
788
|
+
for &pos in np {
|
|
789
|
+
if let Some(n) = ps.get(pos) {
|
|
790
|
+
ctx.insert(n.clone());
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
ctx
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
let arity = all_params.get(&cs.callee).map(|p| p.len()).unwrap_or(0);
|
|
798
|
+
let set = numeric_pos.get_mut(&cs.callee).unwrap();
|
|
799
|
+
if cs.has_spread {
|
|
800
|
+
if !set.is_empty() {
|
|
801
|
+
set.clear();
|
|
802
|
+
changed = true;
|
|
803
|
+
}
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
for pos in 0..arity {
|
|
807
|
+
if !set.contains(&pos) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
let ok = match cs.args.get(pos) {
|
|
811
|
+
Some(ac) => arg_ok(ac, &ctx),
|
|
812
|
+
None => false, // missing arg → undefined/Null → not a number
|
|
813
|
+
};
|
|
814
|
+
if !ok {
|
|
815
|
+
set.remove(&pos);
|
|
816
|
+
changed = true;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if !changed {
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let mut disq = HashSet::new();
|
|
826
|
+
for (f, positions) in &cand {
|
|
827
|
+
match numeric_pos.get(f) {
|
|
828
|
+
Some(np) => {
|
|
829
|
+
if positions.iter().any(|(pos, _)| !np.contains(pos)) {
|
|
830
|
+
disq.insert(f.clone());
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
None => {
|
|
834
|
+
disq.insert(f.clone());
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
disq
|
|
839
|
+
}
|
|
840
|
+
|
|
325
841
|
fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>, escaping: &HashSet<String>) -> Statement {
|
|
326
842
|
if let Statement::FunDecl {
|
|
327
843
|
async_,
|
|
@@ -374,21 +890,9 @@ fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>, escaping: &HashSet<String
|
|
|
374
890
|
// Monotone, locals/param-candidate-only.
|
|
375
891
|
if tp.type_ann.is_none()
|
|
376
892
|
&& tp.default.is_none()
|
|
893
|
+
&& param_is_body_numeric_candidate(&body, &base_nums, tp.name.as_ref())
|
|
377
894
|
{
|
|
378
|
-
|
|
379
|
-
nums.insert(tp.name.to_string());
|
|
380
|
-
collect_numeric_locals_fixpoint(&body, &mut nums);
|
|
381
|
-
let copy_descendants: Vec<&String> = nums
|
|
382
|
-
.iter()
|
|
383
|
-
.filter(|x| x.as_str() != tp.name.as_ref() && !base_nums.contains(*x))
|
|
384
|
-
.collect();
|
|
385
|
-
if nus_stmt(&body, tp.name.as_ref(), &nums)
|
|
386
|
-
&& copy_descendants
|
|
387
|
-
.iter()
|
|
388
|
-
.all(|x| lns_stmt(&body, x.as_str(), &nums))
|
|
389
|
-
{
|
|
390
|
-
tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number"), tishlang_ast::Span::default()));
|
|
391
|
-
}
|
|
895
|
+
tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number"), tishlang_ast::Span::default()));
|
|
392
896
|
}
|
|
393
897
|
FunParam::Simple(tp)
|
|
394
898
|
}
|
|
@@ -484,7 +988,7 @@ fn collect_numeric_locals(s: &Statement, out: &mut HashSet<String>) {
|
|
|
484
988
|
/// names (a copy of an already-numeric source), so it converges in at most one pass per copy-chain
|
|
485
989
|
/// link; the `out.len()` watermark detects quiescence. Soundness: every added name is a copy of a
|
|
486
990
|
/// value already proven/assumed numeric, the same basis as the literal seeding.
|
|
487
|
-
fn collect_numeric_locals_fixpoint(body: &Statement, out: &mut HashSet<String>) {
|
|
991
|
+
pub(crate) fn collect_numeric_locals_fixpoint(body: &Statement, out: &mut HashSet<String>) {
|
|
488
992
|
loop {
|
|
489
993
|
let before = out.len();
|
|
490
994
|
collect_numeric_locals(body, out);
|
|
@@ -3710,7 +4214,7 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
|
|
|
3710
4214
|
// params either — `fn makeRecord(id, name){ return {id, name} }` called `makeRecord(1, "Alice")`
|
|
3711
4215
|
// would otherwise emit a `match … panic!("expected number")` coercion on the String. Same set the
|
|
3712
4216
|
// M4 `param_infer` path uses (infer.rs:273).
|
|
3713
|
-
let nonnumeric =
|
|
4217
|
+
let nonnumeric = fns_with_unsafe_numeric_arg(&program.statements);
|
|
3714
4218
|
// Soundness (#477): a fn referenced as a first-class VALUE (a `serve` handler / stored callback)
|
|
3715
4219
|
// is invoked via the boxed `Value::Function` path with arbitrary runtime Values, so — exactly as
|
|
3716
4220
|
// in `param_infer_program` — its params must NOT get an S-0 `: number` shadow either. Without this
|
|
@@ -59,6 +59,7 @@ const BUILTIN_ALIASES: &[(&str, &str)] = &[
|
|
|
59
59
|
("process", "tish:process"),
|
|
60
60
|
("ws", "tish:ws"),
|
|
61
61
|
("tty", "tish:tty"),
|
|
62
|
+
("pty", "tish:pty"),
|
|
62
63
|
];
|
|
63
64
|
|
|
64
65
|
/// Normalize built-in spec to canonical form. Handles the Node `node:` prefix
|
|
@@ -87,9 +88,10 @@ pub fn is_builtin_native_spec(spec: &str) -> bool {
|
|
|
87
88
|
| "tish:process"
|
|
88
89
|
| "tish:ws"
|
|
89
90
|
| "tish:tty"
|
|
91
|
+
| "tish:pty"
|
|
90
92
|
) || matches!(
|
|
91
93
|
spec,
|
|
92
|
-
"fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty"
|
|
94
|
+
"fs" | "fs/promises" | "http" | "timers" | "process" | "ws" | "tty" | "pty"
|
|
93
95
|
)
|
|
94
96
|
}
|
|
95
97
|
|
|
@@ -390,13 +390,18 @@ impl RustType {
|
|
|
390
390
|
let field_assigns = fields
|
|
391
391
|
.iter()
|
|
392
392
|
.map(|(k, ty)| {
|
|
393
|
-
let fetch = format!("tishlang_runtime::get_prop(
|
|
393
|
+
let fetch = format!("tishlang_runtime::get_prop(_src, {:?})", k.as_ref());
|
|
394
394
|
format!("{}: {}", field_ident(k), ty.from_value_expr(&fetch))
|
|
395
395
|
})
|
|
396
396
|
.collect::<Vec<_>>()
|
|
397
397
|
.join(", ");
|
|
398
|
+
// Bind `_src` as a BORROW (not a move) of the source Value: field fetches only need
|
|
399
|
+
// `&Value` (get_prop borrows), and moving would use-after-move a caller-owned temp —
|
|
400
|
+
// e.g. an assignment-as-expression `{ let _v = obj; lhs = <coerce _v>; _v }` reuses
|
|
401
|
+
// `_v` for its value (tishlang/tish#486). `&(expr)` evaluates the source once and
|
|
402
|
+
// (for a temporary) lifetime-extends it to this block, so it's still single-eval.
|
|
398
403
|
format!(
|
|
399
|
-
"{{ let _src = {}; {} {{ {} }} }}",
|
|
404
|
+
"{{ let _src = &({}); {} {{ {} }} }}",
|
|
400
405
|
value_expr,
|
|
401
406
|
named_struct_ident(name),
|
|
402
407
|
field_assigns
|
|
@@ -448,9 +453,13 @@ impl RustType {
|
|
|
448
453
|
)
|
|
449
454
|
}
|
|
450
455
|
RustType::Option(inner) => {
|
|
451
|
-
|
|
456
|
+
// Box by REFERENCE so the source Option is NOT moved — it may be a local captured by
|
|
457
|
+
// an FnMut closure (a `serve()` handler, called per request) or used again after this
|
|
458
|
+
// conversion. Mirrors the Vec arm's `.iter()` (which likewise avoids moving). `(*v)`
|
|
459
|
+
// derefs the `&inner` the ref-match binds, so the inner's own clone/copy still applies.
|
|
460
|
+
let inner_to_value = inner.to_value_expr("(*v)");
|
|
452
461
|
format!(
|
|
453
|
-
"match {} {{ Some(v) => {}, None => Value::Null }}",
|
|
462
|
+
"match &({}) {{ Some(v) => {}, None => Value::Null }}",
|
|
454
463
|
native_expr, inner_to_value
|
|
455
464
|
)
|
|
456
465
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//! tishlang/tish#484 — a string param used only as an object-field VALUE (`{ v: s }`) must not be
|
|
2
|
+
//! native-promoted to f64. analyze_aggregate's S-0 (`pus_stmt`) treats a field-value param as
|
|
3
|
+
//! numeric; the call-arg whitelist must cover S-0's candidate set, so `f("hello")` disqualifies f.
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use tishlang_compile::compile_project_full;
|
|
6
|
+
fn compile(rel: &str) -> String {
|
|
7
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
8
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
9
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
10
|
+
}
|
|
11
|
+
#[test]
|
|
12
|
+
fn obj_field_string_param_stays_boxed() {
|
|
13
|
+
for f in ["tests/regression/obj_field_string_param.tish", "tests/regression/async_param_across_await.tish"] {
|
|
14
|
+
let rust = compile(f);
|
|
15
|
+
let bad: Vec<&str> = rust.lines().filter(|l| l.contains("expected number")).collect();
|
|
16
|
+
assert!(bad.is_empty(), "{f}: string field-value param must not f64-unbox:\n{}", bad.join("\n"));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//! tishlang/tish#486 — a typed nullable-struct local reassigned to a struct literal in a conditional
|
|
2
|
+
//! must not emit a Value→struct coercion that MOVES the source temp, because the assignment-as-
|
|
3
|
+
//! expression wrapper (`{ let _v = obj; lhs = <coerce _v>; _v }`) reuses that temp for its value —
|
|
4
|
+
//! moving it produced `error[E0382]: use of moved value`. The coercion must borrow the source.
|
|
5
|
+
use std::path::PathBuf;
|
|
6
|
+
use tishlang_compile::compile_project_full;
|
|
7
|
+
|
|
8
|
+
fn compile(rel: &str) -> String {
|
|
9
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
10
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
11
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn typed_struct_coercion_borrows_source() {
|
|
16
|
+
let rust = compile("tests/regression/typed_struct_cond_assign.tish");
|
|
17
|
+
// The Value→struct coercion must bind `_src` by BORROW, never by moving a bare temp.
|
|
18
|
+
assert!(
|
|
19
|
+
rust.contains("let _src = &("),
|
|
20
|
+
"expected the struct coercion to borrow its source (`let _src = &(...)`)"
|
|
21
|
+
);
|
|
22
|
+
assert!(
|
|
23
|
+
!rust.contains("let _src = _v;"),
|
|
24
|
+
"tishlang/tish#486: struct coercion must not MOVE the wrapper temp `_v` (use-after-move / E0382)"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//! tishlang/tish#485/#477/#484 — SYSTEMIC: a param is native-promoted to f64 only if the
|
|
2
|
+
//! interprocedural per-position fixpoint proves every call site feeds it numbers. #485's
|
|
3
|
+
//! return-either-param helper (called with string|null results) must NOT get an f64 unbox; the
|
|
4
|
+
//! recursive + pass-through numeric fns MUST still promote (no perf regression).
|
|
5
|
+
use std::path::PathBuf;
|
|
6
|
+
use tishlang_compile::compile_project_full;
|
|
7
|
+
|
|
8
|
+
fn compile(rel: &str) -> String {
|
|
9
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
10
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
11
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn unsafe_numeric_arg_stays_boxed_no_f64_unbox() {
|
|
16
|
+
let rust = compile("tests/regression/f64_return_either_param.tish");
|
|
17
|
+
let bad: Vec<&str> = rust.lines().filter(|l| l.contains("expected number")).collect();
|
|
18
|
+
assert!(bad.is_empty(), "no fn here does arithmetic; nothing should f64-unbox:\n{}", bad.join("\n"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[test]
|
|
22
|
+
fn recursive_and_passthrough_numeric_fns_still_promote() {
|
|
23
|
+
let rust = compile("tests/regression/numeric_arg_whitelist.tish");
|
|
24
|
+
// fib(n-1)/fib(n-2): n is arithmetic → promoted; work(n) via driver's param n → pass-through.
|
|
25
|
+
assert!(
|
|
26
|
+
rust.contains("fib") && (rust.contains(": f64") || rust.contains("Value::Number")),
|
|
27
|
+
"recursive/pass-through numeric params must still get the native f64 fast path"
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//! tishlang/tish — a typed Option (`string?`) captured by a long-lived FnMut closure (a `serve()`
|
|
2
|
+
//! handler, called per request) must not be MOVED when boxed to a Value for a `=== null` comparison.
|
|
3
|
+
//! `to_value_expr(Option)` must match by REFERENCE (`match &(…)`), like the Vec arm's `.iter()`.
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use tishlang_compile::compile_project_full;
|
|
6
|
+
fn compile(rel: &str) -> String {
|
|
7
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
8
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
9
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
10
|
+
}
|
|
11
|
+
#[test]
|
|
12
|
+
fn option_boxing_borrows_not_moves() {
|
|
13
|
+
let rust = compile("tests/regression/serve_closure_captures_typed.tish");
|
|
14
|
+
assert!(
|
|
15
|
+
rust.contains("match &(sToken)"),
|
|
16
|
+
"Option→Value boxing must match by reference so the source isn't moved"
|
|
17
|
+
);
|
|
18
|
+
assert!(
|
|
19
|
+
!rust.contains("match sToken { Some(v)"),
|
|
20
|
+
"Option→Value boxing must not match by value (moves the closure-captured Option)"
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -58,6 +58,9 @@ fs = ["promise", "dep:rand"]
|
|
|
58
58
|
process = []
|
|
59
59
|
# Interactive terminal I/O (issue #101): raw mode, key/resize events, size, alt screen.
|
|
60
60
|
tty = ["dep:crossterm"]
|
|
61
|
+
# Pseudoterminal hosting (issue #493): spawn a shell/program on a real PTY, read/write/resize/kill —
|
|
62
|
+
# so an interactive terminal emulator on the other end of a socket behaves like a real TTY.
|
|
63
|
+
pty = ["dep:portable-pty", "dep:lazy_static"]
|
|
61
64
|
regex = ["tishlang_core/regex"]
|
|
62
65
|
# WebSocket (JS-like API). Modular: import { WebSocket, Server } from 'tish:ws'.
|
|
63
66
|
# Requires `send-values` because the WS server keeps an `Arc<dyn Fn + Send +
|
|
@@ -90,6 +93,8 @@ libc = { version = "0.2", optional = true }
|
|
|
90
93
|
arc-swap = { version = "1", optional = true }
|
|
91
94
|
# Interactive terminal I/O (feature = tty): cross-platform raw mode, key/resize events, size.
|
|
92
95
|
crossterm = { version = "0.28", optional = true }
|
|
96
|
+
# Pseudoterminal hosting (feature = pty): cross-platform openpty + shell spawn (same crate Dune uses).
|
|
97
|
+
portable-pty = { version = "0.9", optional = true }
|
|
93
98
|
# hyper backend (feature = http-hyper)
|
|
94
99
|
hyper = { version = "1", default-features = false, features = ["server", "http1", "http2"], optional = true }
|
|
95
100
|
hyper-util = { version = "0.1", features = ["server", "tokio"], optional = true }
|
|
@@ -1727,6 +1727,11 @@ pub use tty::{
|
|
|
1727
1727
|
tty_enter_alt_screen, tty_is_tty, tty_leave_alt_screen, tty_read, tty_read_line, tty_set_raw_mode, tty_size,
|
|
1728
1728
|
};
|
|
1729
1729
|
|
|
1730
|
+
#[cfg(feature = "pty")]
|
|
1731
|
+
pub mod pty;
|
|
1732
|
+
#[cfg(feature = "pty")]
|
|
1733
|
+
pub use pty::{pty_kill, pty_pid, pty_read, pty_resize, pty_spawn, pty_write};
|
|
1734
|
+
|
|
1730
1735
|
#[cfg(feature = "ws")]
|
|
1731
1736
|
pub use ws::{
|
|
1732
1737
|
web_socket_client, web_socket_server_accept, web_socket_server_construct,
|
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
//! Pseudoterminal (PTY) module for Tish (`tish:pty`), behind the `pty` feature.
|
|
2
|
+
//!
|
|
3
|
+
//! Spawns a real OS pseudoterminal with a live shell/program attached — so an interactive
|
|
4
|
+
//! terminal emulator (xterm.js) on the other end of a socket behaves like a real TTY:
|
|
5
|
+
//! `isatty()` passes, so line-editing and curses apps (vim, top, ssh, tab-completion, and
|
|
6
|
+
//! `SIGWINCH` resize) all work. This is what `tish:process` (run-to-completion capture) and
|
|
7
|
+
//! `tish:tty` (this process's OWN controlling terminal) cannot do. Imported as
|
|
8
|
+
//! `import { spawn, read, write, resize, kill } from 'tish:pty'`.
|
|
9
|
+
//!
|
|
10
|
+
//! Polling model (transport-agnostic — pairs with a `tish:ws` pump or an HTTP long-poll):
|
|
11
|
+
//! - `spawn({ program?, cwd?, cols?, rows?, env? }) -> id | null`
|
|
12
|
+
//! - `read(id, timeoutMs?) -> string | null` (`""` = live but no output yet; `null` = EOF/unknown)
|
|
13
|
+
//! - `write(id, data) -> bool`
|
|
14
|
+
//! - `resize(id, cols, rows) -> bool`
|
|
15
|
+
//! - `kill(id) -> bool`
|
|
16
|
+
//! - `pid(id) -> number | null`
|
|
17
|
+
//!
|
|
18
|
+
//! A per-session reader thread fills a byte buffer that `read` drains at a UTF-8 boundary
|
|
19
|
+
//! (incomplete trailing multibyte sequences are held for the next read — no mojibake). A global
|
|
20
|
+
//! `Mutex<HashMap<id, PtySession>>` registry mirrors `ws.rs`'s `CONNS`. Errors surface as
|
|
21
|
+
//! `null`/`false` rather than panicking.
|
|
22
|
+
|
|
23
|
+
use std::collections::HashMap;
|
|
24
|
+
use std::io::{Read, Write};
|
|
25
|
+
use std::sync::atomic::{AtomicU64, Ordering};
|
|
26
|
+
use std::sync::{Arc, Condvar, Mutex};
|
|
27
|
+
use std::time::Duration;
|
|
28
|
+
|
|
29
|
+
use lazy_static::lazy_static;
|
|
30
|
+
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
|
|
31
|
+
use tishlang_core::Value;
|
|
32
|
+
|
|
33
|
+
/// Output accumulated by the reader thread, drained by `read`.
|
|
34
|
+
struct PtyBuf {
|
|
35
|
+
data: Vec<u8>,
|
|
36
|
+
eof: bool,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
struct PtySession {
|
|
40
|
+
child: Mutex<Box<dyn Child + Send + Sync>>,
|
|
41
|
+
master: Mutex<Box<dyn MasterPty + Send>>,
|
|
42
|
+
writer: Mutex<Box<dyn Write + Send>>,
|
|
43
|
+
buf: Arc<(Mutex<PtyBuf>, Condvar)>,
|
|
44
|
+
pid: Option<u32>,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
lazy_static! {
|
|
48
|
+
static ref SESSIONS: Mutex<HashMap<u64, PtySession>> = Mutex::new(HashMap::new());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
|
|
52
|
+
|
|
53
|
+
// --- Value helpers (mirror the ws.rs idioms) ---
|
|
54
|
+
|
|
55
|
+
fn arg_u64(args: &[Value], i: usize) -> Option<u64> {
|
|
56
|
+
match args.get(i) {
|
|
57
|
+
Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => Some(*n as u64),
|
|
58
|
+
_ => None,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
fn arg_u16(args: &[Value], i: usize) -> Option<u16> {
|
|
63
|
+
match args.get(i) {
|
|
64
|
+
Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => Some(*n as u16),
|
|
65
|
+
_ => None,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/// Clone a field out of an options object (`{ key: value }`), or `None` if the arg isn't an object.
|
|
70
|
+
fn obj_field(o: &Value, key: &str) -> Option<Value> {
|
|
71
|
+
if let Value::Object(m) = o {
|
|
72
|
+
return m.borrow().strings.get(key).cloned();
|
|
73
|
+
}
|
|
74
|
+
None
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn obj_str(o: &Value, key: &str) -> Option<String> {
|
|
78
|
+
match obj_field(o, key) {
|
|
79
|
+
Some(Value::String(s)) => Some(s.to_string()),
|
|
80
|
+
Some(Value::Null) | None => None,
|
|
81
|
+
Some(v) => Some(v.to_display_string()),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
fn obj_num(o: &Value, key: &str) -> Option<f64> {
|
|
86
|
+
match obj_field(o, key) {
|
|
87
|
+
Some(Value::Number(n)) if n.is_finite() => Some(n),
|
|
88
|
+
_ => None,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// `spawn({ program?, cwd?, cols?, rows?, env? })` → a stable id for read/write/resize/kill,
|
|
93
|
+
/// or `null` on failure.
|
|
94
|
+
pub fn pty_spawn(args: &[Value]) -> Value {
|
|
95
|
+
let null = Value::Null;
|
|
96
|
+
let opts = args.first().unwrap_or(&null);
|
|
97
|
+
|
|
98
|
+
let cols = obj_num(opts, "cols").filter(|n| *n > 0.0).unwrap_or(80.0) as u16;
|
|
99
|
+
let rows = obj_num(opts, "rows").filter(|n| *n > 0.0).unwrap_or(24.0) as u16;
|
|
100
|
+
|
|
101
|
+
let pty_system = native_pty_system();
|
|
102
|
+
let pair = match pty_system.openpty(PtySize {
|
|
103
|
+
rows,
|
|
104
|
+
cols,
|
|
105
|
+
pixel_width: 0,
|
|
106
|
+
pixel_height: 0,
|
|
107
|
+
}) {
|
|
108
|
+
Ok(p) => p,
|
|
109
|
+
Err(_) => return Value::Null,
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
let mut cmd = if let Some(prog) = obj_str(opts, "program") {
|
|
113
|
+
CommandBuilder::new(prog)
|
|
114
|
+
} else if cfg!(windows) {
|
|
115
|
+
CommandBuilder::new("powershell.exe")
|
|
116
|
+
} else {
|
|
117
|
+
let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
|
|
118
|
+
CommandBuilder::new(shell)
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
if let Some(cwd) = obj_str(opts, "cwd") {
|
|
122
|
+
cmd.cwd(cwd);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Caller-supplied env overrides inherit the parent environment (portable_pty default); we
|
|
126
|
+
// only add. Ensure TERM is set so curses apps negotiate correctly unless overridden.
|
|
127
|
+
let mut has_term = false;
|
|
128
|
+
if let Some(Value::Object(em)) = obj_field(opts, "env") {
|
|
129
|
+
for (k, v) in em.borrow().strings.iter() {
|
|
130
|
+
if k.as_ref() == "TERM" {
|
|
131
|
+
has_term = true;
|
|
132
|
+
}
|
|
133
|
+
cmd.env(k.as_ref(), v.to_display_string());
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if !has_term {
|
|
137
|
+
cmd.env("TERM", "xterm-256color");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let child = match pair.slave.spawn_command(cmd) {
|
|
141
|
+
Ok(c) => c,
|
|
142
|
+
Err(_) => return Value::Null,
|
|
143
|
+
};
|
|
144
|
+
let reader = match pair.master.try_clone_reader() {
|
|
145
|
+
Ok(r) => r,
|
|
146
|
+
Err(_) => return Value::Null,
|
|
147
|
+
};
|
|
148
|
+
let writer = match pair.master.take_writer() {
|
|
149
|
+
Ok(w) => w,
|
|
150
|
+
Err(_) => return Value::Null,
|
|
151
|
+
};
|
|
152
|
+
let pid = child.process_id();
|
|
153
|
+
|
|
154
|
+
let buf = Arc::new((
|
|
155
|
+
Mutex::new(PtyBuf {
|
|
156
|
+
data: Vec::new(),
|
|
157
|
+
eof: false,
|
|
158
|
+
}),
|
|
159
|
+
Condvar::new(),
|
|
160
|
+
));
|
|
161
|
+
|
|
162
|
+
// Reader thread: block on the master, append bytes, wake any waiting `read`. On EOF/error,
|
|
163
|
+
// mark eof so `read` can return null once the buffer drains.
|
|
164
|
+
{
|
|
165
|
+
let buf = buf.clone();
|
|
166
|
+
let mut reader = reader;
|
|
167
|
+
std::thread::spawn(move || {
|
|
168
|
+
let mut tmp = [0u8; 8192];
|
|
169
|
+
loop {
|
|
170
|
+
match reader.read(&mut tmp) {
|
|
171
|
+
Ok(0) => {
|
|
172
|
+
let (lock, cv) = &*buf;
|
|
173
|
+
if let Ok(mut b) = lock.lock() {
|
|
174
|
+
b.eof = true;
|
|
175
|
+
}
|
|
176
|
+
cv.notify_all();
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
Ok(n) => {
|
|
180
|
+
let (lock, cv) = &*buf;
|
|
181
|
+
if let Ok(mut b) = lock.lock() {
|
|
182
|
+
b.data.extend_from_slice(&tmp[..n]);
|
|
183
|
+
}
|
|
184
|
+
cv.notify_all();
|
|
185
|
+
}
|
|
186
|
+
Err(_) => {
|
|
187
|
+
let (lock, cv) = &*buf;
|
|
188
|
+
if let Ok(mut b) = lock.lock() {
|
|
189
|
+
b.eof = true;
|
|
190
|
+
}
|
|
191
|
+
cv.notify_all();
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let id = NEXT_ID.fetch_add(1, Ordering::SeqCst);
|
|
200
|
+
let session = PtySession {
|
|
201
|
+
child: Mutex::new(child),
|
|
202
|
+
master: Mutex::new(pair.master),
|
|
203
|
+
writer: Mutex::new(writer),
|
|
204
|
+
buf,
|
|
205
|
+
pid,
|
|
206
|
+
};
|
|
207
|
+
match SESSIONS.lock() {
|
|
208
|
+
Ok(mut g) => {
|
|
209
|
+
g.insert(id, session);
|
|
210
|
+
}
|
|
211
|
+
Err(_) => return Value::Null,
|
|
212
|
+
}
|
|
213
|
+
Value::Number(id as f64)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/// `read(id, timeoutMs?)` → available output as a string (possibly `""` if none arrived within the
|
|
217
|
+
/// timeout), or `null` at EOF / for an unknown id. Drains only a valid UTF-8 prefix, holding any
|
|
218
|
+
/// incomplete trailing multibyte bytes for the next call.
|
|
219
|
+
pub fn pty_read(args: &[Value]) -> Value {
|
|
220
|
+
let id = match arg_u64(args, 0) {
|
|
221
|
+
Some(x) => x,
|
|
222
|
+
None => return Value::Null,
|
|
223
|
+
};
|
|
224
|
+
let timeout_ms = match args.get(1) {
|
|
225
|
+
Some(Value::Number(n)) if n.is_finite() && *n >= 0.0 => *n as u64,
|
|
226
|
+
_ => 0,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
let buf = {
|
|
230
|
+
let g = match SESSIONS.lock() {
|
|
231
|
+
Ok(g) => g,
|
|
232
|
+
Err(_) => return Value::Null,
|
|
233
|
+
};
|
|
234
|
+
match g.get(&id) {
|
|
235
|
+
Some(s) => s.buf.clone(),
|
|
236
|
+
None => return Value::Null,
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
let (lock, cv) = &*buf;
|
|
241
|
+
let mut b = match lock.lock() {
|
|
242
|
+
Ok(b) => b,
|
|
243
|
+
Err(_) => return Value::Null,
|
|
244
|
+
};
|
|
245
|
+
if b.data.is_empty() && !b.eof && timeout_ms > 0 {
|
|
246
|
+
let res = cv.wait_timeout_while(b, Duration::from_millis(timeout_ms), |b| {
|
|
247
|
+
b.data.is_empty() && !b.eof
|
|
248
|
+
});
|
|
249
|
+
b = match res {
|
|
250
|
+
Ok((g, _)) => g,
|
|
251
|
+
Err(e) => e.into_inner().0,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if b.data.is_empty() {
|
|
256
|
+
if b.eof {
|
|
257
|
+
return Value::Null;
|
|
258
|
+
}
|
|
259
|
+
return Value::String("".into());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Drain up to the last complete UTF-8 code point; keep the incomplete tail for next time.
|
|
263
|
+
let valid = match std::str::from_utf8(&b.data) {
|
|
264
|
+
Ok(_) => b.data.len(),
|
|
265
|
+
Err(e) => e.valid_up_to(),
|
|
266
|
+
};
|
|
267
|
+
if valid == 0 {
|
|
268
|
+
// A leading incomplete multibyte sequence. If the stream is done, flush it lossily so
|
|
269
|
+
// nothing is stranded; otherwise wait for the rest.
|
|
270
|
+
if b.eof {
|
|
271
|
+
let s = String::from_utf8_lossy(&b.data).into_owned();
|
|
272
|
+
b.data.clear();
|
|
273
|
+
return Value::String(s.into());
|
|
274
|
+
}
|
|
275
|
+
return Value::String("".into());
|
|
276
|
+
}
|
|
277
|
+
let out: Vec<u8> = b.data.drain(..valid).collect();
|
|
278
|
+
let s = String::from_utf8(out).unwrap_or_default();
|
|
279
|
+
Value::String(s.into())
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/// `write(id, data)` → feed input bytes to the PTY. Returns whether the write succeeded.
|
|
283
|
+
pub fn pty_write(args: &[Value]) -> Value {
|
|
284
|
+
let id = match arg_u64(args, 0) {
|
|
285
|
+
Some(x) => x,
|
|
286
|
+
None => return Value::Bool(false),
|
|
287
|
+
};
|
|
288
|
+
let data = match args.get(1) {
|
|
289
|
+
Some(Value::String(s)) => s.to_string(),
|
|
290
|
+
Some(v) => v.to_display_string(),
|
|
291
|
+
None => return Value::Bool(false),
|
|
292
|
+
};
|
|
293
|
+
let g = match SESSIONS.lock() {
|
|
294
|
+
Ok(g) => g,
|
|
295
|
+
Err(_) => return Value::Bool(false),
|
|
296
|
+
};
|
|
297
|
+
let s = match g.get(&id) {
|
|
298
|
+
Some(s) => s,
|
|
299
|
+
None => return Value::Bool(false),
|
|
300
|
+
};
|
|
301
|
+
let mut w = match s.writer.lock() {
|
|
302
|
+
Ok(w) => w,
|
|
303
|
+
Err(_) => return Value::Bool(false),
|
|
304
|
+
};
|
|
305
|
+
match w.write_all(data.as_bytes()).and_then(|_| w.flush()) {
|
|
306
|
+
Ok(_) => Value::Bool(true),
|
|
307
|
+
Err(_) => Value::Bool(false),
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/// `resize(id, cols, rows)` → tell the PTY its new window size (fires `SIGWINCH` in the child).
|
|
312
|
+
pub fn pty_resize(args: &[Value]) -> Value {
|
|
313
|
+
let id = match arg_u64(args, 0) {
|
|
314
|
+
Some(x) => x,
|
|
315
|
+
None => return Value::Bool(false),
|
|
316
|
+
};
|
|
317
|
+
let cols = match arg_u16(args, 1) {
|
|
318
|
+
Some(c) if c > 0 => c,
|
|
319
|
+
_ => return Value::Bool(false),
|
|
320
|
+
};
|
|
321
|
+
let rows = match arg_u16(args, 2) {
|
|
322
|
+
Some(r) if r > 0 => r,
|
|
323
|
+
_ => return Value::Bool(false),
|
|
324
|
+
};
|
|
325
|
+
let g = match SESSIONS.lock() {
|
|
326
|
+
Ok(g) => g,
|
|
327
|
+
Err(_) => return Value::Bool(false),
|
|
328
|
+
};
|
|
329
|
+
let s = match g.get(&id) {
|
|
330
|
+
Some(s) => s,
|
|
331
|
+
None => return Value::Bool(false),
|
|
332
|
+
};
|
|
333
|
+
let m = match s.master.lock() {
|
|
334
|
+
Ok(m) => m,
|
|
335
|
+
Err(_) => return Value::Bool(false),
|
|
336
|
+
};
|
|
337
|
+
match m.resize(PtySize {
|
|
338
|
+
rows,
|
|
339
|
+
cols,
|
|
340
|
+
pixel_width: 0,
|
|
341
|
+
pixel_height: 0,
|
|
342
|
+
}) {
|
|
343
|
+
Ok(_) => Value::Bool(true),
|
|
344
|
+
Err(_) => Value::Bool(false),
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/// `kill(id)` → terminate the child and drop the session. Returns whether the id was live.
|
|
349
|
+
pub fn pty_kill(args: &[Value]) -> Value {
|
|
350
|
+
let id = match arg_u64(args, 0) {
|
|
351
|
+
Some(x) => x,
|
|
352
|
+
None => return Value::Bool(false),
|
|
353
|
+
};
|
|
354
|
+
let sess = {
|
|
355
|
+
let mut g = match SESSIONS.lock() {
|
|
356
|
+
Ok(g) => g,
|
|
357
|
+
Err(_) => return Value::Bool(false),
|
|
358
|
+
};
|
|
359
|
+
g.remove(&id)
|
|
360
|
+
};
|
|
361
|
+
match sess {
|
|
362
|
+
Some(s) => {
|
|
363
|
+
if let Ok(mut c) = s.child.lock() {
|
|
364
|
+
let _ = c.kill();
|
|
365
|
+
let _ = c.wait();
|
|
366
|
+
}
|
|
367
|
+
Value::Bool(true)
|
|
368
|
+
}
|
|
369
|
+
None => Value::Bool(false),
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/// `pid(id)` → the child process id, or `null` for an unknown id / no pid.
|
|
374
|
+
pub fn pty_pid(args: &[Value]) -> Value {
|
|
375
|
+
let id = match arg_u64(args, 0) {
|
|
376
|
+
Some(x) => x,
|
|
377
|
+
None => return Value::Null,
|
|
378
|
+
};
|
|
379
|
+
let g = match SESSIONS.lock() {
|
|
380
|
+
Ok(g) => g,
|
|
381
|
+
Err(_) => return Value::Null,
|
|
382
|
+
};
|
|
383
|
+
match g.get(&id).and_then(|s| s.pid) {
|
|
384
|
+
Some(p) => Value::Number(p as f64),
|
|
385
|
+
None => Value::Null,
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
#[cfg(all(test, unix))]
|
|
390
|
+
mod tests {
|
|
391
|
+
use super::*;
|
|
392
|
+
|
|
393
|
+
/// Proves a LIVE interactive PTY (persistent shell on a pseudoterminal), not run-to-completion:
|
|
394
|
+
/// spawn a shell, write a command, and read its executed output back.
|
|
395
|
+
#[test]
|
|
396
|
+
fn spawn_write_read_roundtrip() {
|
|
397
|
+
let id = match pty_spawn(&[]) {
|
|
398
|
+
Value::Number(n) => n,
|
|
399
|
+
other => panic!("spawn failed: {:?}", other),
|
|
400
|
+
};
|
|
401
|
+
// Drain the shell's startup banner/prompt.
|
|
402
|
+
let _ = pty_read(&[Value::Number(id), Value::Number(300.0)]);
|
|
403
|
+
assert!(matches!(
|
|
404
|
+
pty_write(&[Value::Number(id), Value::String("echo pty_ok_123\n".into())]),
|
|
405
|
+
Value::Bool(true)
|
|
406
|
+
));
|
|
407
|
+
let mut acc = String::new();
|
|
408
|
+
for _ in 0..100 {
|
|
409
|
+
match pty_read(&[Value::Number(id), Value::Number(50.0)]) {
|
|
410
|
+
Value::String(s) => acc.push_str(&s),
|
|
411
|
+
Value::Null => break,
|
|
412
|
+
_ => {}
|
|
413
|
+
}
|
|
414
|
+
if acc.contains("pty_ok_123") {
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
let _ = pty_kill(&[Value::Number(id)]);
|
|
419
|
+
assert!(acc.contains("pty_ok_123"), "pty output missing echo: {:?}", acc);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
#[test]
|
|
423
|
+
fn resize_live_session_ok() {
|
|
424
|
+
let id = match pty_spawn(&[]) {
|
|
425
|
+
Value::Number(n) => n,
|
|
426
|
+
other => panic!("spawn failed: {:?}", other),
|
|
427
|
+
};
|
|
428
|
+
assert!(matches!(
|
|
429
|
+
pty_resize(&[Value::Number(id), Value::Number(120.0), Value::Number(40.0)]),
|
|
430
|
+
Value::Bool(true)
|
|
431
|
+
));
|
|
432
|
+
assert!(matches!(pty_pid(&[Value::Number(id)]), Value::Number(_)));
|
|
433
|
+
assert!(matches!(pty_kill(&[Value::Number(id)]), Value::Bool(true)));
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
#[test]
|
|
437
|
+
fn unknown_id_surfaces_null_or_false() {
|
|
438
|
+
let bad = 9_999_999.0;
|
|
439
|
+
assert!(matches!(pty_read(&[Value::Number(bad)]), Value::Null));
|
|
440
|
+
assert!(matches!(
|
|
441
|
+
pty_write(&[Value::Number(bad), Value::String("x".into())]),
|
|
442
|
+
Value::Bool(false)
|
|
443
|
+
));
|
|
444
|
+
assert!(matches!(
|
|
445
|
+
pty_resize(&[Value::Number(bad), Value::Number(80.0), Value::Number(24.0)]),
|
|
446
|
+
Value::Bool(false)
|
|
447
|
+
));
|
|
448
|
+
assert!(matches!(pty_kill(&[Value::Number(bad)]), Value::Bool(false)));
|
|
449
|
+
assert!(matches!(pty_pid(&[Value::Number(bad)]), Value::Null));
|
|
450
|
+
}
|
|
451
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|