@tishlang/tish-format 2.36.2 → 2.37.3

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
@@ -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
@@ -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
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_fmt"
3
- version = "2.36.2"
3
+ version = "2.37.3"
4
4
  edition = "2021"
5
5
  description = "Opinionated formatter for Tish source (parse → pretty-print)"
6
6
  license-file = { workspace = true }
@@ -79,7 +79,7 @@ ws = [
79
79
  tishlang_core = { path = "../tish_core", version = ">=0.1" }
80
80
  tishlang_builtins = { path = "../tish_builtins", version = ">=0.1" }
81
81
  tokio = { version = "1.50.0", features = ["rt-multi-thread", "macros", "time", "sync"], optional = true }
82
- reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json", "stream"], optional = true }
82
+ reqwest = { version = "0.13.2", default-features = false, features = ["rustls", "json", "stream", "multipart"], optional = true }
83
83
  futures = { version = "0.3.32", optional = true }
84
84
  bytes = { version = "1", optional = true }
85
85
  tiny_http = { version = "0.12.0", optional = true }
@@ -111,6 +111,57 @@ pub(crate) fn extract_body(options: Option<&Value>) -> Option<String> {
111
111
  })
112
112
  }
113
113
 
114
+ /// A single `multipart/form-data` part extracted from a `fetch` options object's `multipart` array.
115
+ pub(crate) enum MultipartPart {
116
+ /// A text field: `{ name, value }`.
117
+ Text { name: String, value: String },
118
+ /// A file field read from disk (binary-safe): `{ name, path, filename?, contentType? }`.
119
+ File {
120
+ name: String,
121
+ path: String,
122
+ filename: Option<String>,
123
+ content_type: Option<String>,
124
+ },
125
+ }
126
+
127
+ /// Extract the `multipart` option — an array of part descriptors — from a `fetch` options object.
128
+ /// Each part is `{ name, value }` (text) or `{ name, path, filename?, contentType? }` (file, read as
129
+ /// bytes so binary uploads work). Returns `None` when there is no usable `multipart` array, so callers
130
+ /// fall back to `body`. A part missing `name`, or with neither `path` nor `value`, is skipped.
131
+ pub(crate) fn extract_multipart(options: Option<&Value>) -> Option<Vec<MultipartPart>> {
132
+ let field = options.and_then(|v| match v {
133
+ Value::Object(obj) => obj.borrow().strings.get("multipart").cloned(),
134
+ _ => None,
135
+ })?;
136
+ let items = match field {
137
+ Value::Array(a) => a.borrow().clone(),
138
+ _ => return None,
139
+ };
140
+ let mut parts = Vec::with_capacity(items.len());
141
+ for item in items {
142
+ let obj = match &item {
143
+ Value::Object(o) => o.borrow(),
144
+ _ => continue,
145
+ };
146
+ let name = match obj.strings.get("name") {
147
+ Some(v) => v.to_display_string(),
148
+ None => continue,
149
+ };
150
+ let filename = obj.strings.get("filename").map(|v| v.to_display_string());
151
+ let content_type = obj.strings.get("contentType").map(|v| v.to_display_string());
152
+ if let Some(path) = obj.strings.get("path").map(|v| v.to_display_string()) {
153
+ parts.push(MultipartPart::File { name, path, filename, content_type });
154
+ } else if let Some(value) = obj.strings.get("value").map(|v| v.to_display_string()) {
155
+ parts.push(MultipartPart::Text { name, value });
156
+ }
157
+ }
158
+ if parts.is_empty() {
159
+ None
160
+ } else {
161
+ Some(parts)
162
+ }
163
+ }
164
+
114
165
  pub(crate) fn build_error_response(error: &str) -> Value {
115
166
  let mut obj: ObjectMap = ObjectMap::with_capacity(2);
116
167
  obj.insert(Arc::from("error"), Value::String(error.into()));
@@ -10,7 +10,10 @@ use futures::Stream;
10
10
  use futures::StreamExt;
11
11
  use tishlang_core::{NativeFn, ObjectMap, TishOpaque, TishPromise, Value};
12
12
 
13
- use crate::http::{build_error_response, extract_body, extract_headers, extract_method};
13
+ use crate::http::{
14
+ build_error_response, extract_body, extract_headers, extract_method, extract_multipart,
15
+ MultipartPart,
16
+ };
14
17
 
15
18
  // --- Promises (Send payloads only; Value built on awaiting thread) ---
16
19
 
@@ -479,6 +482,7 @@ async fn send_request_parts(
479
482
  method: String,
480
483
  headers: Vec<(String, String)>,
481
484
  body: Option<String>,
485
+ multipart: Option<Vec<MultipartPart>>,
482
486
  ) -> Result<reqwest::Response, String> {
483
487
  if fetch_block_private() {
484
488
  ssrf_preflight(&url).await?;
@@ -496,7 +500,39 @@ async fn send_request_parts(
496
500
  for (key, value) in headers {
497
501
  req = req.header(key, value);
498
502
  }
499
- if let Some(body) = body {
503
+ // A `multipart` option takes precedence over `body` — it builds a multipart/form-data request
504
+ // (reqwest sets the Content-Type + boundary). File parts are read as bytes, so binary uploads work.
505
+ if let Some(parts) = multipart {
506
+ let mut form = reqwest::multipart::Form::new();
507
+ for part in parts {
508
+ match part {
509
+ MultipartPart::Text { name, value } => {
510
+ form = form.text(name, value);
511
+ }
512
+ MultipartPart::File {
513
+ name,
514
+ path,
515
+ filename,
516
+ content_type,
517
+ } => {
518
+ let bytes = std::fs::read(&path)
519
+ .map_err(|e| format!("multipart file '{path}': {e}"))?;
520
+ let fname = filename.unwrap_or_else(|| {
521
+ std::path::Path::new(&path)
522
+ .file_name()
523
+ .map(|s| s.to_string_lossy().into_owned())
524
+ .unwrap_or_else(|| name.clone())
525
+ });
526
+ let mut p = reqwest::multipart::Part::bytes(bytes).file_name(fname);
527
+ if let Some(ct) = content_type {
528
+ p = p.mime_str(&ct).map_err(|e| e.to_string())?;
529
+ }
530
+ form = form.part(name, p);
531
+ }
532
+ }
533
+ }
534
+ req = req.multipart(form);
535
+ } else if let Some(body) = body {
500
536
  req = req.body(body);
501
537
  }
502
538
  req.send().await.map_err(|e| e.to_string())
@@ -517,10 +553,11 @@ pub fn fetch_promise_from_args(args: Vec<Value>) -> Value {
517
553
  let method = extract_method(args.get(1));
518
554
  let headers = extract_headers(args.get(1));
519
555
  let body = extract_body(args.get(1));
556
+ let multipart = extract_multipart(args.get(1));
520
557
  let (tx, rx) = tokio::sync::oneshot::channel();
521
558
  crate::http::RUNTIME.with(|rt| {
522
559
  rt.spawn(async move {
523
- let r = send_request_parts(url, method, headers, body).await;
560
+ let r = send_request_parts(url, method, headers, body, multipart).await;
524
561
  let _ = tx.send(r);
525
562
  });
526
563
  });
@@ -541,7 +578,13 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
541
578
  }
542
579
  };
543
580
  #[allow(clippy::type_complexity)]
544
- let mut parts: Vec<(String, String, Vec<(String, String)>, Option<String>)> = Vec::new();
581
+ let mut parts: Vec<(
582
+ String,
583
+ String,
584
+ Vec<(String, String)>,
585
+ Option<String>,
586
+ Option<Vec<MultipartPart>>,
587
+ )> = Vec::new();
545
588
  for req in requests {
546
589
  let (url, opt) = match &req {
547
590
  Value::String(s) => (s.to_string(), None),
@@ -576,14 +619,15 @@ pub fn fetch_all_promise_from_args(args: Vec<Value>) -> Value {
576
619
  let method = extract_method(opt.as_ref());
577
620
  let headers = extract_headers(opt.as_ref());
578
621
  let body = extract_body(opt.as_ref());
579
- parts.push((url, method, headers, body));
622
+ let multipart = extract_multipart(opt.as_ref());
623
+ parts.push((url, method, headers, body, multipart));
580
624
  }
581
625
  let (tx, rx) = tokio::sync::oneshot::channel();
582
626
  crate::http::RUNTIME.with(|rt| {
583
627
  rt.spawn(async move {
584
628
  let futs: Vec<_> = parts
585
629
  .into_iter()
586
- .map(|(url, m, h, b)| send_request_parts(url, m, h, b))
630
+ .map(|(url, m, h, b, mp)| send_request_parts(url, m, h, b, mp))
587
631
  .collect();
588
632
  let results = futures::future::join_all(futs).await;
589
633
  let mapped: Vec<Result<reqwest::Response, String>> = results.into_iter().collect();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "2.36.2",
3
+ "version": "2.37.3",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file