@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 CHANGED
Binary file
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang"
3
- version = "2.1.0"
3
+ version = "2.2.3"
4
4
  edition = "2021"
5
5
  description = "Tish CLI - run, REPL, compile to native"
6
6
  license-file = { workspace = true }
@@ -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
- let mut v: Vec<String> = tishlang_vm::all_compiled_capabilities()
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
- cli_features.to_vec()
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.fract() == 0.0 && n.abs() < 1e15 {
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
- self.child(callee, PREC_POSTFIX);
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
- let compact = children.len() == 1
1374
- && matches!(
1375
- &children[0],
1376
- JsxChild::Text(t) if !t.as_ref().contains('\n')
1377
- );
1378
- if compact {
1379
- self.buf.push('>');
1380
- if let JsxChild::Text(t) = &children[0] {
1381
- self.buf.push_str(t.as_ref());
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
- if children.is_empty() {
1410
- self.buf.push_str("</>");
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
- s.replace('\\', "\\\\")
1470
- .replace('`', "\\`")
1471
- .replace('$', "\\$")
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 jsx_multiline_when_mixed_children() {
1818
- let src = "let x = <div>a{b}</div>\n";
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
- out.contains('\n'),
1822
- "expected line breaks in formatted JSX: {out:?}"
1823
- );
1824
- let _ = tishlang_parser::parse(&out).unwrap();
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
- return resolve_relative_tish(from_dir, from_s, imported);
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) {