@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 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 = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
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
- let mut nums = base_nums.clone();
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 = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
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(&_src, {:?})", k.as_ref());
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
- let inner_to_value = inner.to_value_expr("v");
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
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "2.37.0"
3
+ version = "2.38.0"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
@@ -17,6 +17,7 @@ const RUNTIME_CARGO_FEATURES: &[&str] = &[
17
17
  "regex",
18
18
  "ws",
19
19
  "tty",
20
+ "pty",
20
21
  ];
21
22
 
22
23
  /// Map CLI/compile features to flags passed to `tishlang_runtime` in the temp crate's Cargo.toml.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "2.37.0",
3
+ "version": "2.38.0",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file