@tishlang/tish-format 3.3.3 → 3.6.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/tests/scope_merge.rs +90 -0
- package/crates/tish_compile/src/check.rs +178 -0
- package/crates/tish_compile/src/codegen.rs +383 -63
- package/crates/tish_compile/src/lib.rs +33 -1
- package/crates/tish_compile/src/resolve.rs +76 -7
- package/crates/tish_compile/src/types.rs +35 -1
- package/crates/tish_compile/tests/call_arity_641.rs +104 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- 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
|
|
@@ -108,3 +108,93 @@ fn scope_tish_crate_routes_native() {
|
|
|
108
108
|
"@scope package with tish.crate must NOT be merged as Tish source"
|
|
109
109
|
);
|
|
110
110
|
}
|
|
111
|
+
|
|
112
|
+
// ── #587: two modules exporting the SAME name, flattened into one scope ──────────────────────────
|
|
113
|
+
//
|
|
114
|
+
// `isolate_private_top_level_bindings` renamed colliding PRIVATE top-level names but skipped
|
|
115
|
+
// exported ones, on the reasoning that "exported names stay stable so imports keep resolving".
|
|
116
|
+
// `module_exports` is an indirection from export name to source symbol, so that was stricter than
|
|
117
|
+
// necessary — and leaving the collision produced a silently WRONG program rather than a slower one.
|
|
118
|
+
//
|
|
119
|
+
// The barrel pattern is where it bites: `import { greet as _greet }` + a local `export fn greet`
|
|
120
|
+
// that calls `_greet`. Flattened, that emits two `function greet` declarations, and because JS
|
|
121
|
+
// function declarations HOIST, the second overwrote the first BEFORE `const _greet = greet` ran.
|
|
122
|
+
// `_greet` then pointed at the wrapper and the program recursed until the stack blew.
|
|
123
|
+
//
|
|
124
|
+
// The entry module is excluded from renaming on purpose: its exports are the bundle's public
|
|
125
|
+
// surface, so moving those would change the API the caller sees. Only the dep is renamed here.
|
|
126
|
+
|
|
127
|
+
/// dep exports `greet`; barrel imports it aliased and re-exports its own `greet` wrapper.
|
|
128
|
+
fn setup_barrel() -> tempfile::TempDir {
|
|
129
|
+
let dir = tempfile::tempdir().unwrap();
|
|
130
|
+
let root = dir.path();
|
|
131
|
+
fs::create_dir_all(root.join("dep")).unwrap();
|
|
132
|
+
fs::create_dir_all(root.join("app")).unwrap();
|
|
133
|
+
fs::write(
|
|
134
|
+
root.join("dep/lib.tish"),
|
|
135
|
+
"export fn greet(name) { return \"hi \" + name }\n",
|
|
136
|
+
)
|
|
137
|
+
.unwrap();
|
|
138
|
+
fs::write(
|
|
139
|
+
root.join("app/barrel.tish"),
|
|
140
|
+
"import { greet as _greet } from \"../dep/lib.tish\"\n\
|
|
141
|
+
export fn greet(name) { return _greet(name) }\n",
|
|
142
|
+
)
|
|
143
|
+
.unwrap();
|
|
144
|
+
fs::write(
|
|
145
|
+
root.join("main.tish"),
|
|
146
|
+
"import { greet } from \"./app/barrel.tish\"\nconsole.log(greet(\"world\"))\n",
|
|
147
|
+
)
|
|
148
|
+
.unwrap();
|
|
149
|
+
dir
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#[test]
|
|
153
|
+
fn colliding_exported_names_are_isolated_across_modules() {
|
|
154
|
+
let dir = setup_barrel();
|
|
155
|
+
let root = dir.path();
|
|
156
|
+
let modules = resolve_project(&root.join("main.tish"), Some(root)).expect("resolve");
|
|
157
|
+
let merged = merge_modules(modules).expect("merge");
|
|
158
|
+
|
|
159
|
+
// The two `greet` declarations must not both be named `greet` in the flat program — that is the
|
|
160
|
+
// collision, and on the JS target hoisting silently resolves it the wrong way.
|
|
161
|
+
let greet_decls: Vec<&str> = merged
|
|
162
|
+
.program
|
|
163
|
+
.statements
|
|
164
|
+
.iter()
|
|
165
|
+
.filter_map(|s| match s {
|
|
166
|
+
Statement::FunDecl { name, .. } => Some(name.as_ref()),
|
|
167
|
+
_ => None,
|
|
168
|
+
})
|
|
169
|
+
.filter(|n| n.starts_with("greet"))
|
|
170
|
+
.collect();
|
|
171
|
+
assert!(
|
|
172
|
+
greet_decls.len() >= 2,
|
|
173
|
+
"expected both modules' greet fns in the merged program, got {:?}",
|
|
174
|
+
greet_decls
|
|
175
|
+
);
|
|
176
|
+
let bare = greet_decls.iter().filter(|n| **n == "greet").count();
|
|
177
|
+
assert!(
|
|
178
|
+
bare <= 1,
|
|
179
|
+
"two top-level `greet` declarations survived the merge — on the JS target the later one \
|
|
180
|
+
hoists over the former and the barrel's alias captures the wrapper (infinite recursion). \
|
|
181
|
+
Got {:?}",
|
|
182
|
+
greet_decls
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// The alias must bind to the DEP's symbol, not to the local wrapper.
|
|
186
|
+
let alias_target = merged.program.statements.iter().find_map(|s| match s {
|
|
187
|
+
Statement::VarDecl {
|
|
188
|
+
name,
|
|
189
|
+
init: Some(Expr::Ident { name: src, .. }),
|
|
190
|
+
..
|
|
191
|
+
} if name.as_ref() == "_greet" => Some(src.to_string()),
|
|
192
|
+
_ => None,
|
|
193
|
+
});
|
|
194
|
+
if let Some(target) = alias_target {
|
|
195
|
+
assert_ne!(
|
|
196
|
+
target, "greet",
|
|
197
|
+
"`_greet` must not bind to the ambiguous bare `greet`; it should name the dep's symbol"
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -773,3 +773,181 @@ mod tests {
|
|
|
773
773
|
assert_eq!(diags("fn f(a: number) {}\nf(someUnknown())"), Vec::<String>::new());
|
|
774
774
|
}
|
|
775
775
|
}
|
|
776
|
+
|
|
777
|
+
// ── #641: call arity ────────────────────────────────────────────────────────────────────────────
|
|
778
|
+
//
|
|
779
|
+
// tish did not check arity at all. A call with too few arguments compiled, the missing parameter
|
|
780
|
+
// became `Value::Null`, and what happened next depended on which lowering the CALLEE happened to
|
|
781
|
+
// get — a property of the callee, not of the call:
|
|
782
|
+
//
|
|
783
|
+
// boxed `Value::native` + typed params -> builds clean, PANICS at runtime the first time that
|
|
784
|
+
// path runs ("expected number", pointing at generated
|
|
785
|
+
// code, saying nothing about the call site)
|
|
786
|
+
// direct native `fn` + typed params -> rustc E0061 against generated code
|
|
787
|
+
// interpreter + typed params -> silently returns NaN / null
|
|
788
|
+
// untyped params -> `null` arrives; a deliberate, widely-used idiom
|
|
789
|
+
//
|
|
790
|
+
// So the same mistake is a build error in one function and a shipped crash in the next.
|
|
791
|
+
//
|
|
792
|
+
// THE RULE, and why it is exactly this narrow: an omitted argument bound to a TYPED parameter can
|
|
793
|
+
// never be correct, because `Value::Null` cannot satisfy a `Value::Number` prologue. That is a fact
|
|
794
|
+
// about the lowering, not a style preference, so it can be a hard error without a policy debate.
|
|
795
|
+
//
|
|
796
|
+
// An omitted argument bound to an UNTYPED parameter stays legal, because it is an established
|
|
797
|
+
// idiom in shipping code — `packages/shop.tish` has six such calls, `packages/ui.tish` one:
|
|
798
|
+
//
|
|
799
|
+
// function renderTab(defer, stream) { if (present(defer) === 1) { … } }
|
|
800
|
+
// renderTab() // deliberate: "render now, no defer, no stream"
|
|
801
|
+
//
|
|
802
|
+
// A parameter with a DEFAULT imposes no minimum either: the default is what fills it. Rest params
|
|
803
|
+
// impose none by definition.
|
|
804
|
+
|
|
805
|
+
/// The minimum argument count a call to `params` must supply: one past the LAST parameter that is
|
|
806
|
+
/// typed and has no default. A typed parameter at index 2 forces arity 3 even when 0 and 1 are
|
|
807
|
+
/// untyped, because the call cannot skip a position to reach it.
|
|
808
|
+
fn min_required_arity(params: &[FunParam]) -> usize {
|
|
809
|
+
let mut min = 0;
|
|
810
|
+
for (i, p) in params.iter().enumerate() {
|
|
811
|
+
if let FunParam::Simple(tp) = p {
|
|
812
|
+
if tp.type_ann.is_some() && tp.default.is_none() {
|
|
813
|
+
min = i + 1;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
min
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/// #641: report every call that supplies fewer arguments than a typed parameter requires. Runs
|
|
821
|
+
/// unconditionally in the compile path — unlike [`check_program`], which is opt-in behind
|
|
822
|
+
/// `TISH_CHECK` and would therefore never have caught the shipped case this exists for.
|
|
823
|
+
pub fn check_call_arity(program: &Program) -> Vec<TypeDiagnostic> {
|
|
824
|
+
use std::collections::HashMap;
|
|
825
|
+
|
|
826
|
+
// name -> (minimum arity, declared parameter count). Nested and top-level fns alike; a later
|
|
827
|
+
// declaration of the same name wins, matching the runtime's own last-wins binding.
|
|
828
|
+
let mut sigs: HashMap<String, (usize, usize)> = HashMap::new();
|
|
829
|
+
fn collect(stmts: &[Statement], sigs: &mut HashMap<String, (usize, usize)>) {
|
|
830
|
+
for s in stmts {
|
|
831
|
+
if let Statement::FunDecl { name, params, rest_param, body, .. } = s {
|
|
832
|
+
// A rest param swallows any surplus, but the leading typed params still bind
|
|
833
|
+
// positionally, so the minimum is unchanged.
|
|
834
|
+
let _ = rest_param;
|
|
835
|
+
sigs.insert(name.to_string(), (min_required_arity(params), params.len()));
|
|
836
|
+
collect(std::slice::from_ref(body.as_ref()), sigs);
|
|
837
|
+
} else {
|
|
838
|
+
for_each_child_block(s, &mut |b| collect(b, sigs));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
collect(&program.statements, &mut sigs);
|
|
843
|
+
|
|
844
|
+
let mut diags = Vec::new();
|
|
845
|
+
for s in &program.statements {
|
|
846
|
+
crate::codegen::Codegen::for_each_stmt_expr(s, &mut |root| {
|
|
847
|
+
walk_expr(root, &mut |e| {
|
|
848
|
+
let Expr::Call { callee, args, span } = e else { return };
|
|
849
|
+
let Expr::Ident { name, .. } = callee.as_ref() else { return };
|
|
850
|
+
let Some((min, declared)) = sigs.get(name.as_ref()).copied() else { return };
|
|
851
|
+
// A spread makes the count unknowable at compile time — say nothing rather than guess.
|
|
852
|
+
if args.iter().any(|a| matches!(a, CallArg::Spread(_))) {
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if args.len() < min {
|
|
856
|
+
diags.push(TypeDiagnostic {
|
|
857
|
+
message: format!(
|
|
858
|
+
"'{}' needs {} argument(s) but {} were supplied; parameter {} is typed, and \
|
|
859
|
+
an omitted argument arrives as null, which a typed parameter cannot accept. \
|
|
860
|
+
(An untyped parameter may be omitted — that idiom is unaffected.)",
|
|
861
|
+
name,
|
|
862
|
+
min,
|
|
863
|
+
args.len(),
|
|
864
|
+
min,
|
|
865
|
+
),
|
|
866
|
+
span: *span,
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
let _ = declared;
|
|
870
|
+
});
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
diags
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/// Visit `e` and every sub-expression. `for_each_stmt_expr` hands out only a statement's ROOT
|
|
877
|
+
/// expression, so without this the checker never sees `paintTyped(1)` inside `log("x" +
|
|
878
|
+
/// paintTyped(1))` — which is the shape the reported bug actually had.
|
|
879
|
+
///
|
|
880
|
+
/// The catch-all arm is deliberate: a variant not enumerated here yields a MISSED diagnostic, never
|
|
881
|
+
/// a wrong one, so a future AST addition degrades this check quietly instead of breaking builds.
|
|
882
|
+
fn walk_expr(e: &Expr, f: &mut dyn FnMut(&Expr)) {
|
|
883
|
+
f(e);
|
|
884
|
+
let mut go = |x: &Expr| walk_expr(x, f);
|
|
885
|
+
match e {
|
|
886
|
+
Expr::Binary { left, right, .. }
|
|
887
|
+
| Expr::NullishCoalesce { left, right, .. }
|
|
888
|
+
| Expr::Index { object: left, index: right, .. } => {
|
|
889
|
+
go(left);
|
|
890
|
+
go(right);
|
|
891
|
+
}
|
|
892
|
+
Expr::Unary { operand, .. } | Expr::TypeOf { operand, .. } | Expr::Await { operand, .. } => {
|
|
893
|
+
go(operand)
|
|
894
|
+
}
|
|
895
|
+
// The inc/dec forms name a variable rather than holding a sub-expression, so there is
|
|
896
|
+
// nothing to descend into.
|
|
897
|
+
Expr::Delete { target, .. } => go(target),
|
|
898
|
+
Expr::Member { object, .. } => go(object),
|
|
899
|
+
Expr::Call { callee, args, .. } | Expr::New { callee, args, .. } => {
|
|
900
|
+
go(callee);
|
|
901
|
+
for a in args {
|
|
902
|
+
match a {
|
|
903
|
+
CallArg::Expr(x) | CallArg::Spread(x) => go(x),
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
Expr::Conditional { cond, then_branch, else_branch, .. } => {
|
|
908
|
+
go(cond);
|
|
909
|
+
go(then_branch);
|
|
910
|
+
go(else_branch);
|
|
911
|
+
}
|
|
912
|
+
Expr::Array { elements, .. } => {
|
|
913
|
+
for el in elements {
|
|
914
|
+
match el {
|
|
915
|
+
tishlang_ast::ArrayElement::Expr(x)
|
|
916
|
+
| tishlang_ast::ArrayElement::Spread(x) => go(x),
|
|
917
|
+
_ => {}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
Expr::Object { props, .. } => {
|
|
922
|
+
for pr in props {
|
|
923
|
+
match pr {
|
|
924
|
+
tishlang_ast::ObjectProp::KeyValue(_, x, _) => go(x),
|
|
925
|
+
tishlang_ast::ObjectProp::Spread(x) => go(x),
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
Expr::Assign { value, .. }
|
|
930
|
+
| Expr::CompoundAssign { value, .. }
|
|
931
|
+
| Expr::LogicalAssign { value, .. } => go(value),
|
|
932
|
+
Expr::MemberAssign { object, value, .. } => {
|
|
933
|
+
go(object);
|
|
934
|
+
go(value);
|
|
935
|
+
}
|
|
936
|
+
Expr::IndexAssign { object, index, value, .. } => {
|
|
937
|
+
go(object);
|
|
938
|
+
go(index);
|
|
939
|
+
go(value);
|
|
940
|
+
}
|
|
941
|
+
Expr::TemplateLiteral { exprs, .. } => {
|
|
942
|
+
for x in exprs {
|
|
943
|
+
go(x);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
Expr::ArrowFunction { body, .. } => {
|
|
947
|
+
if let tishlang_ast::ArrowBody::Expr(x) = body {
|
|
948
|
+
go(x.as_ref());
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
_ => {}
|
|
952
|
+
}
|
|
953
|
+
}
|