@tishlang/tish-format 3.1.0 → 3.3.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.
@@ -1706,6 +1706,12 @@ impl Scanner {
1706
1706
  self.chars.get(self.i + 1).copied()
1707
1707
  }
1708
1708
 
1709
+ /// True when the char immediately before the cursor is `:`, i.e. the `//` about to
1710
+ /// be scanned is the scheme separator of a URL (`http://`) rather than a comment.
1711
+ fn prev_is_colon(&self) -> bool {
1712
+ self.i > 0 && self.chars.get(self.i - 1) == Some(&':')
1713
+ }
1714
+
1709
1715
  /// Consume one char, tracking line/col like the lexer (col resets after `\n`).
1710
1716
  fn bump(&mut self) -> Option<char> {
1711
1717
  let c = *self.chars.get(self.i)?;
@@ -1765,7 +1771,23 @@ impl Scanner {
1765
1771
  self.seen_nonws = true;
1766
1772
  self.scan_template();
1767
1773
  }
1768
- '/' if self.peek2() == Some('/') => self.scan_line_comment(),
1774
+ // `://` is a URL scheme separator, never a comment. This scanner has no JSX
1775
+ // awareness, so JSX TEXT — which, unlike a string or an attribute value, is bare
1776
+ // source — used to have its URLs read as comments:
1777
+ //
1778
+ // <div>Redirect URI: http://127.0.0.1/callback</div>
1779
+ //
1780
+ // scanned `//127.0.0.1/callback</div>` as a line comment, and the printer then
1781
+ // re-emitted it after the element. Worse, the re-emitted text is a REAL comment, so
1782
+ // every subsequent run found one more and the file grew without converging — and
1783
+ // `--check` could never pass on such a file. See tishlang/tish#578.
1784
+ //
1785
+ // Guarding on the preceding `:` fixes the whole class (http://, file://, dune://)
1786
+ // without teaching this scanner to parse JSX, which would mean distinguishing
1787
+ // `<div>` from `a < b` and risks breaking comment scanning in ordinary code.
1788
+ '/' if self.peek2() == Some('/') && !self.prev_is_colon() => {
1789
+ self.scan_line_comment()
1790
+ }
1769
1791
  '/' if self.peek2() == Some('*') => self.scan_block_comment(),
1770
1792
  ' ' | '\t' | '\r' | '\n' => {
1771
1793
  self.bump();
@@ -1884,6 +1906,37 @@ mod tests {
1884
1906
  let _ = tishlang_parser::parse(&out).unwrap();
1885
1907
  }
1886
1908
 
1909
+ /// tishlang/tish#578 — a URL in JSX TEXT had its `//` scanned as a line comment, so the
1910
+ /// printer re-emitted everything after it as a trailing comment. The re-emitted text was
1911
+ /// itself a real comment, so each run appended another copy and the file never converged.
1912
+ #[test]
1913
+ fn url_in_jsx_text_is_not_a_comment() {
1914
+ let src = "/** @jsxImportSource lattish */\n\nexport fn Demo() {\n return <div>Redirect URI: http://127.0.0.1/callback for details</div>\n}\n";
1915
+ let once = format_source(src).unwrap();
1916
+ // Not `!contains("//127.0.0.1")` — that substring is part of the legitimate URL. The
1917
+ // corruption signature is the text appearing TWICE: once in the JSX, once hoisted out.
1918
+ assert_eq!(
1919
+ once.matches("127.0.0.1").count(),
1920
+ 1,
1921
+ "URL text was duplicated into a comment:\n{once}"
1922
+ );
1923
+ // Idempotence is the property that actually broke: formatting must reach a fixed point,
1924
+ // otherwise `--check` can never pass no matter how many times the file is formatted.
1925
+ let twice = format_source(&once).unwrap();
1926
+ assert_eq!(once, twice, "formatting is not idempotent");
1927
+ let thrice = format_source(&twice).unwrap();
1928
+ assert_eq!(twice, thrice, "formatting is not idempotent");
1929
+ }
1930
+
1931
+ /// The guard keys off the `:` immediately before `//`, so ordinary trailing comments —
1932
+ /// including ones right after a colon-bearing construct — must still be recognised.
1933
+ #[test]
1934
+ fn ordinary_comments_still_scanned() {
1935
+ let src = "fn f() {\n let o = { a: 1 } // trailing\n return o\n}\n";
1936
+ let out = format_source(src).unwrap();
1937
+ assert!(out.contains("// trailing"), "comment was dropped:\n{out}");
1938
+ }
1939
+
1887
1940
  /// Debug-render the parsed AST with all `Span { … }` contents removed, so two programs compare
1888
1941
  /// equal iff they are structurally identical (reformatting legitimately changes spans).
1889
1942
  fn structure(src: &str) -> String {
@@ -1,7 +1,7 @@
1
1
  //! Build native binary via cargo (interim path until Cranelift backend is ready).
2
2
 
3
3
  use std::fs;
4
- use std::path::Path;
4
+ use std::path::{Path, PathBuf};
5
5
 
6
6
  use tishlang_compile::ResolvedNativeModule;
7
7
 
@@ -149,6 +149,17 @@ pub fn build_via_cargo_with_config(
149
149
  project_root,
150
150
  );
151
151
  }
152
+ if build_config.artifact == NativeArtifact::RustLib {
153
+ return emit_rust_lib_crate(
154
+ rust_code,
155
+ native_modules,
156
+ output_path,
157
+ features,
158
+ extra_dependencies_toml,
159
+ generated_native_rs,
160
+ project_root,
161
+ );
162
+ }
152
163
 
153
164
  let out_stem = output_path
154
165
  .file_stem()
@@ -348,6 +359,122 @@ fn read_facade_agb_version(facade_path: &Path) -> Option<String> {
348
359
  /// Runs its own `cargo build` (NOT `run_cargo_build`) so the `.cargo/config.toml`
349
360
  /// rustflags (`-Tgba.ld`, `-Ctarget-cpu=arm7tdmi`) apply — `run_cargo_build` sets a
350
361
  /// `RUSTFLAGS` env that would shadow them.
362
+ /// Read `version = "…"` from the `[package]` section of a crate's `Cargo.toml`.
363
+ fn read_crate_version(crate_dir: &Path) -> Option<String> {
364
+ let toml = fs::read_to_string(crate_dir.join("Cargo.toml")).ok()?;
365
+ for line in toml.lines() {
366
+ let line = line.trim();
367
+ if let Some(rest) = line.strip_prefix("version") {
368
+ let rest = rest.trim_start();
369
+ if let Some(rest) = rest.strip_prefix('=') {
370
+ return Some(rest.trim().trim_matches('"').to_string());
371
+ }
372
+ }
373
+ // Only the first (package) table — stop before dependency versions.
374
+ if line.starts_with('[') && !line.starts_with("[package]") {
375
+ break;
376
+ }
377
+ }
378
+ None
379
+ }
380
+
381
+ /// `--target rust --emit lib`: write a Cargo **library source crate** to `output_path` and stop.
382
+ ///
383
+ /// This is the one artifact that does not run cargo. The consumer decides what to build it for and
384
+ /// how to depend on it, exactly as they would with any other crate — which is the point: it lets a
385
+ /// Tish library be consumed from Rust the way `--target js` lets it be consumed from JS.
386
+ ///
387
+ /// The `tishlang_runtime` dependency carries **both** `path` and `version`. Cargo prefers the path
388
+ /// locally and falls back to the registry version when the crate is published, so the emitted crate
389
+ /// is publishable without a post-processing step.
390
+ fn emit_rust_lib_crate(
391
+ rust_code: &str,
392
+ native_modules: Vec<ResolvedNativeModule>,
393
+ output_path: &Path,
394
+ features: &[String],
395
+ extra_dependencies_toml: &str,
396
+ generated_native_rs: Option<&str>,
397
+ project_root: Option<&Path>,
398
+ ) -> Result<(), String> {
399
+ let crate_name = tishlang_build_utils::cargo_target_name(
400
+ output_path
401
+ .file_name()
402
+ .and_then(|s| s.to_str())
403
+ .unwrap_or("tish_lib"),
404
+ );
405
+ let runtime_path = tishlang_build_utils::find_runtime_path_for_project(project_root)?;
406
+ let runtime_version = read_crate_version(Path::new(&runtime_path))
407
+ .map(|v| format!(", version = {:?}", v))
408
+ .unwrap_or_default();
409
+
410
+ let runtime_features = runtime_features_for_cargo(features);
411
+ let runtime_refs: Vec<&str> = runtime_features.iter().map(String::as_str).collect();
412
+ let features_str = if runtime_refs.is_empty() {
413
+ String::new()
414
+ } else {
415
+ format!(", features = {:?}", runtime_refs)
416
+ };
417
+
418
+ let native_deps: String = native_modules
419
+ .iter()
420
+ .filter(|m| m.use_path_dependency)
421
+ .map(|m| {
422
+ let path = m.crate_path.display().to_string().replace('\\', "/");
423
+ format!("{} = {{ path = {:?} }}\n", m.package_name, path)
424
+ })
425
+ .collect();
426
+
427
+ let mut more_deps = String::new();
428
+ if !native_deps.is_empty() {
429
+ more_deps.push_str(&format!("\n{}", native_deps));
430
+ }
431
+ if !extra_dependencies_toml.trim().is_empty() {
432
+ more_deps.push_str(&format!("\n{}", extra_dependencies_toml));
433
+ }
434
+
435
+ let lib_rs = if generated_native_rs.is_some() {
436
+ inject_generated_native_mod(rust_code)
437
+ } else {
438
+ rust_code.to_string()
439
+ };
440
+
441
+ let cargo_toml = format!(
442
+ r#"[package]
443
+ name = "{name}"
444
+ version = "0.1.0"
445
+ edition = "2021"
446
+
447
+ [lib]
448
+ name = "{name}"
449
+ crate-type = ["rlib"]
450
+ path = "src/lib.rs"
451
+
452
+ [dependencies]
453
+ tishlang_runtime = {{ path = {runtime:?}{version}{features} }}
454
+ {more}"#,
455
+ name = crate_name,
456
+ runtime = runtime_path,
457
+ version = runtime_version,
458
+ features = features_str,
459
+ more = more_deps,
460
+ );
461
+
462
+ let src_dir = output_path.join("src");
463
+ fs::create_dir_all(&src_dir)
464
+ .map_err(|e| format!("Cannot create {}: {}", src_dir.display(), e))?;
465
+ fs::write(output_path.join("Cargo.toml"), cargo_toml)
466
+ .map_err(|e| format!("Cannot write Cargo.toml: {}", e))?;
467
+ if let Some(gen) = generated_native_rs {
468
+ fs::write(src_dir.join("generated_native.rs"), gen)
469
+ .map_err(|e| format!("Cannot write generated_native.rs: {}", e))?;
470
+ }
471
+ fs::write(src_dir.join("lib.rs"), lib_rs)
472
+ .map_err(|e| format!("Cannot write lib.rs: {}", e))?;
473
+
474
+ println!("Emitted Rust library crate: {}", output_path.display());
475
+ Ok(())
476
+ }
477
+
351
478
  fn build_gba_rom(
352
479
  rust_code: &str,
353
480
  native_modules: Vec<ResolvedNativeModule>,
@@ -500,15 +627,34 @@ runner = ["mgba-qt", "-C", "logToStdout=1", "-C", "logLevel.gba.debug=127"]
500
627
  // with `-Tgba.ld` — cargo *joins* rustflags arrays across config files, so a
501
628
  // config-file approach would pass `-Tgba.ld` twice ("region already defined").
502
629
  // build-std still comes from the generated `[unstable]` config (not rustflags).
503
- let status = Command::new("cargo")
630
+ //
631
+ // TISH_GBA_TARGET_DIR (opt-in, explicit — deliberately NOT the ambient
632
+ // CARGO_TARGET_DIR, which the desktop path scrubs for good reason): share ONE
633
+ // cargo target dir across every example's ROM build. Each example otherwise
634
+ // carries a full private dependency tree — measured at ~45 GB across the
635
+ // tish-gba fleet, and it filled the disk mid-build once. An inherited
636
+ // CARGO_TARGET_DIR meanwhile made cargo write where the ELF lookup below
637
+ // never looked ("GBA ELF not found" on a SUCCESSFUL compile), so that env is
638
+ // scrubbed here and only the explicit opt-in steers both paths together.
639
+ let shared_target = std::env::var_os("TISH_GBA_TARGET_DIR").map(PathBuf::from);
640
+ let target_root = shared_target
641
+ .clone()
642
+ .unwrap_or_else(|| build_dir.join("target"));
643
+ let mut cargo = Command::new("cargo");
644
+ cargo
504
645
  .arg("build")
505
646
  .arg("--release")
506
647
  .current_dir(&build_dir)
648
+ .env_remove("CARGO_TARGET_DIR")
507
649
  .env(
508
650
  "RUSTFLAGS",
509
651
  "-Clink-arg=-Tgba.ld -Ctarget-cpu=arm7tdmi -Cforce-frame-pointers=yes",
510
652
  )
511
- .env("CARGO_TERM_COLOR", "always")
653
+ .env("CARGO_TERM_COLOR", "always");
654
+ if let Some(dir) = &shared_target {
655
+ cargo.env("CARGO_TARGET_DIR", dir);
656
+ }
657
+ let status = cargo
512
658
  .status()
513
659
  .map_err(|e| format!("Failed to run cargo for GBA build: {}", e))?;
514
660
  if !status.success() {
@@ -518,9 +664,8 @@ runner = ["mgba-qt", "-C", "logToStdout=1", "-C", "logLevel.gba.debug=127"]
518
664
  ));
519
665
  }
520
666
 
521
- // ELF → .gba via agb-gbafix.
522
- let elf = build_dir
523
- .join("target")
667
+ // ELF → .gba via agb-gbafix. Resolved from the SAME root the build wrote to.
668
+ let elf = target_root
524
669
  .join("thumbv4t-none-eabi")
525
670
  .join("release")
526
671
  .join(&cargo_name);
@@ -11,6 +11,10 @@ pub enum NativeArtifact {
11
11
  /// A GBA ROM: cargo builds an ELF for `thumbv4t-none-eabi`, then `agb-gbafix`
12
12
  /// converts it to a `.gba`.
13
13
  GbaRom,
14
+ /// A Rust *library source crate* (`crate-type = ["rlib"]`) whose public API is the entry
15
+ /// module's `export fn`s. Unlike every other artifact, cargo is **not** run — the emitted
16
+ /// crate directory is itself the output, for the consumer to add as a path/registry dependency.
17
+ RustLib,
14
18
  }
15
19
 
16
20
  /// Options passed from the CLI into nested `cargo build`.
@@ -45,6 +49,16 @@ impl NativeBuildConfig {
45
49
  }
46
50
  }
47
51
 
52
+ /// `tish build --target rust --emit lib`: emit a Cargo library crate and stop. Host target —
53
+ /// the consumer decides what to build it for.
54
+ pub fn rust_lib() -> Self {
55
+ Self {
56
+ artifact: NativeArtifact::RustLib,
57
+ cargo_target: None,
58
+ emit_mode: NativeEmitMode::RustLib,
59
+ }
60
+ }
61
+
48
62
  pub fn is_cross_compile(&self) -> bool {
49
63
  self.cargo_target.is_some()
50
64
  }
@@ -101,21 +101,41 @@ type BgAsset = (
101
101
  &'static agb::display::tile_data::TileData,
102
102
  );
103
103
 
104
- static ASSET_BGS: SingleCore<RefCell<Vec<BgAsset>>> = SingleCore::new(RefCell::new(Vec::new()));
104
+ /// ⚠️ A FIXED TABLE, not a `Vec`, because every entry is `&'static` cartridge data and the registry
105
+ /// never needed to own one.
106
+ ///
107
+ /// As a `Vec` it did: registering N backgrounds ran N pushes with log2(N) reallocs, all during
108
+ /// module init before the program body, and the freed buffers left the heap too fragmented for the
109
+ /// one big contiguous block a GBA UI canvas wants. A game with a handful of backgrounds booted; a
110
+ /// game with 162 died in the allocator with plenty of total heap free.
111
+ ///
112
+ /// This costs `MAX_BGS * size_of::<Option<BgAsset>>()` of STATIC memory and allocates nothing.
113
+ /// ⚠️ Not a `Vec` with `reserve` — that charges every small game for the largest one.
114
+ const MAX_BGS: usize = 256;
115
+ static ASSET_BGS: SingleCore<RefCell<[Option<BgAsset>; MAX_BGS]>> =
116
+ SingleCore::new(RefCell::new([None; MAX_BGS]));
117
+ static ASSET_BGS_N: SingleCore<RefCell<usize>> = SingleCore::new(RefCell::new(0));
105
118
 
106
119
  /// Register a background (palettes + tile data), returning its i32 handle.
107
120
  pub fn __asset_register_bg(bg: BgAsset) -> i32 {
108
- ASSET_BGS.with(|c| {
109
- let mut v = c.borrow_mut();
110
- let idx = v.len() as i32;
111
- v.push(bg);
121
+ ASSET_BGS_N.with(|n| {
122
+ let mut n = n.borrow_mut();
123
+ if *n >= MAX_BGS {
124
+ return -1;
125
+ }
126
+ let idx = *n as i32;
127
+ ASSET_BGS.with(|c| c.borrow_mut()[*n] = Some(bg));
128
+ *n += 1;
112
129
  idx
113
130
  })
114
131
  }
115
132
 
116
133
  /// Look up a registered background by handle. `None` if out of range.
117
134
  pub fn asset_bg(handle: i32) -> Option<BgAsset> {
118
- ASSET_BGS.with(|c| c.borrow().get(handle as usize).copied())
135
+ if handle < 0 || handle as usize >= MAX_BGS {
136
+ return None;
137
+ }
138
+ ASSET_BGS.with(|c| c.borrow()[handle as usize])
119
139
  }
120
140
 
121
141
  /// Registered fonts (`font<N>:` imports) — a compile-time-baked `Font`. All `font<N>:` schemes share
@@ -266,6 +266,52 @@ pub fn get_index(obj: &Value, index: &Value) -> Value {
266
266
  }
267
267
  }
268
268
 
269
+ /// `&str` `s[i]` — char at a non-negative integer `idx`; non-int / negative / OOB → null.
270
+ ///
271
+ /// Mirrors `tish_runtime::str_index`, and exists here because codegen emits a call to it whenever
272
+ /// the indexed expression is statically a `&str` rather than a `Value` — which is what a module
273
+ /// `const` string lowers to. Without it, `const D = "0123456789"` followed by `D[i]` is a hard
274
+ /// E0425 on the GBA target only, while the identical code compiles and runs everywhere else.
275
+ #[inline]
276
+ pub fn str_index(s: &str, idx: &Value) -> Value {
277
+ match idx {
278
+ Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => match s.chars().nth(*n as usize) {
279
+ Some(c) => Value::String(c.to_string().into()),
280
+ None => Value::Null,
281
+ },
282
+ _ => Value::Null,
283
+ }
284
+ }
285
+
286
+ /// `&str` charCodeAt — the code unit at `idx`, NaN when out of range. Mirrors
287
+ /// `tish_runtime::str_char_code_at`, and missing here for the same reason `str_index` was: codegen
288
+ /// emits it whenever the receiver is statically a `&str`, so `"abc".charCodeAt(i)` was a hard E0425
289
+ /// on the GBA target only while compiling everywhere else. Found by a ROM that hashes its own trace.
290
+ #[inline]
291
+ pub fn str_char_code_at(s: &str, idx: &Value) -> Value {
292
+ let i = match idx {
293
+ Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => *n as usize,
294
+ _ => return Value::Number(f64::NAN),
295
+ };
296
+ match s.chars().nth(i) {
297
+ Some(c) => Value::Number(c as u32 as f64),
298
+ None => Value::Number(f64::NAN),
299
+ }
300
+ }
301
+
302
+ /// `&str` charAt — the 1-character string at `idx`, `""` out of range. Same reason as above.
303
+ #[inline]
304
+ pub fn str_char_at(s: &str, idx: &Value) -> Value {
305
+ let i = match idx {
306
+ Value::Number(n) if *n >= 0.0 && n.fract() == 0.0 => *n as usize,
307
+ _ => return Value::String("".into()),
308
+ };
309
+ match s.chars().nth(i) {
310
+ Some(c) => Value::String(c.to_string().into()),
311
+ None => Value::String("".into()),
312
+ }
313
+ }
314
+
269
315
  pub fn delete_property(obj: &Value, key: &Value) -> Value {
270
316
  match obj {
271
317
  Value::Object(m) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-format",
3
- "version": "3.1.0",
3
+ "version": "3.3.0",
4
4
  "description": "Tish code formatter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file