@tishlang/tish 2.1.0 → 2.2.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 +0 -0
- package/crates/tish/Cargo.toml +1 -1
- package/crates/tish/src/main.rs +26 -8
- package/crates/tish/tests/fixtures/read_file_bytes.tish +10 -0
- package/crates/tish/tests/fs_read_file_bytes.rs +45 -0
- package/crates/tish_ast/src/ast.rs +5 -0
- package/crates/tish_compile/src/codegen.rs +23 -1
- package/crates/tish_eval/src/eval.rs +4 -0
- package/crates/tish_eval/src/natives.rs +16 -0
- package/crates/tish_fmt/src/lib.rs +156 -75
- package/crates/tish_lsp/src/import_goto.rs +25 -3
- package/crates/tish_lsp/src/main.rs +250 -5
- package/crates/tish_parser/src/parser.rs +20 -8
- package/crates/tish_resolve/src/lib.rs +775 -55
- package/crates/tish_runtime/src/lib.rs +17 -0
- package/crates/tish_vm/src/vm.rs +3 -0
- package/package.json +1 -1
- package/platform/darwin-arm64/tish +0 -0
- package/platform/darwin-x64/tish +0 -0
- package/platform/linux-arm64/tish +0 -0
- package/platform/linux-x64/tish +0 -0
- package/platform/win32-x64/tish.exe +0 -0
package/bin/tish
CHANGED
|
Binary file
|
package/crates/tish/Cargo.toml
CHANGED
package/crates/tish/src/main.rs
CHANGED
|
@@ -56,15 +56,17 @@ fn vm_capabilities_for_cli_run(cli_features: &[String]) -> HashSet<String> {
|
|
|
56
56
|
|
|
57
57
|
/// `--feature` list for `tish build --target native`: same default as `tish run` (all linked-in caps).
|
|
58
58
|
fn native_build_features_from_cli(cli_features: &[String]) -> Vec<String> {
|
|
59
|
-
if cli_features.is_empty() {
|
|
60
|
-
|
|
61
|
-
.into_iter()
|
|
62
|
-
.collect();
|
|
63
|
-
v.sort();
|
|
64
|
-
v
|
|
59
|
+
let mut v: Vec<String> = if cli_features.is_empty() {
|
|
60
|
+
tishlang_vm::all_compiled_capabilities().into_iter().collect()
|
|
65
61
|
} else {
|
|
66
|
-
|
|
67
|
-
|
|
62
|
+
// Normalize exactly like `tish run`: split comma-separated values (`--feature http,timers`)
|
|
63
|
+
// and expand `full`. Without this, a value like "http,timers,process" was treated as one
|
|
64
|
+
// opaque feature name, so `has_feature("http")` was false and the http runtime imports were
|
|
65
|
+
// never emitted — async `fetch` then failed to compile (issue #209).
|
|
66
|
+
normalize_capability_flags(cli_features).into_iter().collect()
|
|
67
|
+
};
|
|
68
|
+
v.sort();
|
|
69
|
+
v
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/// `tish script.tish` → insert `run` so it matches `tish run script.tish` (npx / npm UX).
|
|
@@ -829,6 +831,22 @@ mod cli_tests {
|
|
|
829
831
|
}
|
|
830
832
|
}
|
|
831
833
|
|
|
834
|
+
#[test]
|
|
835
|
+
fn native_build_features_split_commas_and_expand_full() {
|
|
836
|
+
use super::native_build_features_from_cli;
|
|
837
|
+
// Issue #209: a comma-joined value was treated as one opaque feature, so has_feature("http")
|
|
838
|
+
// was false and the native http imports were skipped (async `fetch` failed to compile).
|
|
839
|
+
let f = native_build_features_from_cli(&["http,timers,process".to_string()]);
|
|
840
|
+
for cap in ["http", "timers", "process"] {
|
|
841
|
+
assert!(f.iter().any(|s| s == cap), "missing `{cap}` in {f:?}");
|
|
842
|
+
}
|
|
843
|
+
// `full` expands to every capability, matching `tish run`.
|
|
844
|
+
let full = native_build_features_from_cli(&["full".to_string()]);
|
|
845
|
+
for cap in ["http", "timers", "fs", "process", "regex", "ws", "tty"] {
|
|
846
|
+
assert!(full.iter().any(|s| s == cap), "`full` missing `{cap}` in {full:?}");
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
832
850
|
#[test]
|
|
833
851
|
fn run_stdin_marker_parses_as_file() {
|
|
834
852
|
let cli = Cli::try_parse_from(["tish", "run", "-"]).unwrap();
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Fixture for issue #120: read a binary file as a byte array via tish:fs.readFileBytes.
|
|
2
|
+
// The test points RFB_FIXTURE at a file containing non-UTF-8 bytes (NUL, 0xFF, …).
|
|
3
|
+
import { readFileBytes } from "tish:fs"
|
|
4
|
+
import { process } from "tish:process"
|
|
5
|
+
|
|
6
|
+
let path = process.env.RFB_FIXTURE
|
|
7
|
+
let b = readFileBytes(path)
|
|
8
|
+
console.log("isArray:" + String(Array.isArray(b)))
|
|
9
|
+
console.log("len:" + String(b.length))
|
|
10
|
+
console.log("bytes:" + b.join(","))
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//! Issue #120: `tish:fs.readFileBytes` reads a binary file as an array of byte values (0–255)
|
|
2
|
+
//! on every backend, where the UTF-8-only `readFile` cannot. Verifies the exact bytes,
|
|
3
|
+
//! including a NUL and 0xFF (not valid UTF-8).
|
|
4
|
+
|
|
5
|
+
use std::path::PathBuf;
|
|
6
|
+
use std::process::Command;
|
|
7
|
+
|
|
8
|
+
const EXPECTED: &str = "\
|
|
9
|
+
isArray:true
|
|
10
|
+
len:7
|
|
11
|
+
bytes:0,1,2,255,128,72,73
|
|
12
|
+
";
|
|
13
|
+
|
|
14
|
+
fn run(backend: &str, fixture: &str, data_path: &str) -> String {
|
|
15
|
+
let tish = PathBuf::from(env!("CARGO_BIN_EXE_tish"));
|
|
16
|
+
let out = Command::new(&tish)
|
|
17
|
+
.args(["run", "--backend", backend, "--feature", "fs,process", fixture])
|
|
18
|
+
.env("RFB_FIXTURE", data_path)
|
|
19
|
+
.output()
|
|
20
|
+
.expect("spawn tish run");
|
|
21
|
+
assert!(
|
|
22
|
+
out.status.success(),
|
|
23
|
+
"tish run --backend {backend} failed: {}",
|
|
24
|
+
String::from_utf8_lossy(&out.stderr)
|
|
25
|
+
);
|
|
26
|
+
String::from_utf8_lossy(&out.stdout).into_owned()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
#[test]
|
|
30
|
+
fn read_file_bytes_reads_binary_on_every_backend() {
|
|
31
|
+
// A file whose bytes are not valid UTF-8 (NUL, 0xFF, 0x80) — readFile would error on it.
|
|
32
|
+
let data_path = std::env::temp_dir().join("tish_rfb_fixture.dat");
|
|
33
|
+
std::fs::write(&data_path, [0u8, 1, 2, 255, 128, 72, 73]).expect("write fixture");
|
|
34
|
+
let data_path = data_path.to_str().unwrap();
|
|
35
|
+
|
|
36
|
+
let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
37
|
+
.join("tests")
|
|
38
|
+
.join("fixtures")
|
|
39
|
+
.join("read_file_bytes.tish");
|
|
40
|
+
assert!(fixture.is_file(), "missing fixture {}", fixture.display());
|
|
41
|
+
let fixture = fixture.to_str().unwrap();
|
|
42
|
+
|
|
43
|
+
assert_eq!(run("vm", fixture, data_path), EXPECTED, "vm output mismatch");
|
|
44
|
+
assert_eq!(run("interp", fixture, data_path), EXPECTED, "interp output mismatch");
|
|
45
|
+
}
|
|
@@ -436,6 +436,11 @@ pub enum Expr {
|
|
|
436
436
|
/// JSX element: <Tag props>children</Tag>
|
|
437
437
|
JsxElement {
|
|
438
438
|
tag: Arc<str>,
|
|
439
|
+
/// Span of the tag name in the opening tag (`<`**`Foo`**`>`), for go-to-def / rename.
|
|
440
|
+
tag_span: Span,
|
|
441
|
+
/// Span of the tag name in the closing tag (`</`**`Foo`**`>`); `None` for self-closing
|
|
442
|
+
/// (`<Foo/>`). Renaming a component must update both, or `<Bar></Foo>` would be invalid.
|
|
443
|
+
close_tag_span: Option<Span>,
|
|
439
444
|
props: Vec<JsxProp>,
|
|
440
445
|
children: Vec<JsxChild>,
|
|
441
446
|
span: Span,
|
|
@@ -1094,6 +1094,7 @@ impl Codegen {
|
|
|
1094
1094
|
"fileExists" => Some("Value::native(|args: &[Value]| tish_file_exists(args))"),
|
|
1095
1095
|
"isDir" => Some("Value::native(|args: &[Value]| tish_is_dir(args))"),
|
|
1096
1096
|
"readDir" => Some("Value::native(|args: &[Value]| tish_read_dir(args))"),
|
|
1097
|
+
"readFileBytes" => Some("Value::native(|args: &[Value]| tish_read_file_bytes(args))"),
|
|
1097
1098
|
"mkdir" => Some("Value::native(|args: &[Value]| tish_mkdir(args))"),
|
|
1098
1099
|
_ => None,
|
|
1099
1100
|
},
|
|
@@ -1455,7 +1456,7 @@ impl Codegen {
|
|
|
1455
1456
|
}
|
|
1456
1457
|
}
|
|
1457
1458
|
if self.has_feature("fs") {
|
|
1458
|
-
self.write("use tishlang_runtime::{read_file as tish_read_file, write_file as tish_write_file, file_exists as tish_file_exists, is_dir as tish_is_dir, read_dir as tish_read_dir, mkdir as tish_mkdir};\n");
|
|
1459
|
+
self.write("use tishlang_runtime::{read_file as tish_read_file, read_file_bytes as tish_read_file_bytes, write_file as tish_write_file, file_exists as tish_file_exists, is_dir as tish_is_dir, read_dir as tish_read_dir, mkdir as tish_mkdir};\n");
|
|
1459
1460
|
}
|
|
1460
1461
|
if self.has_feature("ws") {
|
|
1461
1462
|
self.write("use tishlang_runtime::{web_socket_client as tish_ws_client, web_socket_server_construct as tish_ws_server_construct};\n");
|
|
@@ -2673,6 +2674,8 @@ impl Codegen {
|
|
|
2673
2674
|
"Set",
|
|
2674
2675
|
"Map",
|
|
2675
2676
|
"Object",
|
|
2677
|
+
"Array",
|
|
2678
|
+
"Number",
|
|
2676
2679
|
"Float64Array",
|
|
2677
2680
|
"Float32Array",
|
|
2678
2681
|
"Int8Array",
|
|
@@ -2713,6 +2716,25 @@ impl Codegen {
|
|
|
2713
2716
|
self.writeln(&format!("let {} = {}.clone();", builtin, builtin));
|
|
2714
2717
|
}
|
|
2715
2718
|
}
|
|
2719
|
+
// Feature-gated globals also move into the closure when referenced.
|
|
2720
|
+
// Clone them only when their capability is actually linked, so we
|
|
2721
|
+
// never emit `let h = h.clone();` for a binding that was never
|
|
2722
|
+
// emitted (e.g. a fn-local named `h` in a program without JSX).
|
|
2723
|
+
let mut gated: Vec<&str> = Vec::new();
|
|
2724
|
+
if self.has_feature("http") {
|
|
2725
|
+
gated.extend(["fetch", "fetchAll"]);
|
|
2726
|
+
}
|
|
2727
|
+
if self.has_feature("fs") {
|
|
2728
|
+
gated.extend(["readFile", "writeFile", "fileExists", "isDir", "readDir", "mkdir"]);
|
|
2729
|
+
}
|
|
2730
|
+
if self.program_has_jsx && !self.has_native_ui_host {
|
|
2731
|
+
gated.extend(["Fragment", "h", "text", "useState", "createRoot"]);
|
|
2732
|
+
}
|
|
2733
|
+
for name in gated {
|
|
2734
|
+
if referenced.contains(name) {
|
|
2735
|
+
self.writeln(&format!("let {} = {}.clone();", name, name));
|
|
2736
|
+
}
|
|
2737
|
+
}
|
|
2716
2738
|
self.writeln("Value::native(move |args: &[Value]| {");
|
|
2717
2739
|
self.value_fn_depth += 1;
|
|
2718
2740
|
self.indent += 1;
|
|
@@ -916,6 +916,10 @@ impl Evaluator {
|
|
|
916
916
|
exports.insert("fileExists".into(), Value::Native(natives::file_exists));
|
|
917
917
|
exports.insert("isDir".into(), Value::Native(natives::is_dir));
|
|
918
918
|
exports.insert("readDir".into(), Value::Native(natives::read_dir));
|
|
919
|
+
exports.insert(
|
|
920
|
+
"readFileBytes".into(),
|
|
921
|
+
Value::Native(natives::read_file_bytes),
|
|
922
|
+
);
|
|
919
923
|
exports.insert("mkdir".into(), Value::Native(natives::mkdir));
|
|
920
924
|
Ok(Value::object(exports))
|
|
921
925
|
}
|
|
@@ -420,6 +420,22 @@ pub fn read_file(args: &[Value]) -> Result<Value, String> {
|
|
|
420
420
|
}
|
|
421
421
|
}
|
|
422
422
|
|
|
423
|
+
/// Read a file as raw bytes (array of numbers 0–255) for binary data that `read_file`
|
|
424
|
+
/// (UTF-8 only) can't handle. See issue #120.
|
|
425
|
+
#[cfg(feature = "fs")]
|
|
426
|
+
pub fn read_file_bytes(args: &[Value]) -> Result<Value, String> {
|
|
427
|
+
use std::cell::RefCell;
|
|
428
|
+
use std::rc::Rc;
|
|
429
|
+
let path = args.first().map(|v| v.to_string()).unwrap_or_default();
|
|
430
|
+
match std::fs::read(&path) {
|
|
431
|
+
Ok(bytes) => {
|
|
432
|
+
let items: Vec<Value> = bytes.into_iter().map(|b| Value::Number(b as f64)).collect();
|
|
433
|
+
Ok(Value::Array(Rc::new(RefCell::new(items))))
|
|
434
|
+
}
|
|
435
|
+
Err(e) => Ok(Value::String(format!("Error: {}", e).into())),
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
423
439
|
#[cfg(feature = "fs")]
|
|
424
440
|
pub fn write_file(args: &[Value]) -> Result<Value, String> {
|
|
425
441
|
let path = args.first().map(|v| v.to_string()).unwrap_or_default();
|
|
@@ -1124,11 +1124,41 @@ impl Printer {
|
|
|
1124
1124
|
}
|
|
1125
1125
|
}
|
|
1126
1126
|
|
|
1127
|
+
/// Print JSX children inline and verbatim. JSX whitespace is significant in tish, so text is
|
|
1128
|
+
/// emitted exactly as written and there is no reflow/indentation. A nested element/fragment is
|
|
1129
|
+
/// printed bare (as a child element); any other expression child gets `{ }`.
|
|
1130
|
+
fn jsx_children(&mut self, children: &[JsxChild]) {
|
|
1131
|
+
for ch in children {
|
|
1132
|
+
match ch {
|
|
1133
|
+
JsxChild::Text(t) => self.buf.push_str(t.as_ref()),
|
|
1134
|
+
JsxChild::Expr(e) => {
|
|
1135
|
+
if matches!(e, Expr::JsxElement { .. } | Expr::JsxFragment { .. }) {
|
|
1136
|
+
self.expr(e);
|
|
1137
|
+
} else {
|
|
1138
|
+
self.buf.push('{');
|
|
1139
|
+
self.expr(e);
|
|
1140
|
+
self.buf.push('}');
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1127
1147
|
fn expr(&mut self, e: &Expr) {
|
|
1128
1148
|
match e {
|
|
1129
1149
|
Expr::Literal { value, .. } => match value {
|
|
1130
1150
|
Literal::Number(n) => {
|
|
1131
|
-
if n.
|
|
1151
|
+
if !n.is_finite() {
|
|
1152
|
+
// f64 overflow / NaN: emit the tish globals, not Rust's bare `inf`/`-inf`/`NaN`
|
|
1153
|
+
// (`inf` would re-parse as an undefined identifier).
|
|
1154
|
+
self.buf.push_str(if n.is_nan() {
|
|
1155
|
+
"NaN"
|
|
1156
|
+
} else if *n > 0.0 {
|
|
1157
|
+
"Infinity"
|
|
1158
|
+
} else {
|
|
1159
|
+
"-Infinity"
|
|
1160
|
+
});
|
|
1161
|
+
} else if n.fract() == 0.0 && n.abs() < 1e15 {
|
|
1132
1162
|
self.buf.push_str(&format!("{}", *n as i64));
|
|
1133
1163
|
} else {
|
|
1134
1164
|
self.buf.push_str(&format!("{}", n));
|
|
@@ -1169,7 +1199,18 @@ impl Printer {
|
|
|
1169
1199
|
}
|
|
1170
1200
|
Expr::New { callee, args, .. } => {
|
|
1171
1201
|
self.buf.push_str("new ");
|
|
1172
|
-
|
|
1202
|
+
// `new` parses its callee as a member expression WITHOUT a trailing call, so any call
|
|
1203
|
+
// in the callee's spine (`new (factory())()`, `new (factory().Cls)()`) must be
|
|
1204
|
+
// parenthesized — otherwise the printed `()` rebinds as the constructor's argument
|
|
1205
|
+
// list. child()'s precedence check can't catch this because a Call/Member already has
|
|
1206
|
+
// postfix precedence.
|
|
1207
|
+
if new_callee_has_call(callee) {
|
|
1208
|
+
self.buf.push('(');
|
|
1209
|
+
self.expr(callee);
|
|
1210
|
+
self.buf.push(')');
|
|
1211
|
+
} else {
|
|
1212
|
+
self.child(callee, PREC_POSTFIX);
|
|
1213
|
+
}
|
|
1173
1214
|
if !args.is_empty() {
|
|
1174
1215
|
self.emit_args(args);
|
|
1175
1216
|
}
|
|
@@ -1370,72 +1411,21 @@ impl Printer {
|
|
|
1370
1411
|
if children.is_empty() {
|
|
1371
1412
|
self.buf.push_str(" />");
|
|
1372
1413
|
} else {
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
}
|
|
1383
|
-
self.buf.push_str("</");
|
|
1384
|
-
self.buf.push_str(tag.as_ref());
|
|
1385
|
-
self.buf.push('>');
|
|
1386
|
-
} else {
|
|
1387
|
-
self.buf.push('>');
|
|
1388
|
-
self.buf.push('\n');
|
|
1389
|
-
for ch in children {
|
|
1390
|
-
self.buf.push_str(" ");
|
|
1391
|
-
match ch {
|
|
1392
|
-
JsxChild::Text(t) => self.buf.push_str(t.as_ref()),
|
|
1393
|
-
JsxChild::Expr(e) => {
|
|
1394
|
-
self.buf.push('{');
|
|
1395
|
-
self.expr(e);
|
|
1396
|
-
self.buf.push('}');
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
self.buf.push('\n');
|
|
1400
|
-
}
|
|
1401
|
-
self.buf.push_str(" </");
|
|
1402
|
-
self.buf.push_str(tag.as_ref());
|
|
1403
|
-
self.buf.push('>');
|
|
1404
|
-
}
|
|
1414
|
+
// JSX whitespace is significant in tish (the lexer keeps it verbatim and codegen
|
|
1415
|
+
// emits it as content), so children are printed inline and verbatim. Reflowing
|
|
1416
|
+
// them onto indented lines injected newlines/spaces as real text nodes, which
|
|
1417
|
+
// changed the rendered output and was non-idempotent.
|
|
1418
|
+
self.buf.push('>');
|
|
1419
|
+
self.jsx_children(children);
|
|
1420
|
+
self.buf.push_str("</");
|
|
1421
|
+
self.buf.push_str(tag.as_ref());
|
|
1422
|
+
self.buf.push('>');
|
|
1405
1423
|
}
|
|
1406
1424
|
}
|
|
1407
1425
|
Expr::JsxFragment { children, .. } => {
|
|
1408
1426
|
self.buf.push_str("<>");
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
} else {
|
|
1412
|
-
let compact = children.len() == 1
|
|
1413
|
-
&& matches!(
|
|
1414
|
-
&children[0],
|
|
1415
|
-
JsxChild::Text(t) if !t.as_ref().contains('\n')
|
|
1416
|
-
);
|
|
1417
|
-
if compact {
|
|
1418
|
-
if let JsxChild::Text(t) = &children[0] {
|
|
1419
|
-
self.buf.push_str(t.as_ref());
|
|
1420
|
-
}
|
|
1421
|
-
self.buf.push_str("</>");
|
|
1422
|
-
} else {
|
|
1423
|
-
self.buf.push('\n');
|
|
1424
|
-
for ch in children {
|
|
1425
|
-
self.buf.push_str(" ");
|
|
1426
|
-
match ch {
|
|
1427
|
-
JsxChild::Text(t) => self.buf.push_str(t.as_ref()),
|
|
1428
|
-
JsxChild::Expr(e) => {
|
|
1429
|
-
self.buf.push('{');
|
|
1430
|
-
self.expr(e);
|
|
1431
|
-
self.buf.push('}');
|
|
1432
|
-
}
|
|
1433
|
-
}
|
|
1434
|
-
self.buf.push('\n');
|
|
1435
|
-
}
|
|
1436
|
-
self.buf.push_str("</>");
|
|
1437
|
-
}
|
|
1438
|
-
}
|
|
1427
|
+
self.jsx_children(children);
|
|
1428
|
+
self.buf.push_str("</>");
|
|
1439
1429
|
}
|
|
1440
1430
|
Expr::NativeModuleLoad {
|
|
1441
1431
|
spec, export_name, ..
|
|
@@ -1466,9 +1456,19 @@ impl Printer {
|
|
|
1466
1456
|
}
|
|
1467
1457
|
|
|
1468
1458
|
fn escape_template(s: &str) -> String {
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1459
|
+
// In a template literal only backslash, backtick, and a `$` that begins an interpolation (`${`)
|
|
1460
|
+
// need escaping. A bare `$` is literal, so escaping every `$` just adds spurious backslashes.
|
|
1461
|
+
let mut out = String::with_capacity(s.len());
|
|
1462
|
+
let mut chars = s.chars().peekable();
|
|
1463
|
+
while let Some(c) = chars.next() {
|
|
1464
|
+
match c {
|
|
1465
|
+
'\\' => out.push_str("\\\\"),
|
|
1466
|
+
'`' => out.push_str("\\`"),
|
|
1467
|
+
'$' if chars.peek() == Some(&'{') => out.push_str("\\$"),
|
|
1468
|
+
_ => out.push(c),
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
out
|
|
1472
1472
|
}
|
|
1473
1473
|
|
|
1474
1474
|
// Expression precedence levels (higher binds tighter), mirroring the parser's
|
|
@@ -1528,16 +1528,29 @@ fn expr_prec(e: &Expr) -> u8 {
|
|
|
1528
1528
|
| Expr::PrefixInc { .. }
|
|
1529
1529
|
| Expr::PrefixDec { .. } => 14,
|
|
1530
1530
|
Expr::Call { .. }
|
|
1531
|
-
| Expr::New { .. }
|
|
1532
1531
|
| Expr::Member { .. }
|
|
1533
1532
|
| Expr::Index { .. }
|
|
1534
1533
|
| Expr::PostfixInc { .. }
|
|
1535
1534
|
| Expr::PostfixDec { .. } => PREC_POSTFIX,
|
|
1535
|
+
// `new X` binds looser than member/call, so a New used as the object of `.`/`[]`/`()`
|
|
1536
|
+
// must be parenthesized (e.g. `(new Foo()).bar()`, not `new Foo().bar()`).
|
|
1537
|
+
Expr::New { .. } => PREC_POSTFIX - 1,
|
|
1536
1538
|
Expr::ArrowFunction { .. } => PREC_ATOM,
|
|
1537
1539
|
_ => PREC_ATOM,
|
|
1538
1540
|
}
|
|
1539
1541
|
}
|
|
1540
1542
|
|
|
1543
|
+
/// True when a `new` callee contains a call in its member/index spine. Such a callee must be
|
|
1544
|
+
/// parenthesized, because `new` parses its callee without a trailing call and would otherwise bind
|
|
1545
|
+
/// the first `()` as the constructor's argument list (`new (f())()`, `new (f().C)()`).
|
|
1546
|
+
fn new_callee_has_call(e: &Expr) -> bool {
|
|
1547
|
+
match e {
|
|
1548
|
+
Expr::Call { .. } => true,
|
|
1549
|
+
Expr::Member { object, .. } | Expr::Index { object, .. } => new_callee_has_call(object),
|
|
1550
|
+
_ => false,
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1541
1554
|
fn binop(op: BinOp) -> &'static str {
|
|
1542
1555
|
match op {
|
|
1543
1556
|
BinOp::Add => "+",
|
|
@@ -1813,15 +1826,83 @@ mod tests {
|
|
|
1813
1826
|
let _ = tishlang_parser::parse(&out).unwrap();
|
|
1814
1827
|
}
|
|
1815
1828
|
|
|
1829
|
+
/// Debug-render the parsed AST with all `Span { … }` contents removed, so two programs compare
|
|
1830
|
+
/// equal iff they are structurally identical (reformatting legitimately changes spans).
|
|
1831
|
+
fn structure(src: &str) -> String {
|
|
1832
|
+
let dbg = format!("{:#?}", tishlang_parser::parse(src).unwrap().statements);
|
|
1833
|
+
let mut out = String::new();
|
|
1834
|
+
let mut rest = dbg.as_str();
|
|
1835
|
+
while let Some(i) = rest.find("Span {") {
|
|
1836
|
+
out.push_str(&rest[..i]);
|
|
1837
|
+
match rest[i..].find('}') {
|
|
1838
|
+
Some(j) => rest = &rest[i + j + 1..],
|
|
1839
|
+
None => {
|
|
1840
|
+
rest = "";
|
|
1841
|
+
break;
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
out.push_str(rest);
|
|
1846
|
+
out
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1849
|
+
#[test]
|
|
1850
|
+
fn new_expr_preserves_structure_and_is_idempotent() {
|
|
1851
|
+
// Each input must format to a program with the SAME structure (modulo spans), and a second
|
|
1852
|
+
// format pass must be a no-op. These previously corrupted into structurally different
|
|
1853
|
+
// programs (Call<->New<->Member swaps) on format.
|
|
1854
|
+
for src in [
|
|
1855
|
+
"const d = (new Foo()).bar()\n",
|
|
1856
|
+
"const b = new (factory().Cls)()\n",
|
|
1857
|
+
"const x = new (getClass())(1)\n",
|
|
1858
|
+
"const e = new Foo()\n",
|
|
1859
|
+
"const f = new a.b.c()\n",
|
|
1860
|
+
"const g = new Foo(1, 2).baz\n",
|
|
1861
|
+
] {
|
|
1862
|
+
let out = format_source(src).unwrap();
|
|
1863
|
+
let out2 = format_source(&out).unwrap();
|
|
1864
|
+
assert_eq!(structure(src), structure(&out), "structure changed: {src:?} -> {out:?}");
|
|
1865
|
+
assert_eq!(out, out2, "not idempotent: {src:?} -> {out:?} -> {out2:?}");
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
#[test]
|
|
1870
|
+
fn jsx_preserves_structure_and_is_idempotent() {
|
|
1871
|
+
// JSX whitespace is significant; formatting must not inject newlines/indentation as text
|
|
1872
|
+
// (which changed rendered output and was non-idempotent). Structure must be preserved.
|
|
1873
|
+
for src in [
|
|
1874
|
+
"let x = <div>a{b}</div>\n",
|
|
1875
|
+
"const e = <div>Hello {name}!</div>\n",
|
|
1876
|
+
"const e2 = <div><span>a</span><span>b</span></div>\n",
|
|
1877
|
+
"let y = <div> spaced text </div>\n",
|
|
1878
|
+
"let z = <>{a}{b}</>\n",
|
|
1879
|
+
"let s = <div a={1} b=\"x\">{c}</div>\n",
|
|
1880
|
+
] {
|
|
1881
|
+
let out = format_source(src).unwrap();
|
|
1882
|
+
let out2 = format_source(&out).unwrap();
|
|
1883
|
+
assert_eq!(structure(src), structure(&out), "JSX structure changed: {src:?} -> {out:?}");
|
|
1884
|
+
assert_eq!(out, out2, "JSX not idempotent: {src:?} -> {out:?} -> {out2:?}");
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1816
1888
|
#[test]
|
|
1817
|
-
fn
|
|
1818
|
-
|
|
1889
|
+
fn non_finite_number_literal_emits_valid_token() {
|
|
1890
|
+
// 1e400 overflows f64 to infinity; it must format to the `Infinity` global, not Rust's bare
|
|
1891
|
+
// `inf` (which would re-parse as an undefined identifier).
|
|
1892
|
+
let src = "let x = 1e400\n";
|
|
1819
1893
|
let out = format_source(src).unwrap();
|
|
1820
|
-
assert!(
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1894
|
+
assert!(out.contains("Infinity"), "expected Infinity, got {out:?}");
|
|
1895
|
+
tishlang_parser::parse(&out).expect("formatted output must parse");
|
|
1896
|
+
assert_eq!(format_source(&out).unwrap(), out, "not idempotent: {out:?}");
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
#[test]
|
|
1900
|
+
fn template_literal_escapes_only_interpolation_dollar() {
|
|
1901
|
+
let src = "let p = `cost: $5 and ${x} done`\n";
|
|
1902
|
+
let out = format_source(src).unwrap();
|
|
1903
|
+
assert!(out.contains("cost: $5"), "a bare $ must not be escaped: {out:?}");
|
|
1904
|
+
assert_eq!(structure(src), structure(&out), "structure changed: {src:?} -> {out:?}");
|
|
1905
|
+
assert_eq!(format_source(&out).unwrap(), out, "not idempotent: {out:?}");
|
|
1825
1906
|
}
|
|
1826
1907
|
|
|
1827
1908
|
#[test]
|
|
@@ -450,6 +450,21 @@ fn resolve_relative_tish(from_dir: &Path, from_s: &str, imported: &str) -> Optio
|
|
|
450
450
|
crate::find_export(&prog, imported, &u, &src)
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
/// Resolve a relative default import (`import X from "./m"`) to the `export default` in the source.
|
|
454
|
+
fn resolve_relative_tish_default(from_dir: &Path, from_s: &str) -> Option<Location> {
|
|
455
|
+
let target = from_dir.join(from_s.trim_start_matches("./"));
|
|
456
|
+
let target = if target.extension().is_none() {
|
|
457
|
+
target.with_extension("tish")
|
|
458
|
+
} else {
|
|
459
|
+
target
|
|
460
|
+
};
|
|
461
|
+
let can = target.canonicalize().ok()?;
|
|
462
|
+
let u = Url::from_file_path(&can).ok()?;
|
|
463
|
+
let src = std::fs::read_to_string(&can).ok()?;
|
|
464
|
+
let prog = tishlang_parser::parse(&src).ok()?;
|
|
465
|
+
crate::find_default_export(&prog, &u, &src)
|
|
466
|
+
}
|
|
467
|
+
|
|
453
468
|
/// After same-file [`tishlang_resolve::definition_span`] misses, resolve import sites (Tish files,
|
|
454
469
|
/// `node_modules` packages, and Rust `pub fn` for native / `cargo:` imports).
|
|
455
470
|
pub fn definition_for_import(
|
|
@@ -474,12 +489,13 @@ pub fn definition_for_import(
|
|
|
474
489
|
};
|
|
475
490
|
let from_s = from.as_ref();
|
|
476
491
|
for sp in specifiers {
|
|
477
|
-
let (imported, local) = match sp {
|
|
492
|
+
let (imported, local, is_default) = match sp {
|
|
478
493
|
ImportSpecifier::Named { name, alias, .. } => (
|
|
479
494
|
name.as_ref(),
|
|
480
495
|
alias.as_ref().map(|a| a.as_ref()).unwrap_or(name.as_ref()),
|
|
496
|
+
false,
|
|
481
497
|
),
|
|
482
|
-
ImportSpecifier::Default { name, .. } => (name.as_ref(), name.as_ref()),
|
|
498
|
+
ImportSpecifier::Default { name, .. } => (name.as_ref(), name.as_ref(), true),
|
|
483
499
|
ImportSpecifier::Namespace { .. } => continue,
|
|
484
500
|
};
|
|
485
501
|
if local != word {
|
|
@@ -487,7 +503,13 @@ pub fn definition_for_import(
|
|
|
487
503
|
}
|
|
488
504
|
|
|
489
505
|
if from_s.starts_with("./") || from_s.starts_with("../") {
|
|
490
|
-
|
|
506
|
+
// A default import binds the source module's `export default`, which has no name to
|
|
507
|
+
// match; resolve it directly. Named imports resolve by exported name.
|
|
508
|
+
return if is_default {
|
|
509
|
+
resolve_relative_tish_default(from_dir, from_s)
|
|
510
|
+
} else {
|
|
511
|
+
resolve_relative_tish(from_dir, from_s, imported)
|
|
512
|
+
};
|
|
491
513
|
}
|
|
492
514
|
|
|
493
515
|
if is_native_import(from_s) {
|