plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


dc07fdf — Peter John 2026-11-02T16:59:41+05:30
plum game: serve the web build on 127.0.0.1 and open the browser; -o writes a static build
plum-cli/src/game.rs CHANGED
@@ -1,11 +1,17 @@
1
- //! `plum game`: compile a game and run it natively, or write a static web build.
1
+ //! `plum game`: compile a game and serve it in the browser, or write a static web build.
2
2
 
3
3
  use std::fs;
4
+ use std::io::{BufRead, BufReader, Write};
5
+ use std::net::{TcpListener, TcpStream};
4
6
  use std::path::{Path, PathBuf};
5
7
  use std::process::Command;
8
+ use std::sync::Arc;
6
9
 
7
10
  use anyhow::{bail, Context, Result};
8
11
 
12
+ const INDEX_HTML: &str = include_str!("../../plum-game/web/index.html");
13
+ const BRIDGE_JS: &str = include_str!("../../plum-game/web/plum_bridge.js");
14
+
9
15
  /// The repository root, fixed when plum-cli is compiled.
10
16
  fn repoRoot() -> PathBuf {
11
17
  Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -14,10 +20,6 @@ fn repoRoot() -> PathBuf {
14
20
  .to_path_buf()
15
21
  }
16
22
 
17
- fn gameManifest() -> PathBuf {
18
- repoRoot().join("plum-game/Cargo.toml")
19
- }
20
-
21
23
  /// The asset root: `--assets`, else `assets` beside the source file.
22
24
  fn assetRoot(file: &Path, cli: Option<PathBuf>) -> PathBuf {
23
25
  cli.unwrap_or_else(|| {
@@ -28,117 +30,6 @@ fn assetRoot(file: &Path, cli: Option<PathBuf>) -> PathBuf {
28
30
  })
29
31
  }
30
32
 
31
- /// The browser renderer of a `--web` build.
32
- #[derive(Clone, Copy, Debug, PartialEq, Eq, clap::ValueEnum)]
33
- pub enum Renderer {
34
- /// macroquad compiled to wasm32 (the default)
35
- Macroquad,
36
- /// PixiJS, driven straight from the Plum module (experimental)
37
- Pixi,
38
- /// our own batching renderer, WebGPU with a WebGL2 fallback, no library (experimental)
39
- Gpu,
40
- }
41
-
42
- pub fn cmdGame(
43
- file: PathBuf,
44
- web: bool,
45
- renderer: Renderer,
46
- output: Option<PathBuf>,
47
- assets: Option<PathBuf>,
48
- lib_path: Option<PathBuf>,
49
- ) -> Result<()> {
50
- let lib_path = lib_path.unwrap_or_else(repoRoot);
51
- let wasm = crate::compileToWasm(&file, &lib_path)?;
52
- let assets = assetRoot(&file, assets);
53
- if web {
54
- let dist = output.unwrap_or_else(|| {
55
- file.parent()
56
- .filter(|p| !p.as_os_str().is_empty())
57
- .unwrap_or(Path::new("."))
58
- .join("dist")
59
- });
60
- return match renderer {
61
- Renderer::Macroquad => writeWeb(&wasm, &dist, &assets),
62
- Renderer::Pixi => writeWebPixi(&wasm, &dist, &assets),
63
- Renderer::Gpu => writeWebGpu(&wasm, &dist, &assets),
64
- };
65
- }
66
- runNative(&wasm, &assets)
67
- }
68
-
69
- fn runNative(wasm: &[u8], assets: &Path) -> Result<()> {
70
- let dir = std::env::temp_dir().join(format!("plum_game_{}", std::process::id()));
71
- fs::create_dir_all(&dir)?;
72
- let path = dir.join("game.wasm");
73
- fs::write(&path, wasm).with_context(|| format!("failed to write {}", path.display()))?;
74
- let status = Command::new("cargo")
75
- .args(["run", "--release", "--manifest-path"])
76
- .arg(gameManifest())
77
- .arg("--")
78
- .arg(&path)
79
- .arg("--assets")
80
- .arg(assets)
81
- .status()
82
- .context("failed to run cargo")?;
83
- let _ = fs::remove_dir_all(&dir);
84
- if !status.success() {
85
- bail!("the game host exited with {status}");
86
- }
87
- Ok(())
88
- }
89
-
90
- /// Path of macroquad's `mq_js_bundle.js`, found through the host crate's
91
- /// locked dependency (the bundle must match the crate version).
92
- fn jsBundle() -> Result<PathBuf> {
93
- let out = Command::new("cargo")
94
- .args(["metadata", "--format-version", "1", "--manifest-path"])
95
- .arg(gameManifest())
96
- .output()
97
- .context("failed to run cargo metadata")?;
98
- if !out.status.success() {
99
- bail!(
100
- "cargo metadata failed: {}",
101
- String::from_utf8_lossy(&out.stderr)
102
- );
103
- }
104
- let json = String::from_utf8_lossy(&out.stdout);
105
- // `"manifest_path":".../macroquad-0.4.x/Cargo.toml"` of the macroquad package.
106
- let mut rest = json.as_ref();
107
- while let Some(i) = rest.find("\"manifest_path\":\"") {
108
- rest = &rest[i + 17..];
109
- let end = rest.find('"').unwrap_or(rest.len());
110
- let manifest = &rest[..end];
111
- if Path::new(manifest)
112
- .parent()
113
- .and_then(|d| d.file_name())
114
- .is_some_and(|n| n.to_string_lossy().starts_with("macroquad-"))
115
- {
116
- let bundle = Path::new(manifest).with_file_name("js/mq_js_bundle.js");
117
- if bundle.is_file() {
118
- return Ok(bundle);
119
- }
120
- }
121
- }
122
- bail!("macroquad's mq_js_bundle.js not found; run `cargo fetch --manifest-path plum-game/Cargo.toml`")
123
- }
124
-
125
- /// Builds the host for the browser and returns the path of its wasm.
126
- fn buildHostWasm() -> Result<PathBuf> {
127
- let status = Command::new("cargo")
128
- .args(["build", "--release", "--target", "wasm32-unknown-unknown"])
129
- .arg("--manifest-path")
130
- .arg(gameManifest())
131
- .status()
132
- .context("failed to run cargo")?;
133
- if !status.success() {
134
- bail!("building the web host failed (is the wasm32-unknown-unknown target installed?)");
135
- }
136
- let target = std::env::var_os("CARGO_TARGET_DIR")
137
- .map(PathBuf::from)
138
- .unwrap_or_else(|| repoRoot().join("plum-game/target"));
139
- Ok(target.join("wasm32-unknown-unknown/release/plum-game.wasm"))
140
- }
141
-
142
33
  /// Every file under `dir` (dotfiles skipped), as `/`-separated paths relative to it, sorted.
143
34
  fn listFiles(dir: &Path) -> Result<Vec<String>> {
144
35
  fn walk(dir: &Path, prefix: &str, out: &mut Vec<String>) -> Result<()> {
@@ -159,96 +50,182 @@ fn listFiles(dir: &Path) -> Result<Vec<String>> {
159
50
  Ok(())
160
51
  }
161
52
  let mut out = Vec::new();
53
+ if dir.is_dir() {
162
- walk(dir, "", &mut out)?;
54
+ walk(dir, "", &mut out)?;
55
+ }
163
56
  out.sort();
164
57
  Ok(out)
165
58
  }
166
59
 
167
- /// Writes `plum.wasm`, `preload.txt` and the `assets/` copy shared by every web build.
60
+ /// The generated files of a web build: the page, the bridge, the Plum module and
61
+ /// the list of assets the bridge fetches before the first frame.
62
+ struct Site {
63
+ wasm: Vec<u8>,
64
+ preload: String,
65
+ assets: PathBuf,
66
+ }
67
+
68
+ impl Site {
69
+ fn new(wasm: Vec<u8>, assets: PathBuf) -> Result<Site> {
70
+ let preload = listFiles(&assets)?.iter().map(|f| format!("{f}\n")).collect();
71
+ Ok(Site { wasm, preload, assets })
72
+ }
73
+
74
+ /// The body and content type of the file at the URL path `rel`.
75
+ fn get(&self, rel: &str) -> Option<(Vec<u8>, &'static str)> {
76
+ let rel = if rel.is_empty() { "index.html" } else { rel };
77
+ match rel {
78
+ "index.html" => return Some((INDEX_HTML.as_bytes().to_vec(), mime("index.html"))),
79
+ "plum_bridge.js" => return Some((BRIDGE_JS.as_bytes().to_vec(), mime("plum_bridge.js"))),
80
+ "plum.wasm" => return Some((self.wasm.clone(), mime("plum.wasm"))),
81
+ "preload.txt" => return Some((self.preload.clone().into_bytes(), mime("preload.txt"))),
82
+ _ => {}
83
+ }
84
+ let asset = rel.strip_prefix("assets/")?;
85
+ let listed = self.preload.lines().any(|l| l == asset);
86
+ listed.then(|| fs::read(self.assets.join(asset)).ok()).flatten().map(|b| (b, mime(asset)))
87
+ }
88
+
89
+ /// Writes the static build (index.html, plum_bridge.js, plum.wasm, preload.txt, assets/).
168
- fn writeWebAssets(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
90
+ fn write(&self, dist: &Path) -> Result<()> {
169
- // The whole asset directory (if there is one) is bundled and preloaded.
170
- let files = if assets.is_dir() {
91
+ fs::create_dir_all(dist)?;
171
- listFiles(assets)?
92
+ fs::write(dist.join("index.html"), INDEX_HTML)?;
172
- } else {
173
- Vec::new()
93
+ fs::write(dist.join("plum_bridge.js"), BRIDGE_JS)?;
174
- };
175
- fs::write(dist.join("plum.wasm"), wasm)?;
94
+ fs::write(dist.join("plum.wasm"), &self.wasm)?;
176
- fs::write(
177
- dist.join("preload.txt"),
95
+ fs::write(dist.join("preload.txt"), &self.preload)?;
178
- files.iter().map(|f| format!("{f}\n")).collect::<String>(),
179
- )?;
180
- for rel in &files {
96
+ for rel in self.preload.lines() {
181
- let to = dist.join("assets").join(rel);
97
+ let to = dist.join("assets").join(rel);
182
- fs::create_dir_all(to.parent().expect("asset has a parent"))?;
98
+ fs::create_dir_all(to.parent().expect("asset has a parent"))?;
183
- fs::copy(assets.join(rel), &to)?;
99
+ fs::copy(self.assets.join(rel), &to)?;
100
+ }
101
+ Ok(())
184
102
  }
185
- Ok(())
186
103
  }
187
104
 
188
- fn writeWeb(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
105
+ fn mime(path: &str) -> &'static str {
106
+ match path.rsplit('.').next().unwrap_or("").to_ascii_lowercase().as_str() {
107
+ "html" => "text/html; charset=utf-8",
108
+ "js" | "mjs" => "text/javascript; charset=utf-8",
189
- let hostWasm = buildHostWasm()?;
109
+ "wasm" => "application/wasm",
110
+ "txt" => "text/plain; charset=utf-8",
111
+ "json" => "application/json",
190
- let bundle = jsBundle()?;
112
+ "png" => "image/png",
113
+ "jpg" | "jpeg" => "image/jpeg",
191
- fs::create_dir_all(dist)?;
114
+ "gif" => "image/gif",
192
- let web = repoRoot().join("plum-game/web");
115
+ "webp" => "image/webp",
193
- for name in ["index.html", "plum_bridge.js"] {
116
+ "bmp" => "image/bmp",
194
- fs::copy(web.join(name), dist.join(name))
195
- .with_context(|| format!("failed to copy {name}"))?;
117
+ "wav" => "audio/wav",
118
+ "ogg" => "audio/ogg",
119
+ _ => "application/octet-stream",
196
120
  }
197
- fs::copy(&bundle, dist.join("mq_js_bundle.js"))?;
198
- fs::copy(&hostWasm, dist.join("plum-game.wasm"))
199
- .with_context(|| format!("failed to copy {}", hostWasm.display()))?;
200
- writeWebAssets(wasm, dist, assets)?;
201
- eprintln!("wrote web build to {}", dist.display());
202
- Ok(())
203
121
  }
204
122
 
205
- /// The vendored PixiJS build and its license, in `plum-game/web/vendor`.
123
+ /// Decodes `%XX` escapes; None when the result is not UTF-8.
206
- fn pixiVendor() -> Result<(PathBuf, PathBuf)> {
124
+ fn percentDecode(s: &str) -> Option<String> {
207
- let dir = repoRoot().join("plum-game/web/vendor");
208
- let js = fs::read_dir(&dir)
125
+ let b = s.as_bytes();
209
- .with_context(|| format!("failed to read {}", dir.display()))?
126
+ let mut out = Vec::with_capacity(b.len());
210
- .flatten()
211
- .map(|e| e.path())
212
- .find(|p| {
127
+ let mut i = 0;
213
- p.file_name().is_some_and(|n| {
128
+ while i < b.len() {
129
+ if b[i] == b'%' {
130
+ let hex = std::str::from_utf8(b.get(i + 1..i + 3)?).ok()?;
131
+ out.push(u8::from_str_radix(hex, 16).ok()?);
132
+ i += 3;
133
+ } else {
134
+ out.push(b[i]);
135
+ i += 1;
136
+ }
137
+ }
138
+ String::from_utf8(out).ok()
139
+ }
140
+
141
+ fn respond(mut stream: TcpStream, site: &Site) {
214
- let n = n.to_string_lossy();
142
+ let mut line = String::new();
215
- n.starts_with("pixi-") && n.ends_with(".min.js")
143
+ if BufReader::new(&stream).read_line(&mut line).is_err() {
144
+ return;
216
- })
145
+ }
146
+ let mut parts = line.split_whitespace();
147
+ let (method, target) = (parts.next().unwrap_or(""), parts.next().unwrap_or("/"));
148
+ let rel = target.split(['?', '#']).next().unwrap_or("/").trim_start_matches('/');
149
+ let found = percentDecode(rel)
150
+ .filter(|r| !r.split('/').any(|c| c == ".." || c.contains('\\')))
151
+ .and_then(|r| site.get(&r));
152
+ let head = matches!(method, "GET" | "HEAD");
153
+ match found.filter(|_| head) {
154
+ Some((body, ctype)) => {
155
+ let _ = write!(
156
+ stream,
157
+ "HTTP/1.1 200 OK\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n",
158
+ body.len()
159
+ );
160
+ if method == "GET" {
161
+ let _ = stream.write_all(&body);
217
- })
162
+ }
163
+ }
164
+ None => {
218
- .with_context(|| format!("no pixi-*.min.js in {}", dir.display()))?;
165
+ let status = if head { "404 Not Found" } else { "405 Method Not Allowed" };
219
- Ok((js, dir.join("pixi-LICENSE")))
166
+ let _ = write!(stream, "HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
167
+ }
168
+ }
220
169
  }
221
170
 
222
- /// The PixiJS build needs no host wasm: just the page, the bridge, Pixi and the Plum module.
171
+ /// Opens `url` in the default browser.
223
- fn writeWebPixi(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
172
+ fn openBrowser(url: &str) -> Result<()> {
224
- let (pixi, license) = pixiVendor()?;
173
+ let mut cmd = if cfg!(target_os = "macos") {
225
- fs::create_dir_all(dist)?;
174
+ Command::new("open")
175
+ } else if cfg!(target_os = "windows") {
226
- let web = repoRoot().join("plum-game/web");
176
+ let mut c = Command::new("cmd");
227
- fs::copy(web.join("index_pixi.html"), dist.join("index.html"))
177
+ c.args(["/C", "start", ""]);
178
+ c
179
+ } else {
180
+ Command::new("xdg-open")
181
+ };
228
- .context("failed to copy index_pixi.html")?;
182
+ cmd.arg(url).spawn().context("failed to launch the browser")?;
229
- fs::copy(
230
- web.join("plum_bridge_pixi.js"),
231
- dist.join("plum_bridge_pixi.js"),
232
- )
233
- .context("failed to copy plum_bridge_pixi.js")?;
234
- fs::copy(&pixi, dist.join("pixi.min.js"))
235
- .with_context(|| format!("failed to copy {}", pixi.display()))?;
236
- fs::copy(&license, dist.join("pixi.LICENSE"))
237
- .with_context(|| format!("failed to copy {}", license.display()))?;
238
- writeWebAssets(wasm, dist, assets)?;
239
- eprintln!("wrote web build (pixi) to {}", dist.display());
240
183
  Ok(())
241
184
  }
242
185
 
186
+ fn bind(port: Option<u16>) -> Result<TcpListener> {
187
+ match port {
188
+ Some(p) => TcpListener::bind(("127.0.0.1", p)).with_context(|| format!("cannot listen on 127.0.0.1:{p}")),
243
- /// The own-batcher build needs no host wasm and no library: the page, the bridge and the Plum module.
189
+ // Prefer a stable port so reloads keep the same URL; any free one otherwise.
244
- fn writeWebGpu(wasm: &[u8], dist: &Path, assets: &Path) -> Result<()> {
245
- fs::create_dir_all(dist)?;
246
- let web = repoRoot().join("plum-game/web");
190
+ None => TcpListener::bind(("127.0.0.1", 8080))
247
- fs::copy(web.join("index_gpu.html"), dist.join("index.html"))
191
+ .or_else(|_| TcpListener::bind(("127.0.0.1", 0)))
248
- .context("failed to copy index_gpu.html")?;
192
+ .context("cannot listen on 127.0.0.1"),
193
+ }
194
+ }
195
+
196
+ pub fn cmdGame(
197
+ file: PathBuf,
198
+ output: Option<PathBuf>,
199
+ assets: Option<PathBuf>,
200
+ port: Option<u16>,
201
+ noOpen: bool,
202
+ lib_path: Option<PathBuf>,
203
+ ) -> Result<()> {
204
+ let lib_path = lib_path.unwrap_or_else(repoRoot);
249
- fs::copy(web.join("plum_bridge_gpu.js"), dist.join("plum_bridge_gpu.js"))
205
+ let wasm = crate::compileToWasm(&file, &lib_path)?;
206
+ let assets = assetRoot(&file, assets);
207
+ if assets.exists() && !assets.is_dir() {
250
- .context("failed to copy plum_bridge_gpu.js")?;
208
+ bail!("{} is not a directory", assets.display());
209
+ }
251
- writeWebAssets(wasm, dist, assets)?;
210
+ let site = Site::new(wasm, assets)?;
211
+ if let Some(dist) = output {
212
+ site.write(&dist)?;
252
- eprintln!("wrote web build (gpu) to {}", dist.display());
213
+ eprintln!("wrote web build to {}", dist.display());
214
+ return Ok(());
215
+ }
216
+ let listener = bind(port)?;
217
+ let url = format!("http://127.0.0.1:{}/", listener.local_addr()?.port());
218
+ println!("serving {url} (Ctrl-C to stop)");
219
+ std::io::stdout().flush()?;
220
+ if !noOpen {
221
+ if let Err(e) = openBrowser(&url) {
222
+ eprintln!("{e:#}; open {url} yourself");
223
+ }
224
+ }
225
+ let site = Arc::new(site);
226
+ for stream in listener.incoming().flatten() {
227
+ let site = site.clone();
228
+ std::thread::spawn(move || respond(stream, &site));
229
+ }
253
230
  Ok(())
254
231
  }
plum-cli/src/main.rs CHANGED
@@ -76,22 +76,22 @@ enum Command {
76
76
  #[arg(long, default_value = ".")]
77
77
  lib_path: std::path::PathBuf,
78
78
  },
79
- /// Compile a Plum game and run it natively, or package it for the browser with --web
79
+ /// Compile a Plum game and serve it in the browser (WebGPU), or write a static web build with -o
80
80
  Game {
81
81
  /// Source file with `export fun init/update/draw`
82
82
  file: std::path::PathBuf,
83
- /// Write a static web build instead of opening a window
84
- #[arg(long)]
85
- web: bool,
86
- /// Browser renderer for --web: macroquad (default), pixi or gpu (both experimental)
83
+ /// Write the static build (index.html, plum_bridge.js, plum.wasm, preload.txt, assets/) here and exit
87
- #[arg(long, value_enum, default_value = "macroquad")]
88
- renderer: game::Renderer,
89
- /// Output directory for --web (default: `dist` beside the source file)
90
84
  #[arg(short, long)]
91
85
  output: Option<std::path::PathBuf>,
92
- /// Asset root (default: `assets` beside the source file); --web bundles all of it
86
+ /// Asset root (default: `assets` beside the source file); all of it is served or bundled
93
87
  #[arg(long)]
94
88
  assets: Option<std::path::PathBuf>,
89
+ /// Port to serve on (default: 8080, else any free port)
90
+ #[arg(long)]
91
+ port: Option<u16>,
92
+ /// Do not open the browser
93
+ #[arg(long)]
94
+ no_open: bool,
95
95
  /// Root directory `import <path>` is resolved against (default: the repository root)
96
96
  #[arg(long)]
97
97
  lib_path: Option<std::path::PathBuf>,
@@ -131,12 +131,12 @@ fn run() -> Result<()> {
131
131
  Command::Test { paths, lib_path } => cmdTest(paths, lib_path),
132
132
  Command::Game {
133
133
  file,
134
- web,
135
- renderer,
136
134
  output,
137
135
  assets,
136
+ port,
137
+ no_open,
138
138
  lib_path,
139
- } => game::cmdGame(file, web, renderer, output, assets, lib_path),
139
+ } => game::cmdGame(file, output, assets, port, no_open, lib_path),
140
140
  Command::Editor { editor, insiders } => editor::install(editor, insiders),
141
141
  }
142
142
  }
plum-cli/tests/game_web_test.rs CHANGED
@@ -1,5 +1,6 @@
1
1
  #![allow(non_snake_case)]
2
+ use std::io::{BufRead, BufReader, Read, Write};
2
- use std::process::Command;
3
+ use std::process::{Command, Stdio};
3
4
 
4
5
  fn plumBin() -> std::path::PathBuf {
5
6
  let mut path = std::env::current_exe().unwrap();
@@ -11,114 +12,122 @@ fn plumBin() -> std::path::PathBuf {
11
12
  path
12
13
  }
13
14
 
14
- /// Exports the host fixture with `renderer` and returns (dist dir, scratch dir).
15
- fn exportFixture(renderer: Option<&str>) -> (std::path::PathBuf, std::path::PathBuf) {
15
+ fn root() -> std::path::PathBuf {
16
- let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
16
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
17
17
  .parent()
18
- .unwrap();
18
+ .unwrap()
19
+ .to_path_buf()
20
+ }
21
+
22
+ /// Stand-in asset files: the build copies and serves them without decoding.
23
+ fn makeAssets(name: &str) -> (std::path::PathBuf, std::path::PathBuf) {
19
- let scratch = std::env::temp_dir().join(format!(
24
+ let scratch = std::env::temp_dir().join(format!("plum_game_web_{}_{name}", std::process::id()));
20
- "plum_game_web_{}_{}",
21
- std::process::id(),
22
- renderer.unwrap_or("default")
23
- ));
24
25
  let assets = scratch.join("assets");
25
- let dist = scratch.join("dist");
26
- // Stand-in asset files: the build copies them without reading them.
27
26
  std::fs::create_dir_all(assets.join("sub")).unwrap();
28
27
  std::fs::write(assets.join("a.png"), b"x").unwrap();
29
- std::fs::write(assets.join("sub/b.txt"), b"x").unwrap();
28
+ std::fs::write(assets.join("sub/b.txt"), b"hello").unwrap();
29
+ (scratch, assets)
30
+ }
30
31
 
32
+ const FIXTURE: &str = "plum-game/tests/fixtures/host_fixture.plum";
33
+
34
+ #[test]
35
+ fn staticBuildWritesTheDist() {
36
+ let (scratch, assets) = makeAssets("static");
37
+ let dist = scratch.join("dist");
31
- let mut cmd = Command::new(plumBin());
38
+ let out = Command::new(plumBin())
39
+ .current_dir(root())
32
- cmd.current_dir(root).args(["game", "--web"]);
40
+ .args(["game", FIXTURE, "-o"])
33
- if let Some(r) = renderer {
34
- cmd.args(["--renderer", r]);
35
- }
36
- let out = cmd
37
- .args(["plum-game/tests/fixtures/host_fixture.plum", "-o"])
38
41
  .arg(&dist)
39
42
  .arg("--assets")
40
43
  .arg(&assets)
41
44
  .output()
42
45
  .expect("failed to run plum");
43
- let all = format!(
44
- "{}{}",
45
- String::from_utf8_lossy(&out.stdout),
46
+ let all = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
46
- String::from_utf8_lossy(&out.stderr)
47
- );
48
- assert!(out.status.success(), "plum game --web failed:\n{all}");
47
+ assert!(out.status.success(), "plum game -o failed:\n{all}");
48
+ let mut names: Vec<String> = std::fs::read_dir(&dist)
49
+ .unwrap()
50
+ .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
51
+ .collect();
49
- (dist, scratch)
52
+ names.sort();
50
- }
51
-
52
- fn checkAssets(dist: &std::path::Path) {
53
+ assert_eq!(names, ["assets", "index.html", "plum.wasm", "plum_bridge.js", "preload.txt"]);
53
54
  let preload = std::fs::read_to_string(dist.join("preload.txt")).unwrap();
54
55
  let lines: Vec<&str> = preload.lines().collect();
55
56
  assert_eq!(lines, ["a.png", "sub/b.txt"]);
56
57
  for l in &lines {
57
- assert!(
58
- dist.join("assets").join(l).is_file(),
58
+ assert!(dist.join("assets").join(l).is_file(), "assets/{l} not copied");
59
- "assets/{l} not copied"
60
- );
61
59
  }
60
+ let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
61
+ assert!(html.contains("plum_bridge.js"));
62
+ let _ = std::fs::remove_dir_all(&scratch);
62
63
  }
63
64
 
64
65
  #[test]
65
- fn webBuildWritesTheDist() {
66
+ fn removedFlagsAreRejected() {
66
- for renderer in [None, Some("macroquad")] {
67
+ for flag in ["--web", "--renderer"] {
67
- let (dist, scratch) = exportFixture(renderer);
68
+ let out = Command::new(plumBin()).current_dir(root()).args(["game", FIXTURE, flag]).output().unwrap();
68
- for f in [
69
- "index.html",
70
- "mq_js_bundle.js",
71
- "plum_bridge.js",
72
- "plum-game.wasm",
73
- "plum.wasm",
74
- "preload.txt",
75
- ] {
76
- assert!(dist.join(f).is_file(), "missing {f}");
69
+ assert!(!out.status.success(), "{flag} should be rejected");
77
- }
78
- assert!(!dist.join("pixi.min.js").exists());
79
- checkAssets(&dist);
80
- let _ = std::fs::remove_dir_all(&scratch);
81
70
  }
82
71
  }
83
72
 
84
- #[test]
85
- fn pixiWebBuildNeedsNoHostWasm() {
73
+ /// GET `path` from 127.0.0.1:`port`; returns (status line, content type, body).
86
- let (dist, scratch) = exportFixture(Some("pixi"));
74
+ fn get(port: u16, path: &str) -> (String, String, Vec<u8>) {
87
- for f in [
88
- "index.html",
89
- "plum_bridge_pixi.js",
90
- "pixi.min.js",
91
- "pixi.LICENSE",
92
- "plum.wasm",
93
- "preload.txt",
94
- ] {
95
- assert!(dist.join(f).is_file(), "missing {f}");
96
- }
97
- for f in ["mq_js_bundle.js", "plum-game.wasm", "plum_bridge.js"] {
98
- assert!(!dist.join(f).exists(), "unexpected {f}");
99
- }
100
- let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
75
+ let mut s = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
101
- assert!(html.contains("plum_bridge_pixi.js") && html.contains("pixi.min.js"));
76
+ write!(s, "GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n").unwrap();
77
+ let mut raw = Vec::new();
102
- checkAssets(&dist);
78
+ s.read_to_end(&mut raw).unwrap();
79
+ let split = raw.windows(4).position(|w| w == b"\r\n\r\n").unwrap();
80
+ let head = String::from_utf8_lossy(&raw[..split]).into_owned();
103
- let _ = std::fs::remove_dir_all(&scratch);
81
+ let status = head.lines().next().unwrap().to_string();
82
+ let ctype = head
83
+ .lines()
84
+ .find_map(|l| l.strip_prefix("Content-Type: "))
85
+ .unwrap_or("")
86
+ .to_string();
87
+ (status, ctype, raw[split + 4..].to_vec())
104
88
  }
105
89
 
106
90
  #[test]
107
- fn gpuWebBuildNeedsNoHostWasmOrLibrary() {
91
+ fn serveStartsAndAnswersWithTheRightContentTypes() {
108
- let (dist, scratch) = exportFixture(Some("gpu"));
92
+ let (scratch, assets) = makeAssets("serve");
93
+ let mut child = Command::new(plumBin())
94
+ .current_dir(root())
109
- for f in ["index.html", "plum_bridge_gpu.js", "plum.wasm", "preload.txt"] {
95
+ .args(["game", FIXTURE, "--no-open", "--port", "0", "--assets"])
96
+ .arg(&assets)
97
+ .stdout(Stdio::piped())
98
+ .stderr(Stdio::null())
99
+ .spawn()
100
+ .expect("failed to run plum");
101
+ let mut line = String::new();
102
+ BufReader::new(child.stdout.take().unwrap()).read_line(&mut line).unwrap();
103
+ let port: u16 = line
104
+ .split("127.0.0.1:")
105
+ .nth(1)
106
+ .and_then(|r| r.split('/').next())
107
+ .and_then(|p| p.parse().ok())
108
+ .unwrap_or_else(|| panic!("no url in `{line}`"));
109
+
110
+ let (status, ctype, body) = get(port, "/");
110
- assert!(dist.join(f).is_file(), "missing {f}");
111
+ assert!(status.contains("200"), "{status}");
111
- }
112
+ assert!(ctype.starts_with("text/html"), "{ctype}");
112
- for f in [
113
+ assert!(String::from_utf8_lossy(&body).contains("plum_bridge.js"));
113
- "mq_js_bundle.js",
114
+ let (_, ctype, body) = get(port, "/plum.wasm");
114
- "plum-game.wasm",
115
+ assert_eq!(ctype, "application/wasm");
116
+ assert_eq!(&body[..4], b"\0asm");
115
- "plum_bridge.js",
117
+ let (_, ctype, _) = get(port, "/plum_bridge.js");
118
+ assert!(ctype.starts_with("text/javascript"), "{ctype}");
116
- "pixi.min.js",
119
+ let (_, ctype, body) = get(port, "/preload.txt");
117
- ] {
118
- assert!(!dist.join(f).exists(), "unexpected {f}");
120
+ assert!(ctype.starts_with("text/plain"));
121
+ assert_eq!(body, b"a.png\nsub/b.txt\n");
122
+ let (_, ctype, body) = get(port, "/assets/sub/b.txt");
123
+ assert!(ctype.starts_with("text/plain"));
119
- }
124
+ assert_eq!(body, b"hello");
125
+ assert_eq!(get(port, "/assets/a.png").1, "image/png");
120
- let html = std::fs::read_to_string(dist.join("index.html")).unwrap();
126
+ assert!(get(port, "/nope").0.contains("404"));
121
- assert!(html.contains("plum_bridge_gpu.js") && !html.contains("pixi"));
127
+ assert!(get(port, "/assets/../a.png").0.contains("404"));
128
+ assert!(get(port, "/assets/%2e%2e/a.png").0.contains("404"));
129
+
130
+ child.kill().unwrap();
122
- checkAssets(&dist);
131
+ child.wait().unwrap();
123
132
  let _ = std::fs::remove_dir_all(&scratch);
124
133
  }