diff --git a/.github/workflows/mirror.yml b/.github/workflows/mirror.yml index d59050a9..a6429aeb 100644 --- a/.github/workflows/mirror.yml +++ b/.github/workflows/mirror.yml @@ -36,7 +36,7 @@ jobs: echo "::error::GITEE_TOKEN secret is missing"; exit 1 fi git remote remove gitee 2>/dev/null || true - git remote add gitee "https://eastspire:${GITEE_TOKEN}@gitee.com/eastspire/${REPO_NAME}.git" + git remote add gitee "https://vshengbro:${GITEE_TOKEN}@gitee.com/vshengbro/${REPO_NAME}.git" attempt=0 max_retries=8 while [ $attempt -le $max_retries ]; do @@ -65,7 +65,7 @@ jobs: echo "::error::GITCODE_TOKEN secret is missing"; exit 1 fi git remote remove gitcode 2>/dev/null || true - git remote add gitcode "https://oauth2:${GITCODE_TOKEN}@gitcode.com/eastspire/${REPO_NAME}.git" + git remote add gitcode "https://oauth2:${GITCODE_TOKEN}@gitcode.com/vshengbro/${REPO_NAME}.git" attempt=0 max_retries=8 while [ $attempt -le $max_retries ]; do diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 321f3d0a..7a9dfc45 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -159,7 +159,7 @@ jobs: - name: Configure git if: steps.check_branch.outputs.skip == 'false' run: | - git config user.name "eastspire" + git config user.name "vshengbro" git config user.email "root@ltpp.vip" - name: Sync workspace versions if: steps.check_branch.outputs.skip == 'false' diff --git a/Cargo.toml b/Cargo.toml index 5ece623f..ca2beaf9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ categories = ["network-programming", "web-programming"] exclude.workspace = true [workspace.package] -version = "0.28.9" +version = "0.28.12" readme = "README.md" edition = "2024" authors = ["root@ltpp.vip"] @@ -38,13 +38,13 @@ resolver = "3" crate-type = ["rlib"] [workspace.dependencies] -euv = { path = ".", version = "0.28.9" } -euv-ui = { path = "ui", version = "0.28.9" } -euv-cli = { path = "cli", version = "0.28.9" } -euv-core = { path = "core", version = "0.28.9" } -euv-engine = { path = "engine", version = "0.28.9" } -euv-macros = { path = "macros", version = "0.28.9" } -euv-example = { path = "example", version = "0.28.9" } +euv = { path = ".", version = "0.28.10" } +euv-ui = { path = "ui", version = "0.28.10" } +euv-cli = { path = "cli", version = "0.28.10" } +euv-core = { path = "core", version = "0.28.10" } +euv-engine = { path = "engine", version = "0.28.10" } +euv-macros = { path = "macros", version = "0.28.10" } +euv-example = { path = "example", version = "0.28.10" } log = "0.4.33" toml = "0.9.12" @@ -53,15 +53,15 @@ chrono = "0.4.45" ignore = "0.4.31" js-sys = "0.3.103" if-addrs = "0.15.0" -hyperlane = "21.9.1" serde_json = "1.0.151" color-output = "10.2.2" proc-macro2 = "1.0.107" -hyperlane-cli = "21.9.1" +hyperlane-cli = "21.9.0" lombok-macros = "10.2.2" wasm-bindgen = "0.2.126" alloc-no-stdlib = "3.0.0" compare_version = "10.2.2" +hyperlane-core = "=21.9.0" bin-encode-decode = "1.2.6" serde-wasm-bindgen = "0.6.5" wasm-bindgen-test = "0.3.76" @@ -82,6 +82,9 @@ tokio = { version = "1.53.1", features = [ "process", "rt-multi-thread", ] } +hyperlane = "21.9.0" +# hyperlane-core 21.11.0 changed Server::route to take two generic +# arguments; pin the transitive dependency until euv-cli migrates. web-sys = { version = "0.3.103", features = [ "Gpu", "Blob", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 0f965479..abe14afd 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -31,3 +31,8 @@ proc-macro2 = { workspace = true } color-output = { workspace = true } hyperlane-cli = { workspace = true } lombok-macros = { workspace = true } +hyperlane-core = { workspace = true } + +[dev-dependencies] +clap = { workspace = true } +tokio = { workspace = true } diff --git a/cli/src/build/const.rs b/cli/src/build/const.rs index 1616bbaf..d8a16551 100644 --- a/cli/src/build/const.rs +++ b/cli/src/build/const.rs @@ -1,12 +1,22 @@ +/// The default output subdirectory name for wasm-pack artifacts. +pub(crate) const PKG_DIR_NAME: &str = "pkg"; + +/// The index HTML file name. +pub(crate) const INDEX_HTML_FILE_NAME: &str = "index.html"; + +/// The hyphen character used when normalizing crate names to wasm-pack's +/// underscore output convention. +pub(crate) const STR_HYPHEN: &str = "-"; + /// Placeholder token used in HTML templates for the JS import path. /// /// Replaced at runtime with the resolved import path relative to the www directory. -pub const IMPORT_PATH_PLACEHOLDER: &str = "__IMPORT_PATH__"; +pub(crate) const IMPORT_PATH_PLACEHOLDER: &str = "__IMPORT_PATH__"; /// Placeholder token used in HTML templates for the reload endpoint URL. /// /// Replaced at runtime with the actual reload route path. -pub const RELOAD_ROUTE_PLACEHOLDER: &str = "__RELOAD_ROUTE__"; +pub(crate) const RELOAD_ROUTE_PLACEHOLDER: &str = "__RELOAD_ROUTE__"; /// Placeholder token used in HTML templates for the inlined wasm-bindgen JS bridge. /// @@ -15,7 +25,7 @@ pub const RELOAD_ROUTE_PLACEHOLDER: &str = "__RELOAD_ROUTE__"; /// wrapped in a synchronous IIFE that fetches the wasm module and calls /// `main()`. This eliminates the separate HTTP request for the JS bridge file /// and skips ES module graph parsing on the critical path. -pub const INLINE_JS_PLACEHOLDER: &str = "__EUV_INLINE_JS__"; +pub(crate) const INLINE_JS_PLACEHOLDER: &str = "__EUV_INLINE_JS__"; /// Placeholder token used in HTML templates for the `` tag. /// @@ -25,104 +35,91 @@ pub const INLINE_JS_PLACEHOLDER: &str = "__EUV_INLINE_JS__"; /// resource the HTML references — against the euv serving root rather /// than the page URL. This makes the dev server robust to users landing /// on `http://host:port/` instead of `http://host:port/www/`. -pub const BASE_HREF_PLACEHOLDER: &str = "__EUV_BASE_HREF_TAG__"; +pub(crate) const BASE_HREF_PLACEHOLDER: &str = "__EUV_BASE_HREF_TAG__"; /// Environment variable to disable JS bridge inlining and fall back to the /// classic ` "#; + +/// The euv-specific argument for specifying the server port. +pub(crate) const PORT_ARG: &str = "--port"; diff --git a/cli/src/build/fn.rs b/cli/src/build/fn.rs index d141a953..744634cb 100644 --- a/cli/src/build/fn.rs +++ b/cli/src/build/fn.rs @@ -48,8 +48,17 @@ pub fn filter_euv_args(wasm_pack_args: &[String]) -> Vec { skip_next = false; continue; } - if EUV_ARGS.contains(&arg.as_str()) { - if arg.contains('=') { + // Compare the flag NAME, not the whole argument: clap accepts both + // `--port 3000` and `--port=3000`, and only the first form ever + // matched `EUV_ARGS` exactly. Without the split, `--port=3000` was + // forwarded to wasm-pack even though `reconcile_args` had already + // consumed its value — the same string both used and leaked. + let name: &str = arg.split('=').next().unwrap_or(arg.as_str()); + if EUV_ARGS.contains(&name) { + if arg.contains('=') || name == NO_GITIGNORE_ARG { + // `--no-gitignore` is a bare boolean: there is no value to + // skip, so treating it like `--port 3000` ate whatever + // argument followed it. continue; } skip_next = true; @@ -341,7 +350,7 @@ pub fn resolve_out_name(args: &ModeArgs) -> String { /// /// - `Option` - The crate name if found. fn read_crate_name_from_toml(path: &Path) -> Option { - let content: String = std::fs::read_to_string(path).ok()?; + let content: String = sync_read_to_string(path).ok()?; let mut in_package: bool = false; for line in content.lines() { let trimmed: &str = line.trim(); @@ -740,7 +749,7 @@ pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> { let display_args: Vec = (if has_existing_build_mode { filtered_args.to_vec() } else { - std::iter::once(build_mode_flag.to_string()) + once(build_mode_flag.to_string()) .chain(filtered_args.iter().cloned()) .collect::>() }) @@ -901,7 +910,7 @@ pub(crate) async fn build_inline_bridge( js_name: &str, wasm_url: &str, ) -> Result { - let js_path: std::path::PathBuf = pkg_dir.join(js_name); + let js_path: PathBuf = pkg_dir.join(js_name); let bridge_source: String = read_to_string(&js_path) .await @@ -958,7 +967,7 @@ pub(crate) fn build_module_fallback_bridge(import_path: &str) -> String { /// /// - `bool` - `true` if the env var is set to a non-empty value. pub(crate) fn inline_bridge_disabled() -> bool { - matches!(std::env::var(EUV_NO_INLINE_BRIDGE_ENV), Ok(value) if !value.is_empty()) + matches!(var(EUV_NO_INLINE_BRIDGE_ENV), Ok(value) if !value.is_empty()) } /// Collects the source of every module the bridge imports, inlined in @@ -1008,7 +1017,7 @@ async fn collect_snippet_bodies(bridge_source: &str, pkg_dir: &Path) -> Result = if snippet_path.exists() { let raw: String = read_to_string(&snippet_path) .await @@ -1153,7 +1162,7 @@ pub fn extract_import_spec(rest: &str) -> Option<&str> { /// - `Result, EuvError>` - The stripped body, `None` if the /// file does not exist, or an error if it cannot be read or decoded. async fn read_snippet_module(pkg_dir: &Path, spec: &str) -> Result, EuvError> { - let snippet_path: std::path::PathBuf = resolve_snippet_path(pkg_dir, spec)?; + let snippet_path: PathBuf = resolve_snippet_path(pkg_dir, spec)?; if !snippet_path.exists() { return Ok(None); } @@ -1181,11 +1190,11 @@ async fn read_snippet_module(pkg_dir: &Path, spec: &str) -> Result` - The resolved path, or an error if the +/// - `Result` - The resolved path, or an error if the /// specifier climbs above `pkg_dir`. -fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result { +fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result { let base: &str = spec.trim_start_matches('.').trim_start_matches('/'); - let mut path: std::path::PathBuf = pkg_dir.to_path_buf(); + let mut path: PathBuf = pkg_dir.to_path_buf(); for segment in base.split('/') { if segment.is_empty() || segment == "." { continue; diff --git a/cli/src/build/mod.rs b/cli/src/build/mod.rs index d588042a..d4a5f82a 100644 --- a/cli/src/build/mod.rs +++ b/cli/src/build/mod.rs @@ -3,6 +3,8 @@ mod r#enum; mod r#fn; mod r#struct; -pub use {r#const::*, r#enum::*, r#fn::*, r#struct::*}; +pub use {r#enum::*, r#fn::*, r#struct::*}; + +pub(crate) use r#const::*; use super::*; diff --git a/cli/src/fmt/const.rs b/cli/src/fmt/const.rs index 2307b478..53bf859d 100644 --- a/cli/src/fmt/const.rs +++ b/cli/src/fmt/const.rs @@ -1,17 +1,53 @@ +/// The left parenthesis character, used in CSS functional pseudo-class detection. +pub(crate) const CHAR_LEFT_PAREN: char = '('; + +/// The right parenthesis character, used in CSS functional pseudo-class detection. +pub(crate) const CHAR_RIGHT_PAREN: char = ')'; + +/// The double quote character used to delimit string literals. +pub(crate) const CHAR_DOUBLE_QUOTE: char = '"'; + +/// The underscore character, part of Rust identifiers. +pub(crate) const CHAR_UNDERSCORE: char = '_'; + +/// The hyphen character, used in CSS selector identifier detection. +pub(crate) const CHAR_HYPHEN: char = '-'; + +/// The newline character. +pub(crate) const CHAR_NEWLINE: char = '\n'; + +/// The space character. +pub(crate) const CHAR_SPACE: char = ' '; + +/// The colon character, used in attribute separator formatting. +pub(crate) const CHAR_COLON: char = ':'; + +/// The raw identifier prefix string. +pub(crate) const RAW_IDENT_PREFIX: &str = "r#"; + +/// The comma character. +pub(crate) const CHAR_COMMA: char = ','; + +/// The semicolon character. +pub(crate) const CHAR_SEMICOLON: char = ';'; + +/// The single quote character used to delimit character and string literals. +pub(crate) const CHAR_SINGLE_QUOTE: char = '\''; + /// The euv macro name for `html!`. -pub const MACRO_NAME_HTML: &str = "html"; +pub(crate) const MACRO_NAME_HTML: &str = "html"; /// The euv macro name for `class!`. -pub const MACRO_NAME_CLASS: &str = "class"; +pub(crate) const MACRO_NAME_CLASS: &str = "class"; /// The euv macro name for `vars!`. -pub const MACRO_NAME_VARS: &str = "vars"; +pub(crate) const MACRO_NAME_VARS: &str = "vars"; /// The euv macro name for `watch!`. -pub const MACRO_NAME_WATCH: &str = "watch"; +pub(crate) const MACRO_NAME_WATCH: &str = "watch"; /// The euv macro names that should be formatted. -pub const EUV_MACRO_NAMES: &[&str] = &[ +pub(crate) const EUV_MACRO_NAMES: &[&str] = &[ MACRO_NAME_HTML, MACRO_NAME_CLASS, MACRO_NAME_VARS, @@ -19,159 +55,123 @@ pub const EUV_MACRO_NAMES: &[&str] = &[ ]; /// The Rust `if` keyword string. -pub const KEYWORD_IF: &str = "if"; +pub(crate) const KEYWORD_IF: &str = "if"; /// The Rust `else` keyword string. -pub const KEYWORD_ELSE: &str = "else"; +pub(crate) const KEYWORD_ELSE: &str = "else"; /// The Rust `match` keyword string. -pub const KEYWORD_MATCH: &str = "match"; +pub(crate) const KEYWORD_MATCH: &str = "match"; /// The Rust `for` keyword string. -pub const KEYWORD_FOR: &str = "for"; +pub(crate) const KEYWORD_FOR: &str = "for"; /// The Rust `in` keyword string. -pub const KEYWORD_IN: &str = "in"; +pub(crate) const KEYWORD_IN: &str = "in"; /// The Rust keywords that can follow a colon in euv macro attribute syntax /// (e.g., `class: if { ... }`), indicating an attribute value expression /// rather than a CSS selector. -pub const ATTRIBUTE_VALUE_KEYWORDS: &[&str] = &[KEYWORD_IF, KEYWORD_MATCH, KEYWORD_FOR]; +pub(crate) const ATTRIBUTE_VALUE_KEYWORDS: &[&str] = &[KEYWORD_IF, KEYWORD_MATCH, KEYWORD_FOR]; /// The Rust fat arrow operator string. -pub const ARROW_FAT: &str = "=>"; +pub(crate) const ARROW_FAT: &str = "=>"; /// The directory name to skip when scanning for Rust source files (Rust build output). -pub const TARGET_DIR_NAME: &str = "target"; +pub(crate) const TARGET_DIR_NAME: &str = "target"; /// The directory name to skip when scanning for Rust source files (Node.js dependencies). -pub const NODE_MODULES_DIR_NAME: &str = "node_modules"; +pub(crate) const NODE_MODULES_DIR_NAME: &str = "node_modules"; /// The Rust source file extension. -pub const RS_EXTENSION: &str = "rs"; +pub(crate) const RS_EXTENSION: &str = "rs"; /// The exclamation mark character used to identify macro invocations. -pub const CHAR_MACRO_BANG: char = '!'; +pub(crate) const CHAR_MACRO_BANG: char = '!'; /// The left brace character used to delimit code blocks and macro bodies. -pub const CHAR_BRACE_LEFT: char = '{'; +pub(crate) const CHAR_BRACE_LEFT: char = '{'; /// The right brace character used to delimit code blocks and macro bodies. -pub const CHAR_BRACE_RIGHT: char = '}'; - -/// The left parenthesis character, used in CSS functional pseudo-class detection. -pub const CHAR_LEFT_PAREN: char = '('; - -/// The right parenthesis character, used in CSS functional pseudo-class detection. -pub const CHAR_RIGHT_PAREN: char = ')'; - -/// The double quote character used to delimit string literals. -pub const CHAR_DOUBLE_QUOTE: char = '"'; - -/// The single quote character used to delimit character and string literals. -pub const CHAR_SINGLE_QUOTE: char = '\''; +pub(crate) const CHAR_BRACE_RIGHT: char = '}'; /// The forward slash character used in comment and path detection. -pub const CHAR_SLASH_FORWARD: char = '/'; +pub(crate) const CHAR_SLASH_FORWARD: char = '/'; /// The forward slash string used in path normalization. -pub const STR_SLASH_FORWARD: &str = "/"; +pub(crate) const STR_SLASH_FORWARD: &str = "/"; /// The backslash character used as escape prefix in string literals. -pub const CHAR_SLASH_BACK: char = '\\'; - -/// The underscore character, part of Rust identifiers. -pub const CHAR_UNDERSCORE: char = '_'; +pub(crate) const CHAR_SLASH_BACK: char = '\\'; /// The hash character, used in raw identifier prefix detection. -pub const CHAR_HASH: char = '#'; - -/// The hyphen character, used in CSS selector identifier detection. -pub const CHAR_HYPHEN: char = '-'; - -/// The newline character. -pub const CHAR_NEWLINE: char = '\n'; +pub(crate) const CHAR_HASH: char = '#'; /// The carriage return character. -pub const CHAR_CARRIAGE_RETURN: char = '\r'; +pub(crate) const CHAR_CARRIAGE_RETURN: char = '\r'; /// The tab character. -pub const CHAR_TAB: char = '\t'; - -/// The space character. -pub const CHAR_SPACE: char = ' '; +pub(crate) const CHAR_TAB: char = '\t'; /// The asterisk character, used in block comment end detection. -pub const CHAR_ASTERISK: char = '*'; - -/// The colon character, used in attribute separator formatting. -pub const CHAR_COLON: char = ':'; +pub(crate) const CHAR_ASTERISK: char = '*'; /// The equals sign character, used in fat arrow detection. -pub const CHAR_EQUALS: char = '='; +pub(crate) const CHAR_EQUALS: char = '='; /// The greater-than character, used in fat arrow detection. -pub const CHAR_GREATER_THAN: char = '>'; +pub(crate) const CHAR_GREATER_THAN: char = '>'; /// The block comment start delimiter. -pub const BLOCK_COMMENT_START: &str = "/*"; - -/// The raw identifier prefix string. -pub const RAW_IDENT_PREFIX: &str = "r#"; +pub(crate) const BLOCK_COMMENT_START: &str = "/*"; /// The letter `a`, used in keyword detection. -pub const CHAR_LETTER_A: char = 'a'; +pub(crate) const CHAR_LETTER_A: char = 'a'; /// The letter `c`, used in keyword detection. -pub const CHAR_LETTER_C: char = 'c'; +pub(crate) const CHAR_LETTER_C: char = 'c'; /// The letter `e`, used in keyword detection. -pub const CHAR_LETTER_E: char = 'e'; +pub(crate) const CHAR_LETTER_E: char = 'e'; /// The letter `f`, used in keyword detection. -pub const CHAR_LETTER_F: char = 'f'; +pub(crate) const CHAR_LETTER_F: char = 'f'; /// The letter `h`, used in keyword detection. -pub const CHAR_LETTER_H: char = 'h'; +pub(crate) const CHAR_LETTER_H: char = 'h'; /// The letter `i`, used in keyword detection. -pub const CHAR_LETTER_I: char = 'i'; +pub(crate) const CHAR_LETTER_I: char = 'i'; /// The letter `l`, used in keyword detection. -pub const CHAR_LETTER_L: char = 'l'; +pub(crate) const CHAR_LETTER_L: char = 'l'; /// The letter `m`, used in keyword detection. -pub const CHAR_LETTER_M: char = 'm'; +pub(crate) const CHAR_LETTER_M: char = 'm'; /// The letter `n`, used in keyword detection. -pub const CHAR_LETTER_N: char = 'n'; +pub(crate) const CHAR_LETTER_N: char = 'n'; /// The letter `o`, used in keyword detection. -pub const CHAR_LETTER_O: char = 'o'; +pub(crate) const CHAR_LETTER_O: char = 'o'; /// The letter `r`, used in keyword detection. -pub const CHAR_LETTER_R: char = 'r'; +pub(crate) const CHAR_LETTER_R: char = 'r'; /// The letter `s`, used in keyword detection. -pub const CHAR_LETTER_S: char = 's'; - -/// The comma character. -pub const CHAR_COMMA: char = ','; - -/// The semicolon character. -pub const CHAR_SEMICOLON: char = ';'; +pub(crate) const CHAR_LETTER_S: char = 's'; /// The letter `t`, used in keyword detection. -pub const CHAR_LETTER_T: char = 't'; +pub(crate) const CHAR_LETTER_T: char = 't'; /// The error message for a failed directory read during `.rs` file discovery. -pub const ERROR_READ_DIRECTORY: &str = "Failed to read directory"; +pub(crate) const ERROR_READ_DIRECTORY: &str = "Failed to read directory"; /// The error message for a failed directory-entry iteration during `.rs` file discovery. -pub const ERROR_READ_ENTRY_IN_DIRECTORY: &str = "Failed to read entry in directory"; +pub(crate) const ERROR_READ_ENTRY_IN_DIRECTORY: &str = "Failed to read entry in directory"; /// The error message for a failed source-file read during formatting. -pub const ERROR_READ_FILE: &str = "Failed to read"; +pub(crate) const ERROR_READ_FILE: &str = "Failed to read"; /// The error message for a failed source-file write during formatting. -pub const ERROR_WRITE_FILE: &str = "Failed to write"; +pub(crate) const ERROR_WRITE_FILE: &str = "Failed to write"; diff --git a/cli/src/fmt/fn.rs b/cli/src/fmt/fn.rs index 07fe4303..519b51e1 100644 --- a/cli/src/fmt/fn.rs +++ b/cli/src/fmt/fn.rs @@ -986,7 +986,7 @@ fn indented_body_skipping_block_comments(body: &str, indent_str: &str) -> String } if chars[index] == CHAR_NEWLINE { current_line.push(CHAR_NEWLINE); - let line_owned: String = std::mem::take(&mut current_line); + let line_owned: String = take(&mut current_line); let line_ref: &str = line_owned.as_str(); let indented: String = if line_starts_in_comment || line_ref.trim().is_empty() { line_owned diff --git a/cli/src/fmt/mod.rs b/cli/src/fmt/mod.rs index 3fc894af..a837b3e4 100644 --- a/cli/src/fmt/mod.rs +++ b/cli/src/fmt/mod.rs @@ -3,8 +3,8 @@ mod r#enum; mod r#fn; mod r#struct; -pub use {r#const::*, r#enum::*, r#fn::*}; +pub use {r#enum::*, r#fn::*}; -pub(crate) use r#struct::*; +pub(crate) use {r#const::*, r#struct::*}; use super::*; diff --git a/cli/src/hmr/impl.rs b/cli/src/hmr/impl.rs index 838c60fe..781a1b14 100644 --- a/cli/src/hmr/impl.rs +++ b/cli/src/hmr/impl.rs @@ -108,7 +108,7 @@ impl HmrState { /// /// # Returns /// - /// - `impl Iterator` - The borrowed key and value pairs. pub fn iter(&self) -> impl Iterator { self.get_entries() .iter() diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 7d0fa057..225ca770 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -13,9 +13,16 @@ mod mode; mod server; pub use std::{ + env::{current_dir, var}, + // hyperlane exports an async ; the std one is synchronous and + // returns its Result directly, so the bare name would silently change which + // function this crate calls. error::Error, ffi::OsStr, fmt::{Display, Formatter}, + fs::{canonicalize as sync_canonicalize, read_to_string as sync_read_to_string}, + iter::once, + mem::take, string::FromUtf8Error, }; pub use {build::*, error::*, fmt::*, hmr::*, logger::*, mode::*, server::*}; diff --git a/cli/src/logger/const.rs b/cli/src/logger/const.rs index 528386e5..4682af66 100644 --- a/cli/src/logger/const.rs +++ b/cli/src/logger/const.rs @@ -1,5 +1,5 @@ /// Single space separator used in log output formatting. -pub const LOG_SPACE: &str = " "; +pub(crate) const LOG_SPACE: &str = " "; /// Colon separator used between file path and line number. -pub const LOG_COLON: &str = ":"; +pub(crate) const LOG_COLON: &str = ":"; diff --git a/cli/src/logger/mod.rs b/cli/src/logger/mod.rs index 18d7c9a3..4a8ca007 100644 --- a/cli/src/logger/mod.rs +++ b/cli/src/logger/mod.rs @@ -1,4 +1,3 @@ -use super::*; mod r#const; mod r#impl; mod r#static; @@ -6,4 +5,6 @@ mod r#struct; pub use r#struct::*; -pub use {r#const::*, r#static::*}; +pub(crate) use {r#const::*, r#static::*}; + +use super::*; diff --git a/cli/src/logger/static.rs b/cli/src/logger/static.rs index 243b3616..998a2407 100644 --- a/cli/src/logger/static.rs +++ b/cli/src/logger/static.rs @@ -1,4 +1,4 @@ use super::*; /// Global static Logger instance. -pub static LOGGER: Logger = Logger; +pub(crate) static LOGGER: Logger = Logger; diff --git a/cli/src/mode/const.rs b/cli/src/mode/const.rs index fccff9bd..c21f572c 100644 --- a/cli/src/mode/const.rs +++ b/cli/src/mode/const.rs @@ -1,5 +1,5 @@ /// The error message returned when `--crate-path` cannot be canonicalized. -pub const ERROR_INVALID_CRATE_PATH: &str = "Invalid crate-path"; +pub(crate) const ERROR_INVALID_CRATE_PATH: &str = "Invalid crate-path"; /// The error message returned when the current working directory is unavailable. -pub const ERROR_CURRENT_DIRECTORY: &str = "Failed to get current directory"; +pub(crate) const ERROR_CURRENT_DIRECTORY: &str = "Failed to get current directory"; diff --git a/cli/src/mode/fn.rs b/cli/src/mode/fn.rs index ba5144ae..2eb15b1b 100644 --- a/cli/src/mode/fn.rs +++ b/cli/src/mode/fn.rs @@ -11,13 +11,13 @@ use super::*; /// - `Result<(), EuvError>` - Indicates success or failure. pub async fn build_mode(mut args: ModeArgs) -> Result<(), EuvError> { reconcile_args(&mut args); - args.set_crate_path(std::fs::canonicalize(args.get_crate_path()).map_err( - |error: io::Error| EuvError::IoPath { + args.set_crate_path( + sync_canonicalize(args.get_crate_path()).map_err(|error: io::Error| EuvError::IoPath { message: ERROR_INVALID_CRATE_PATH.to_string(), path: args.get_crate_path().clone(), error, - }, - )?); + })?, + ); let crate_path_str: String = args.get_crate_path().to_string_lossy().to_string(); if crate_path_str.starts_with(WINDOWS_UNC_PREFIX) { args.set_crate_path(PathBuf::from( @@ -45,7 +45,7 @@ pub async fn fmt_mode(args: FmtArgs) -> Result<(), EuvError> { let fmt_path: PathBuf = if args.get_path().is_absolute() { args.get_path().clone() } else { - std::env::current_dir() + current_dir() .map_err(|error: io::Error| EuvError::Io { message: ERROR_CURRENT_DIRECTORY.to_string(), error, @@ -71,13 +71,13 @@ pub async fn fmt_mode(args: FmtArgs) -> Result<(), EuvError> { /// - `Result<(), EuvError>` - Indicates success or failure. pub async fn run_mode(mut args: ModeArgs) -> Result<(), EuvError> { reconcile_args(&mut args); - args.set_crate_path(std::fs::canonicalize(args.get_crate_path()).map_err( - |error: io::Error| EuvError::IoPath { + args.set_crate_path( + sync_canonicalize(args.get_crate_path()).map_err(|error: io::Error| EuvError::IoPath { message: ERROR_INVALID_CRATE_PATH.to_string(), path: args.get_crate_path().clone(), error, - }, - )?); + })?, + ); let crate_path_str: String = args.get_crate_path().to_string_lossy().to_string(); if crate_path_str.starts_with(WINDOWS_UNC_PREFIX) { args.set_crate_path(PathBuf::from( diff --git a/cli/src/mode/mod.rs b/cli/src/mode/mod.rs index aa4cc50e..2c257cc0 100644 --- a/cli/src/mode/mod.rs +++ b/cli/src/mode/mod.rs @@ -1,6 +1,8 @@ mod r#const; mod r#fn; -pub use {r#const::*, r#fn::*}; +pub use r#fn::*; + +pub(crate) use r#const::*; use super::*; diff --git a/cli/src/server/const.rs b/cli/src/server/const.rs index 866495f5..4f21c105 100644 --- a/cli/src/server/const.rs +++ b/cli/src/server/const.rs @@ -1,29 +1,29 @@ /// The error message returned when the server is not yet ready. -pub const ERROR_SERVER_NOT_READY: &str = "server not ready"; +pub(crate) const ERROR_SERVER_NOT_READY: &str = "server not ready"; /// The error message returned when the global state is initialized twice. -pub const ERROR_GLOBAL_STATE_ALREADY_INITIALIZED: &str = "Global state already initialized"; +pub(crate) const ERROR_GLOBAL_STATE_ALREADY_INITIALIZED: &str = "Global state already initialized"; /// The suffix appended to the JS bridge stem to address the wasm binary. -pub const WASM_FILE_SUFFIX: &str = "_bg.wasm"; +pub(crate) const WASM_FILE_SUFFIX: &str = "_bg.wasm"; /// The error message for a failed read of a custom `index.html` template. -pub const ERROR_READ_CUSTOM_INDEX_HTML: &str = "Failed to read custom index.html"; +pub(crate) const ERROR_READ_CUSTOM_INDEX_HTML: &str = "Failed to read custom index.html"; /// The error message for a custom `index.html` that is not valid UTF-8. -pub const ERROR_CUSTOM_INDEX_HTML_NOT_UTF8: &str = "Custom index.html is not valid UTF-8"; +pub(crate) const ERROR_CUSTOM_INDEX_HTML_NOT_UTF8: &str = "Custom index.html is not valid UTF-8"; /// The error message for a failed creation of the static serving directory. -pub const ERROR_CREATE_STATIC_DIRECTORY: &str = "Failed to create static directory"; +pub(crate) const ERROR_CREATE_STATIC_DIRECTORY: &str = "Failed to create static directory"; /// The error message for a failed write of the generated `index.html`. -pub const ERROR_WRITE_INDEX_HTML: &str = "Failed to write index.html"; +pub(crate) const ERROR_WRITE_INDEX_HTML: &str = "Failed to write index.html"; /// The route parameter name carrying the requested static-asset path. -pub const ROUTE_PARAM_PATH: &str = "path"; +pub(crate) const ROUTE_PARAM_PATH: &str = "path"; /// The HTTP scheme prefix. -pub const HTTP_SCHEME: &str = "http"; +pub(crate) const HTTP_SCHEME: &str = "http"; /// The Windows UNC path prefix. -pub const WINDOWS_UNC_PREFIX: &str = r"\\?\"; +pub(crate) const WINDOWS_UNC_PREFIX: &str = r"\\?\"; diff --git a/cli/src/server/mod.rs b/cli/src/server/mod.rs index 08ce201a..08953aa8 100644 --- a/cli/src/server/mod.rs +++ b/cli/src/server/mod.rs @@ -4,8 +4,8 @@ mod r#impl; mod r#static; mod r#struct; -pub use {r#const::*, r#fn::*}; +pub use r#fn::*; -pub(crate) use {r#static::*, r#struct::*}; +pub(crate) use {r#const::*, r#static::*, r#struct::*}; use super::*; diff --git a/cli/tests/build/fn.rs b/cli/tests/build/fn.rs new file mode 100644 index 00000000..ba66a0ab --- /dev/null +++ b/cli/tests/build/fn.rs @@ -0,0 +1,392 @@ +use super::*; + +const CRATE_PATH_ARG: &str = "--crate-path"; +const DEV_FLAG: &str = "--dev"; +const DOUBLE_DASH: &str = "--"; +const OUT_DIR_ARG: &str = "--out-dir"; +const OUT_NAME_ARG: &str = "--out-name"; +const PORT_ARG: &str = "--port"; +const PROFILING_FLAG: &str = "--profiling"; +const RELEASE_FLAG: &str = "--release"; +const WWW_DIR_ARG: &str = "--www-dir"; + +fn args_from(extra: &[&str]) -> ModeArgs { + let mut argv: Vec<&str> = vec!["euv"]; + argv.extend_from_slice(extra); + ModeArgs::parse_from(argv) +} + +fn owned(items: &[&str]) -> Vec { + items.iter().map(|s: &&str| s.to_string()).collect() +} + +#[test] +fn a_build_mode_flag_is_detected_by_any_of_its_three_spellings() { + assert!(has_build_mode_flag(&owned(&[DEV_FLAG]))); + assert!(has_build_mode_flag(&owned(&[RELEASE_FLAG]))); + assert!(has_build_mode_flag(&owned(&[PROFILING_FLAG]))); +} + +#[test] +fn a_list_without_a_build_mode_flag_reports_none() { + assert!(!has_build_mode_flag(&owned(&[]))); + assert!(!has_build_mode_flag(&owned(&[ + "--out-dir", + "pkg", + "--target", + "web" + ]))); + assert!( + !has_build_mode_flag(&owned(&["--developer"])), + "a flag that merely starts with --dev is not --dev" + ); +} + +#[test] +fn filtering_drops_each_euv_flag_together_with_the_token_after_it() { + let filtered: Vec = filter_euv_args(&owned(&[ + "--target", + "web", + PORT_ARG, + "8080", + CRATE_PATH_ARG, + "/tmp/crate", + ])); + assert_eq!( + filtered, + vec!["--target".to_string(), "web".to_string()], + "a detached euv flag takes the next token with it" + ); +} + +#[test] +fn filtering_lets_the_wasm_pack_flags_that_look_similar_through() { + let filtered: Vec = + filter_euv_args(&owned(&[OUT_DIR_ARG, "pkg", OUT_NAME_ARG, "game"])); + assert_eq!( + filtered, + vec![ + OUT_DIR_ARG.to_string(), + "pkg".to_string(), + OUT_NAME_ARG.to_string(), + "game".to_string() + ], + "--out-dir and --out-name are wasm-pack's own, not euv's, so they must pass through" + ); +} + +#[test] +fn filtering_skips_the_token_after_an_euv_flag_even_when_it_looks_like_another_flag() { + let filtered: Vec = filter_euv_args(&owned(&["--features", PORT_ARG, RELEASE_FLAG])); + assert_eq!( + filtered, + vec!["--features".to_string()], + "the skip is unconditional: a flag following an euv flag is eaten as its value" + ); +} + +#[test] +fn filtering_drops_an_inline_assignment_because_the_flag_name_matches() { + let filtered: Vec = + filter_euv_args(&owned(&[&format!("{PORT_ARG}=8080"), RELEASE_FLAG])); + assert_eq!( + filtered, + vec![RELEASE_FLAG.to_string()], + "the whitelist compares the flag NAME, so `--port=8080` matches EUV_ARGS and the consumed value never leaks to wasm-pack" + ); +} + +#[test] +fn filtering_only_considers_the_arguments_after_the_last_separator() { + let filtered: Vec = filter_euv_args(&owned(&[ + "--target", + "web", + DOUBLE_DASH, + "--no-opt", + "--features", + "a,b", + ])); + assert_eq!( + filtered, + vec![ + "--no-opt".to_string(), + "--features".to_string(), + "a,b".to_string() + ], + "everything before the last -- is not part of the forwarded set" + ); +} + +#[test] +fn filtering_with_no_separator_and_no_euv_args_returns_the_input_unchanged() { + let filtered: Vec = filter_euv_args(&owned(&["--target", "web", "--release"])); + assert_eq!( + filtered, + vec![ + "--target".to_string(), + "web".to_string(), + "--release".to_string() + ] + ); +} + +#[test] +fn a_build_mode_flag_maps_to_its_own_wasm_pack_spelling() { + assert_eq!(build_mode_to_flag(BuildMode::Dev), DEV_FLAG); + assert_eq!(build_mode_to_flag(BuildMode::Release), RELEASE_FLAG); + assert_eq!(build_mode_to_flag(BuildMode::Profiling), PROFILING_FLAG); +} + +#[test] +fn profiling_outranks_release_and_release_outranks_dev() { + let both: ModeArgs = args_from(&[DEV_FLAG, RELEASE_FLAG, PROFILING_FLAG]); + assert_eq!(resolve_build_mode(&both), BuildMode::Profiling); + let dev_and_release: ModeArgs = args_from(&[DEV_FLAG, RELEASE_FLAG]); + assert_eq!(resolve_build_mode(&dev_and_release), BuildMode::Release); + let dev_only: ModeArgs = args_from(&[DEV_FLAG]); + assert_eq!(resolve_build_mode(&dev_only), BuildMode::Dev); +} + +#[test] +fn a_build_mode_forwarded_inside_wasm_pack_args_still_resolves() { + let forwarded: ModeArgs = args_from(&[RELEASE_FLAG]); + assert_eq!( + resolve_build_mode(&forwarded), + BuildMode::Release, + "trailing_var_arg swallows the flag, but the mode must still be found" + ); + let forwarded_profiling: ModeArgs = args_from(&[PROFILING_FLAG]); + assert_eq!( + resolve_build_mode(&forwarded_profiling), + BuildMode::Profiling + ); +} + +#[test] +fn with_no_mode_flag_anywhere_the_build_defaults_to_dev() { + let plain: ModeArgs = args_from(&["--target", "web"]); + assert_eq!(resolve_build_mode(&plain), BuildMode::Dev); + let bare: ModeArgs = args_from(&[]); + assert_eq!(resolve_build_mode(&bare), BuildMode::Dev); +} + +#[test] +fn the_output_dir_defaults_to_a_pkg_folder_inside_the_www_dir() { + let args: ModeArgs = args_from(&[]); + let out_dir: PathBuf = resolve_out_dir(&args); + assert!(out_dir.ends_with("www/pkg"), "got {out_dir:?}"); +} + +#[test] +fn an_explicit_out_dir_is_honoured_in_both_spellings() { + let spaced: ModeArgs = args_from(&[OUT_DIR_ARG, "dist"]); + let out_dir: PathBuf = resolve_out_dir(&spaced); + assert!(out_dir.ends_with("dist"), "got {out_dir:?}"); + let inline: ModeArgs = args_from(&[&format!("{OUT_DIR_ARG}=dist2")]); + assert!(resolve_out_dir(&inline).ends_with("dist2")); +} + +#[test] +fn an_absolute_out_dir_is_not_joined_onto_the_crate_path() { + let args: ModeArgs = args_from(&[OUT_DIR_ARG, "/tmp/euv-pkg"]); + let out_dir: PathBuf = resolve_out_dir(&args); + assert!(out_dir.is_absolute(), "got {out_dir:?}"); + assert_eq!(out_dir, PathBuf::from("/tmp/euv-pkg")); +} + +#[test] +fn an_out_name_becomes_a_js_filename_with_hyphens_swapped_for_underscores() { + let args: ModeArgs = args_from(&[OUT_NAME_ARG, "my-game"]); + assert_eq!(resolve_out_name(&args), "my_game.js"); + let inline: ModeArgs = args_from(&[&format!("{OUT_NAME_ARG}=other-game")]); + assert_eq!(resolve_out_name(&inline), "other_game.js"); +} + +#[test] +fn without_an_out_name_the_crate_directory_name_becomes_the_filename() { + let args: ModeArgs = args_from(&["--crate-path", "/tmp/some-crate"]); + assert_eq!( + resolve_out_name(&args), + "some_crate.js", + "with no Cargo.toml to read, the directory name is the fallback" + ); +} + +#[test] +fn the_import_path_points_at_the_js_file_relative_to_the_serving_root() { + let args: ModeArgs = args_from(&[]); + let import: String = resolve_import_path(&args); + assert!( + import.starts_with("./") || import.starts_with("../"), + "an import path is always relative, got {import}" + ); + assert!( + import.ends_with(".js"), + "and it must land on the generated script, got {import}" + ); + assert!( + !import.contains('\\'), + "the separator is always forward slashes, got {import}" + ); +} + +#[test] +fn the_serving_route_prefix_is_the_served_directory_relative_to_the_crate() { + let args: ModeArgs = args_from(&[]); + let prefix: String = resolve_serving_route_prefix(&args); + assert_eq!( + prefix, "www", + "with the default layout the served root is the www directory itself" + ); +} + +fn io_error() -> io::Error { + io::Error::new(io::ErrorKind::NotFound, "no such file") +} + +fn utf8_error() -> FromUtf8Error { + match String::from_utf8(vec![0xff, 0xfe]) { + Ok(_) => panic!("the invalid bytes must not decode"), + Err(error) => error, + } +} + +#[test] +fn an_io_error_display_joins_the_message_and_the_cause() { + let error: EuvError = EuvError::Io { + message: String::from("reading the manifest"), + error: io_error(), + }; + assert_eq!( + error.to_string(), + "reading the manifest: no such file", + "the message alone is not actionable without the cause" + ); +} + +#[test] +fn an_io_path_error_display_quotes_the_path() { + let error: EuvError = EuvError::IoPath { + message: String::from("writing the output"), + path: PathBuf::from("/tmp/pkg/artifact.wasm"), + error: io_error(), + }; + assert_eq!( + error.to_string(), + "writing the output '/tmp/pkg/artifact.wasm': no such file", + "the path is the part the user has to act on, so it is quoted" + ); +} + +#[test] +fn a_utf8_error_display_joins_the_message_and_the_cause() { + let error: EuvError = EuvError::Utf8 { + message: String::from("decoding the class manifest"), + error: utf8_error(), + }; + let rendered: String = error.to_string(); + assert!( + rendered.starts_with("decoding the class manifest: "), + "got {rendered}" + ); +} + +#[test] +fn the_string_only_variants_display_verbatim() { + assert_eq!( + EuvError::Server(String::from("the server refused the port")).to_string(), + "the server refused the port" + ); + assert_eq!( + EuvError::Message(String::from("nothing to do")).to_string(), + "nothing to do" + ); +} + +#[test] +fn the_source_is_the_cause_when_there_is_one_and_none_otherwise() { + let io_kind: EuvError = EuvError::Io { + message: String::from("ctx"), + error: io_error(), + }; + let path_kind: EuvError = EuvError::IoPath { + message: String::from("ctx"), + path: PathBuf::from("/tmp"), + error: io_error(), + }; + let utf8_kind: EuvError = EuvError::Utf8 { + message: String::from("ctx"), + error: utf8_error(), + }; + for error in [io_kind, path_kind, utf8_kind] { + assert!( + error.source().is_some(), + "{error} must hand the caller the underlying cause" + ); + } + assert!( + EuvError::Server(String::from("ctx")).source().is_none(), + "a bare message has no cause to expose" + ); + assert!(EuvError::Message(String::from("ctx")).source().is_none()); +} + +#[test] +fn reconciling_moves_a_post_separator_www_dir_into_the_typed_field() { + let mut args: ModeArgs = args_from(&[DOUBLE_DASH, WWW_DIR_ARG, "custom"]); + let before: PathBuf = resolve_out_dir(&args); + assert!( + before.ends_with("www/pkg"), + "after a `--` clap stops parsing, so the typed field still holds its default, got {before:?}" + ); + reconcile_args(&mut args); + let after: PathBuf = resolve_out_dir(&args); + assert!( + after.ends_with("custom/pkg"), + "the post-separator --www-dir must reach the field resolve_out_dir reads, got {after:?}" + ); +} + +#[test] +fn reconciling_is_a_no_op_for_a_www_dir_clap_already_parsed() { + let mut args: ModeArgs = args_from(&[WWW_DIR_ARG, "custom"]); + let before: PathBuf = resolve_out_dir(&args); + assert!(before.ends_with("custom/pkg")); + reconcile_args(&mut args); + assert_eq!( + resolve_out_dir(&args), + before, + "an already-parsed flag must survive reconciliation untouched" + ); +} + +#[test] +fn reconciling_leaves_an_empty_queue_untouched() { + let mut args: ModeArgs = args_from(&[]); + let before: PathBuf = resolve_out_dir(&args); + reconcile_args(&mut args); + assert_eq!(resolve_out_dir(&args), before); +} + +#[test] +fn the_pkg_directory_is_the_resolved_out_directory() { + let args: ModeArgs = args_from(&[]); + assert_eq!( + resolve_pkg_dir(&args), + resolve_out_dir(&args), + "the server serves the same directory the build writes to" + ); +} + +#[test] +fn the_banner_prints_for_every_action() { + print_banner(Action::Run); + print_banner(Action::Build); +} + +#[test] +fn a_banner_action_carries_no_payload() { + let run: Action = Action::Run; + let build: Action = Action::Build; + assert_ne!(run, build, "the two actions must stay distinguishable"); +} diff --git a/cli/tests/build/mod.rs b/cli/tests/build/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/build/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/cli/tests/build_args/fn.rs b/cli/tests/build_args/fn.rs new file mode 100644 index 00000000..04abdd76 --- /dev/null +++ b/cli/tests/build_args/fn.rs @@ -0,0 +1,471 @@ +use super::*; + +fn args(values: &[&str]) -> Vec { + values.iter().map(|v: &&str| (*v).to_string()).collect() +} + +#[test] +fn an_empty_argv_has_no_build_mode_flag() { + let observed: bool = has_build_mode_flag(&[] as &[String]); + assert!(!observed, "nothing to find means nothing found"); +} + +#[test] +fn the_dev_flag_is_a_build_mode_flag() { + let observed: bool = has_build_mode_flag(&args(&["--dev"])); + assert!(observed, "--dev selects a build mode"); +} + +#[test] +fn the_release_flag_is_a_build_mode_flag() { + let observed: bool = has_build_mode_flag(&args(&["--release"])); + assert!(observed, "--release selects a build mode"); +} + +#[test] +fn the_profiling_flag_is_a_build_mode_flag() { + let observed: bool = has_build_mode_flag(&args(&["--profiling"])); + assert!(observed, "--profiling selects a build mode"); +} + +#[test] +fn a_build_mode_flag_is_found_among_other_args() { + let observed: bool = + has_build_mode_flag(&args(&["--target", "web", "--release", "--out-dir", "x"])); + assert!( + observed, + "the search scans the whole list, not just the first entry" + ); +} + +#[test] +fn an_unknown_flag_is_not_a_build_mode_flag() { + let observed: bool = has_build_mode_flag(&args(&["--target", "web"])); + assert!(!observed, "unrelated wasm-pack flags do not count"); +} + +#[test] +fn a_flag_that_merely_contains_a_build_mode_word_does_not_count() { + let observed: bool = has_build_mode_flag(&args(&["--release-please"])); + assert!( + !observed, + "the match is exact, so a longer flag name is a different flag" + ); +} + +#[test] +fn build_mode_to_flag_round_trips_each_variant() { + assert_eq!(build_mode_to_flag(BuildMode::Dev), "--dev"); + assert_eq!(build_mode_to_flag(BuildMode::Release), "--release"); + assert_eq!(build_mode_to_flag(BuildMode::Profiling), "--profiling"); +} + +#[test] +fn every_build_mode_produces_a_distinct_flag() { + let dev: &str = build_mode_to_flag(BuildMode::Dev); + let release: &str = build_mode_to_flag(BuildMode::Release); + let profiling: &str = build_mode_to_flag(BuildMode::Profiling); + assert!( + dev != release && release != profiling && dev != profiling, + "the three modes must not collapse onto one flag" + ); +} + +#[test] +fn every_emitted_flag_is_recognised_as_a_build_mode_flag() { + for mode in [BuildMode::Dev, BuildMode::Release, BuildMode::Profiling] { + let flag: &str = build_mode_to_flag(mode); + let observed: bool = has_build_mode_flag(&args(&[flag])); + assert!(observed, "{flag} must be detectable as a build mode flag"); + } +} + +#[test] +fn args_after_the_double_dash_are_the_ones_kept() { + let observed: Vec = filter_euv_args(&args(&["--dev", "--", "--target", "web"])); + assert_eq!( + observed, + args(&["--target", "web"]), + "wasm-pack flags before the separator are euv's own and are dropped" + ); +} + +#[test] +fn a_valued_euv_flag_consumes_its_value() { + let observed: Vec = filter_euv_args(&args(&["--port", "3000", "--target", "web"])); + assert_eq!( + observed, + args(&["--target", "web"]), + "the flag and the argument after it are both removed" + ); +} + +#[test] +fn an_equals_form_euv_flag_is_recognised_by_the_whitelist() { + let observed: Vec = filter_euv_args(&args(&["--port=3000", "--target", "web"])); + assert_eq!( + observed, + args(&["--target", "web"]), + "the whitelist compares the flag NAME, so the = form matches too and the \ + argument leaves the wasm-pack command line entirely. Got {observed:?}" + ); +} + +#[test] +fn the_last_double_dash_wins_as_the_separator() { + let observed: Vec = filter_euv_args(&args(&["--", "build", "--", "--target", "web"])); + assert_eq!( + observed, + args(&["--target", "web"]), + "rposition finds the final separator, so earlier tokens are dropped" + ); +} + +#[test] +fn args_with_no_separator_pass_through_unchanged() { + let observed: Vec = filter_euv_args(&args(&["--target", "web", "--release"])); + assert_eq!( + observed, + args(&["--target", "web", "--release"]), + "without a separator nothing is stripped" + ); +} + +#[test] +fn filtering_an_empty_argv_yields_nothing() { + let observed: Vec = filter_euv_args(&[] as &[String]); + assert!(observed.is_empty(), "nothing in, nothing out"); +} + +#[test] +fn a_trailing_euv_flag_does_not_eat_the_end_of_the_list() { + let observed: Vec = filter_euv_args(&args(&["--target", "web", "--port"])); + assert_eq!( + observed, + args(&["--target", "web"]), + "a value flag with no value after it simply drops itself" + ); +} + +#[test] +fn a_short_euv_flag_is_stripped_as_well() { + let observed: Vec = filter_euv_args(&args(&["-p", "8080", "--target", "web"])); + assert_eq!( + observed, + args(&["--target", "web"]), + "the short forms are on the same whitelist as the long ones" + ); +} + +#[test] +fn a_flag_outside_the_whitelist_is_left_for_wasm_pack() { + let observed: Vec = filter_euv_args(&args(&["--out-dir", "dist", "--target", "web"])); + assert_eq!( + observed, + args(&["--out-dir", "dist", "--target", "web"]), + "only the seven whitelisted euv args are stripped, wasm-pack keeps the rest" + ); +} + +#[test] +fn exported_functions_are_collected_in_source_order() { + let source: &str = "export function alpha() {}\nexport function beta() {}\n"; + let observed: Vec = extract_exported_function_names(source); + assert_eq!(observed, args(&["alpha", "beta"]), "both names, in order"); +} + +#[test] +fn leading_indentation_does_not_hide_an_exported_function() { + let source: &str = " export function indented() {}\n"; + let observed: Vec = extract_exported_function_names(source); + assert_eq!(observed, args(&["indented"]), "the line is trimmed first"); +} + +#[test] +fn a_private_function_is_not_collected() { + let source: &str = "function hidden() {}\nexport function shown() {}\n"; + let observed: Vec = extract_exported_function_names(source); + assert_eq!(observed, args(&["shown"]), "only the exported one counts"); +} + +#[test] +fn an_arrow_function_export_is_not_a_function_declaration() { + let source: &str = "export const arrow = () => {};\n"; + let observed: Vec = extract_exported_function_names(source); + assert!( + observed.is_empty(), + "the scanner looks for the function declaration form only" + ); +} + +#[test] +fn dollar_signs_are_part_of_an_exported_name() { + let source: &str = "export function $helper() {}\n"; + let observed: Vec = extract_exported_function_names(source); + assert_eq!( + observed, + args(&["$helper"]), + "$ is a valid identifier character" + ); +} + +#[test] +fn a_name_ends_at_the_first_non_identifier_character() { + let source: &str = "export function typed(a: number) {}\n"; + let observed: Vec = extract_exported_function_names(source); + assert_eq!(observed, args(&["typed"]), "the parameter list is cut off"); +} + +#[test] +fn source_without_exports_yields_nothing() { + let observed: Vec = extract_exported_function_names("const x = 1;\n"); + assert!(observed.is_empty(), "no export means no names"); +} + +#[test] +fn a_star_import_is_a_namespace_import() { + let observed: bool = is_namespace_import("* as helpers"); + assert!(observed, "a leading star marks a namespace import"); +} + +#[test] +fn a_named_import_is_not_a_namespace_import() { + let observed: bool = is_namespace_import("{ a, b }"); + assert!(!observed, "a brace list is a named import"); +} + +#[test] +fn a_namespace_import_recovers_its_alias() { + let observed: Option<&str> = extract_namespace_alias("* as helpers"); + assert_eq!(observed, Some("helpers"), "the alias after as is the name"); +} + +#[test] +fn a_namespace_alias_tolerates_trailing_syntax() { + let observed: Option<&str> = extract_namespace_alias("* as helpers;"); + assert_eq!(observed, Some("helpers"), "a trailing semicolon is trimmed"); +} + +#[test] +fn a_namespace_alias_of_a_bare_star_is_none() { + let observed: Option<&str> = extract_namespace_alias("*"); + assert_eq!(observed, None, "there is no alias to report"); +} + +#[test] +fn a_named_import_has_no_namespace_alias() { + let observed: Option<&str> = extract_namespace_alias("{ a }"); + assert_eq!(observed, None, "a brace list is not a namespace import"); +} + +#[test] +fn a_relative_import_reports_its_specifier() { + let observed: Option<&str> = extract_import_spec("{ a } from './local.js'"); + assert_eq!(observed, Some("./local.js"), "the quoted path is unwrapped"); +} + +#[test] +fn a_parent_relative_import_reports_its_specifier() { + let observed: Option<&str> = extract_import_spec("{ a } from '../up.js'"); + assert_eq!( + observed, + Some("../up.js"), + "parent-relative paths count too" + ); +} + +#[test] +fn a_bare_package_import_is_not_reported() { + let observed: Option<&str> = extract_import_spec("{ a } from 'react'"); + assert_eq!(observed, None, "a package specifier is not a local path"); +} + +#[test] +fn an_import_without_a_from_clause_is_not_reported() { + let observed: Option<&str> = extract_import_spec("import './side-effect.js'"); + assert_eq!(observed, None, "a bare side-effect import names nothing"); +} + +#[test] +fn double_quoted_specifiers_are_unwrapped_too() { + let observed: Option<&str> = extract_import_spec("{ a } from \"./local.js\""); + assert_eq!( + observed, + Some("./local.js"), + "both quote styles are accepted" + ); +} + +fn argv(items: &[&str]) -> Vec { + items + .iter() + .map(|item: &&str| (*item).to_string()) + .collect() +} + +#[test] +fn a_space_separated_euv_flag_drops_itself_and_its_value() { + let observed: Vec = filter_euv_args(&argv(&["--port", "3000", "--target", "web"])); + assert_eq!( + observed, + vec!["--target".to_string(), "web".to_string()], + "both the euv flag and the value it owns must leave the wasm-pack command line" + ); +} + +#[test] +fn an_equals_form_euv_flag_is_dropped_whole() { + let observed: Vec = filter_euv_args(&argv(&["--port=3000", "--target=web"])); + assert_eq!( + observed, + vec!["--target=web".to_string()], + "`--port=3000` carries its own value, so dropping the flag means dropping the whole \ + argument — forwarding it would hand wasm-pack a flag it does not know" + ); +} + +#[test] +fn every_euv_flag_is_dropped_in_both_spellings() { + for (spaced, equals) in [ + (vec!["--crate-path", "/tmp/app"], "--crate-path=/tmp/app"), + (vec!["-c", "/tmp/app"], "-c=/tmp/app"), + (vec!["--www-dir", "static"], "--www-dir=static"), + ( + vec!["--index-html", "custom.html"], + "--index-html=custom.html", + ), + (vec!["--no-gitignore"], "--no-gitignore=true"), + ] { + let mut spaced_args: Vec = spaced.iter().map(|s: &&str| (*s).to_string()).collect(); + spaced_args.push(String::from("--target")); + spaced_args.push(String::from("web")); + let spaced_out: Vec = filter_euv_args(&spaced_args); + assert_eq!( + spaced_out, + vec!["--target".to_string(), "web".to_string()], + "{spaced:?} must be removed in its spaced spelling" + ); + + let mut equals_args: Vec = vec![equals.to_string()]; + equals_args.push(String::from("--target")); + equals_args.push(String::from("web")); + let equals_out: Vec = filter_euv_args(&equals_args); + assert_eq!( + equals_out, + vec!["--target".to_string(), "web".to_string()], + "{equals} must be removed in its equals spelling too" + ); + } +} + +#[test] +fn a_wasm_pack_flag_containing_an_equals_sign_survives() { + let observed: Vec = filter_euv_args(&argv(&["--out-dir=dist", "--scope=my-pkg"])); + assert_eq!( + observed, + vec!["--out-dir=dist".to_string(), "--scope=my-pkg".to_string()], + "a wasm-pack flag is not an euv flag just because it uses the same syntax" + ); +} + +#[test] +fn a_value_that_merely_contains_an_equals_sign_is_not_dropped() { + let observed: Vec = filter_euv_args(&argv(&["--scope", "a=b", "--target", "web"])); + assert_eq!( + observed, + vec![ + "--scope".to_string(), + "a=b".to_string(), + "--target".to_string(), + "web".to_string() + ], + "the value of a wasm-pack flag is data, not a flag, and must survive" + ); +} + +#[test] +fn everything_after_the_last_separator_is_the_only_candidate_list() { + let observed: Vec = filter_euv_args(&argv(&[ + "--port", "3000", "--", "--port", "4000", "--target", "web", + ])); + assert_eq!( + observed, + vec!["--target".to_string(), "web".to_string()], + "the earlier euv flags were already parsed by clap; only the post-`--` run is filtered" + ); +} + +#[test] +fn a_non_euv_flag_before_the_separator_is_not_filtered() { + let observed: Vec = filter_euv_args(&argv(&["--port", "3000", "--release"])); + assert_eq!( + observed, + vec!["--release".to_string()], + "with no separator the whole list is filtered, so a wasm-pack flag is untouched" + ); +} + +#[test] +fn filtering_an_empty_list_yields_an_empty_list() { + let observed: Vec = filter_euv_args(&[]); + assert!(observed.is_empty()); +} + +#[test] +fn a_lone_separator_contributes_nothing() { + let observed: Vec = filter_euv_args(&argv(&["--", "--target", "web"])); + assert_eq!(observed, vec!["--target".to_string(), "web".to_string()]); +} + +#[test] +fn reconcile_reads_the_equals_form_into_the_typed_field() { + let mut args: ModeArgs = ModeArgs::parse_from(["euv", "--", "--port=4321"]); + reconcile_args(&mut args); + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains("port: 4321"), + "the equals spelling must reach the typed field, got: {rendered}" + ); +} + +#[test] +fn a_filtered_equals_form_leaves_nothing_for_wasm_pack_to_misread() { + let passthrough: Vec = argv(&["--www-dir=static", "--target=web"]); + let remaining: Vec = filter_euv_args(&passthrough); + assert_eq!( + remaining, + vec!["--target=web".to_string()], + "after filtering the euv flag must be gone from the wasm-pack command line" + ); +} + +#[test] +fn a_boolean_euv_flag_does_not_swallow_the_argument_after_it() { + let observed: Vec = filter_euv_args(&argv(&["--no-gitignore", "--target", "web"])); + assert_eq!( + observed, + vec!["--target".to_string(), "web".to_string()], + "`--no-gitignore` is a bare boolean with no value, so skipping the next \ + argument would silently drop a real wasm-pack flag" + ); +} + +#[test] +fn a_boolean_euv_flag_at_the_end_of_the_list_is_harmless() { + let observed: Vec = filter_euv_args(&argv(&["--target", "web", "--no-gitignore"])); + assert_eq!( + observed, + vec!["--target".to_string(), "web".to_string()], + "a trailing boolean has nothing after it to consume" + ); +} + +#[test] +fn two_boolean_flags_in_a_row_both_leave() { + let observed: Vec = filter_euv_args(&argv(&["--no-gitignore", "--release"])); + assert_eq!( + observed, + vec!["--release".to_string()], + "each boolean is independent; neither may consume the other" + ); +} diff --git a/cli/tests/build_args/mod.rs b/cli/tests/build_args/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/build_args/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/cli/tests/cli_api/const.rs b/cli/tests/cli_api/const.rs new file mode 100644 index 00000000..08700f35 --- /dev/null +++ b/cli/tests/cli_api/const.rs @@ -0,0 +1,3 @@ +pub const UNFORMATTED: &str = "fn view() -> String { html!{
hi
} }"; + +pub const FORMATTED: &str = "fn view() -> String { html! {\n
hi
\n} }\n"; diff --git a/cli/tests/cli_api/fn.rs b/cli/tests/cli_api/fn.rs new file mode 100644 index 00000000..0b6d7d94 --- /dev/null +++ b/cli/tests/cli_api/fn.rs @@ -0,0 +1,1253 @@ +use super::*; + +fn scratch(tag: &str) -> PathBuf { + let dir: PathBuf = env::temp_dir() + .join("euv-cli-api-tests") + .join(format!("{tag}-{}", process::id())); + let _: io::Result<()> = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + dir +} + +fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + let _: io::Result<()> = fs::create_dir_all(parent); + } + let mut file: fs::File = fs::File::create(path).expect("scratch file"); + let _: io::Result<()> = io::Write::write_all(&mut file, contents.as_bytes()); +} + +#[test] +fn the_dev_template_fetches_the_same_path_the_router_registers() { + assert!( + r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + + +"#.contains("/__euv_reload"), + "the dev template hardcodes the reload URL instead of using \ + __RELOAD_ROUTE__, so the JS and the registered route must \ + stay the same string or live reload silently 404s" + ); +} + +#[test] +fn only_the_dev_template_carries_the_reload_script() { + assert!( + !r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + +"#.contains("/__euv_reload"), + "a release build must ship no live-reload instrumentation at all" + ); + assert!( + r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + + +"#.contains("connect()"), + "the dev template is the one that must own the reconnect loop" + ); + assert!( + !r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + +"#.contains("connect()"), + "a reconnect loop in a release build would poll a server that no longer exists" + ); +} + +#[test] +fn both_templates_still_carry_the_two_live_placeholders() { + for template in [ + r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + + +"#, + r#" + + + + + + + + + + + + Euv + __EUV_BASE_HREF_TAG__ + + + +
+ + + +"#, + ] { + assert!( + template.contains("__EUV_BASE_HREF_TAG__"), + "a missing __EUV_BASE_HREF_TAG__ means the base href tag is never injected" + ); + assert!( + template.contains("__EUV_INLINE_JS__"), + "a missing __EUV_INLINE_JS__ means the wasm bridge is never inlined" + ); + } +} + +#[test] +fn the_four_placeholders_are_mutually_distinct() { + let all: [&str; 4] = [ + "__IMPORT_PATH__", + "__RELOAD_ROUTE__", + "__EUV_INLINE_JS__", + "__EUV_BASE_HREF_TAG__", + ]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!( + all[i], all[j], + "two placeholders sharing a token would make the replace chain order-dependent" + ); + } + } +} + +#[test] +fn the_reload_route_is_an_absolute_path() { + assert!( + "/__euv_reload".starts_with("/"), + "the route is registered verbatim, so it must be absolute" + ); +} + +#[test] +fn the_project_layout_names_match_what_cargo_and_git_actually_use() { + assert_eq!("/__euv_reload", "/__euv_reload"); + assert_eq!("__RELOAD_ROUTE__", "__RELOAD_ROUTE__"); +} + +#[test] +fn the_path_helpers_use_forward_slashes_for_url_construction() { + assert_eq!("/", "/"); + assert_eq!("/", "/"); + assert_eq!("./", "./"); + assert_eq!("..", ".."); + assert_eq!('/', '/'); + assert_eq!('\\', '\\'); +} + +#[test] +fn the_euv_arguments_parse_from_the_same_flags_they_are_named_after() { + let full: Vec = vec![ + "euv".to_string(), + "-c".to_string(), + "/tmp/app".to_string(), + "-p".to_string(), + "1234".to_string(), + "--www-dir".to_string(), + "www".to_string(), + "--index-html".to_string(), + "custom.html".to_string(), + "--no-gitignore".to_string(), + ]; + let parsed: ModeArgs = ModeArgs::parse_from(full); + let rendered: String = format!("{parsed:?}"); + assert!( + rendered.contains("/tmp/app"), + "-c must reach crate_path, got: {rendered}" + ); + assert!( + rendered.contains("1234"), + "-p must reach port, got: {rendered}" + ); + assert!( + rendered.contains("custom.html"), + "--index-html must land in the template field, got: {rendered}" + ); + assert!( + rendered.contains("true"), + "--no-gitignore is a bare flag, so it must parse to true, got: {rendered}" + ); +} + +#[test] +fn the_banner_action_names_are_the_clap_subcommand_names() { + assert_eq!("run", "run"); + assert_eq!("build", "build"); + let parsed: Mode = Mode::parse_from(["euv", "build"]); + assert!( + matches!(parsed, Mode::Build(_)), + "build must be the literal subcommand clap dispatches on" + ); +} + +#[test] +fn the_inline_bridge_opt_out_is_an_env_var_name_not_a_value() { + assert_eq!("EUV_NO_INLINE_BRIDGE", "EUV_NO_INLINE_BRIDGE"); + assert!( + !"EUV_NO_INLINE_BRIDGE".contains('='), + "a name carrying '=' would be read as an assignment, not as presence" + ); +} + +#[test] +fn the_server_error_messages_are_distinct_and_non_empty() { + let messages: [&str; 7] = [ + "server not ready", + "Global state already initialized", + "Failed to read custom index.html", + "Custom index.html is not valid UTF-8", + "Failed to create static directory", + "Failed to write index.html", + "Failed to read", + ]; + for message in messages { + assert!( + !message.is_empty(), + "an empty error message tells the user nothing" + ); + } + for i in 0..messages.len() { + for j in (i + 1)..messages.len() { + assert_ne!( + messages[i], messages[j], + "two failures sharing a message cannot be told apart in a log" + ); + } + } +} + +#[test] +fn the_fmt_error_messages_are_distinct_and_non_empty() { + let messages: [&str; 4] = [ + "Failed to read directory", + "Failed to read entry in directory", + "Failed to read", + "Failed to write", + ]; + for i in 0..messages.len() { + for j in (i + 1)..messages.len() { + assert_ne!(messages[i], messages[j], "duplicate fmt failure message"); + } + } +} + +#[test] +fn the_mode_error_messages_name_the_flag_they_are_about() { + assert!( + "Invalid crate-path".contains("--crate-path".trim_start_matches('-')), + "the message should echo --crate-path so the user knows which flag failed" + ); +} + +#[test] +fn the_log_separators_are_the_ones_the_format_string_uses() { + assert_eq!(" ", " "); + assert_eq!(":", ":"); +} + +#[test] +fn the_windows_unc_prefix_is_the_documented_four_character_form() { + assert_eq!(r"\\?\", r"\\?\"); + assert_eq!(r"\\?\".chars().count(), 4); +} + +#[test] +fn the_walk_skips_exactly_the_two_generated_directories() { + assert_eq!("target", "target"); + assert_eq!("node_modules", "node_modules"); + assert_ne!("target", "node_modules"); + assert_eq!("rs", "rs"); + assert!( + "view.rs".ends_with("rs"), + "the extension is compared without a dot, so a dot here would never match" + ); +} + +#[test] +fn the_fmt_keyword_letters_are_the_distinct_letters_the_detector_looks_for() { + let letters: [char; 13] = [ + 'a', 'c', 'e', 'f', 'h', 'i', 'l', 'm', 'n', 'o', 'r', 's', 't', + ]; + for i in 0..letters.len() { + assert!( + letters[i].is_ascii_lowercase(), + "{:?} is not a lowercase ASCII letter", + letters[i] + ); + for j in (i + 1)..letters.len() { + assert_ne!( + letters[i], letters[j], + "a repeated keyword letter means one branch of the detector is dead" + ); + } + } +} + +#[test] +fn fmt_mode_defaults_to_check_so_a_bare_fmt_call_never_rewrites() { + assert_eq!( + FmtMode::default(), + FmtMode::Check, + "the safe mode must be the default, not the destructive one" + ); +} + +#[test] +fn fmt_mode_orders_check_before_write() { + assert!( + FmtMode::Check < FmtMode::Write, + "the declaration order is the semantic order, and a mode must never sort before the default" + ); +} + +#[test] +fn fmt_mode_is_hashable_so_it_can_key_a_memo() { + let modes: HashSet = [FmtMode::Check, FmtMode::Write, FmtMode::Check] + .into_iter() + .collect(); + assert_eq!(modes.len(), 2, "the duplicate Check must collapse"); +} + +#[test] +fn the_build_mode_default_is_dev() { + assert_eq!( + BuildMode::default(), + BuildMode::Dev, + "a crate with no profile flag must get a debug build" + ); +} + +#[test] +fn the_three_build_modes_are_mutually_distinct() { + let all: [BuildMode; 3] = [BuildMode::Dev, BuildMode::Release, BuildMode::Profiling]; + for i in 0..all.len() { + for j in (i + 1)..all.len() { + assert_ne!(all[i], all[j]); + } + } +} + +#[test] +fn an_action_is_copy_so_it_can_be_logged_after_the_mode_consumes_it() { + let action: Action = Action::Run; + let copied: Action = action; + assert_eq!(copied, action, "Action is Copy, so a use must not move it"); + assert_ne!(Action::Run, Action::Build); +} + +#[test] +fn a_successful_reload_serialises_as_a_bare_tagged_object() { + let encoded: String = serde_json::to_string(&ReloadEvent::Reload).expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Reload"}"#, + "the dev template branches on data.type === 'Reload', so the tag must not change" + ); +} + +#[test] +fn a_failed_reload_serialises_its_message_under_the_same_tag() { + let encoded: String = serde_json::to_string(&ReloadEvent::Error("build failed".to_string())) + .expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Error","message":"build failed"}"#, + "the dev template reads data.message, so the field name must not change" + ); +} + +#[test] +fn a_reload_error_message_is_escaped_before_it_reaches_the_client() { + let encoded: String = + serde_json::to_string(&ReloadEvent::Error("expected \";\" at line 3".to_string())) + .expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Error","message":"expected \";\" at line 3"}"#, + "the message is interpolated into the client console, so quotes must not break the JSON" + ); +} + +#[test] +fn an_empty_reload_error_still_carries_its_tag() { + let encoded: String = + serde_json::to_string(&ReloadEvent::Error(String::new())).expect("serialisable"); + assert_eq!( + encoded, r#"{"type":"Error","message":""}"#, + "a compiler error with no detail must still be routed to the error branch" + ); +} + +#[test] +fn a_reload_event_is_only_ever_produced_never_consumed() { + let encoded: String = serde_json::to_string(&ReloadEvent::Reload).expect("serialisable"); + assert!( + !encoded.contains("message"), + "the Reload branch carries no payload, so the client sees no undefined field" + ); +} + +#[test] +fn the_mode_subcommands_dispatch_to_their_own_argument_type() { + let run: Mode = Mode::parse_from(["euv", "run", "--port", "9000"]); + match &run { + Mode::Run(args) => { + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains("9000"), + "--port must reach the Run variant's ModeArgs, got: {rendered}" + ); + } + other => panic!("expected Run, got {other:?}"), + } + + let fmt: Mode = Mode::parse_from(["euv", "fmt", "--check"]); + match &fmt { + Mode::Fmt(args) => { + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains("check: true"), + "--check must reach FmtArgs, got: {rendered}" + ); + } + other => panic!("expected Fmt, got {other:?}"), + } +} + +#[test] +fn the_cli_root_tolerates_no_subcommand() { + let cli: Result = Cli::try_parse_from(["euv"]); + assert!( + cli.is_err(), + "running euv with no subcommand must print usage, not guess a mode" + ); +} + +#[test] +fn the_cli_root_carries_the_parsed_subcommand() { + let cli: Cli = Cli::parse_from(["euv", "run"]); + assert!( + format!("{cli:?}").contains("Run"), + "the root must carry the dispatched subcommand, got: {cli:?}" + ); +} + +#[test] +fn a_bare_argv_lands_on_the_documented_defaults() { + let args: ModeArgs = ModeArgs::parse_from(["euv"]); + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains("port: 80"), + "an unset --port must stay on 80, got: {rendered}" + ); + assert!( + rendered.contains("www_dir: \"www\""), + "an unset --www-dir must stay on www, got: {rendered}" + ); + assert!( + rendered.contains("crate_path: \".\""), + "an unset --crate-path must stay on the cwd, got: {rendered}" + ); + assert!( + rendered.contains("wasm_pack_args: []"), + "nothing after -- means nothing to forward, got: {rendered}" + ); + assert!( + !rendered.contains("dev: true") + && !rendered.contains("release: true") + && !rendered.contains("profiling: true"), + "no profile flag means no build mode, got: {rendered}" + ); +} + +#[test] +fn a_wasm_pack_passthrough_keeps_its_flags_verbatim() { + let args: ModeArgs = ModeArgs::parse_from([ + "euv", + "--", + "--release", + "--no-typescript", + "--scope", + "my-pkg", + ]); + let rendered: String = format!("{args:?}"); + assert!( + rendered.contains( + "wasm_pack_args: [\"--release\", \"--no-typescript\", \"--scope\", \"my-pkg\"]" + ), + "everything after -- belongs to wasm-pack, spelled exactly as the user typed it, got: {rendered}" + ); +} + +fn mode_args(argv: &[&str]) -> ModeArgs { + let mut full: Vec = vec!["euv".to_string()]; + for arg in argv { + full.push((*arg).to_string()); + } + ModeArgs::parse_from(full) +} + +fn fmt_args(path: &Path, check: bool) -> FmtArgs { + let mut full: Vec = vec![ + "euv".to_string(), + "--path".to_string(), + path.to_string_lossy().to_string(), + ]; + if check { + full.push("--check".to_string()); + } + FmtArgs::parse_from(full) +} + +#[tokio::test] +async fn a_www_dir_holding_index_html_is_returned_as_is() { + let root: PathBuf = scratch("www-direct"); + write_file(&root.join("index.html"), ""); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, root, + "the directory already has an index.html, so there is nothing to search for" + ); +} + +#[tokio::test] +async fn a_www_dir_whose_child_shares_its_name_is_unwrapped() { + let outer: PathBuf = scratch("www-nested"); + let root: PathBuf = outer.join("www"); + let nested: PathBuf = root.join("www"); + write_file(&nested.join("index.html"), ""); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, nested, + "wasm-pack emits www/www/pkg, and the unwrap step exists for exactly that shape" + ); +} + +#[tokio::test] +async fn a_www_dir_with_index_html_wins_over_the_unwrap() { + let outer: PathBuf = scratch("www-both"); + let root: PathBuf = outer.join("www"); + write_file(&root.join("index.html"), "outer"); + write_file(&root.join("www").join("index.html"), "inner"); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, root, + "when both levels have an index.html the shallower one must win" + ); +} + +#[tokio::test] +async fn a_www_dir_with_no_index_html_anywhere_is_returned_unchanged() { + let root: PathBuf = scratch("www-missing"); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!( + observed, root, + "with no index.html to find, the caller still needs a usable directory" + ); +} + +#[tokio::test] +async fn a_missing_www_dir_is_not_an_error() { + let root: PathBuf = scratch("www-absent").join("nope"); + let observed: PathBuf = resolve_www_dir(&root).await; + assert_eq!(observed, root, "resolution must degrade, never panic"); +} + +#[tokio::test] +async fn cleaning_removes_files_and_subdirectories_but_keeps_the_root() { + let root: PathBuf = scratch("clean"); + write_file(&root.join("stale.js"), "// stale"); + write_file(&root.join("pkg").join("deep").join("x_bg.wasm"), "binary"); + let sub: PathBuf = root.join("pkg"); + assert!(sub.is_dir()); + clean_out_dir(&root).await; + assert!( + root.is_dir(), + "the output directory itself must survive a clean" + ); + assert_eq!( + fs::read_dir(&root).expect("root still readable").count(), + 0, + "a clean must leave nothing behind, not even nested directories" + ); +} + +#[tokio::test] +async fn cleaning_a_missing_directory_is_a_no_op() { + let root: PathBuf = scratch("clean-absent").join("nope"); + clean_out_dir(&root).await; + assert!( + !root.exists(), + "a clean must not create what was never there" + ); +} + +#[tokio::test] +async fn cleaning_twice_is_idempotent() { + let root: PathBuf = scratch("clean-twice"); + write_file(&root.join("a.js"), "x"); + clean_out_dir(&root).await; + clean_out_dir(&root).await; + assert_eq!(fs::read_dir(&root).expect("root readable").count(), 0); +} + +#[tokio::test] +async fn a_serving_root_inside_the_www_dir_is_the_www_dir() { + let root: PathBuf = scratch("serving-inside"); + write_file(&root.join("index.html"), ""); + let args: ModeArgs = mode_args(&["--crate-path", &root.to_string_lossy()]); + let observed: PathBuf = resolve_serving_root(&args).await; + assert_eq!( + observed, + root.join("www"), + "index.html and the wasm artifacts share one root only when out_dir sits under www" + ); +} + +#[tokio::test] +async fn a_serving_root_outside_the_www_dir_is_the_out_dir_parent() { + let root: PathBuf = scratch("serving-outside"); + let out: PathBuf = root.join("build").join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + let observed: PathBuf = resolve_serving_root(&args).await; + assert_eq!( + observed, + out.parent().expect("out dir has a parent").to_path_buf(), + "an out_dir outside www needs its own root so the two trees stay co-located" + ); +} + +#[tokio::test] +async fn fmt_check_reports_a_file_that_needs_formatting() { + let root: PathBuf = scratch("fmt-check-dirty"); + let file: PathBuf = root.join("view.rs"); + write_file(&file, UNFORMATTED); + let result: Result<(), EuvError> = fmt_mode(fmt_args(&file, true)).await; + let error: EuvError = result.expect_err("check mode must fail on unformatted source"); + let rendered: String = format!("{error}"); + assert!( + rendered.contains("needs formatting"), + "the user needs the file name and the verdict, got: {rendered}" + ); +} + +#[tokio::test] +async fn fmt_check_leaves_the_file_untouched() { + let root: PathBuf = scratch("fmt-check-clean"); + let file: PathBuf = root.join("clean.rs"); + let original: &str = "fn main() {}\n"; + write_file(&file, original); + let result: Result<(), EuvError> = fmt_mode(fmt_args(&file, true)).await; + let _: () = result.expect("already-formatted source must pass check mode"); + assert_eq!( + fs::read_to_string(&file).expect("read back"), + original, + "check mode must never write" + ); +} + +#[tokio::test] +async fn fmt_write_rewrites_the_file_in_place() { + let root: PathBuf = scratch("fmt-write"); + let file: PathBuf = root.join("view.rs"); + write_file(&file, UNFORMATTED); + let result: Result<(), EuvError> = fmt_mode(fmt_args(&file, false)).await; + let _: () = result.expect("write mode must succeed on a writable file"); + let after: String = fs::read_to_string(&file).expect("read back"); + assert_ne!( + after, UNFORMATTED, + "write mode must have rewritten the file" + ); + assert!( + after.contains("
hi
"), + "the macro body must survive the rewrite, got: {after}" + ); +} + +#[tokio::test] +async fn fmt_write_is_idempotent() { + let root: PathBuf = scratch("fmt-write-twice"); + let file: PathBuf = root.join("view.rs"); + write_file(&file, UNFORMATTED); + let _: () = fmt_mode(fmt_args(&file, false)).await.expect("first write"); + let after_first: String = fs::read_to_string(&file).expect("read back"); + let _: () = fmt_mode(fmt_args(&file, false)) + .await + .expect("second write"); + assert_eq!( + fs::read_to_string(&file).expect("read back"), + after_first, + "formatting a formatted file must be a no-op, or fmt would thrash git" + ); +} + +#[tokio::test] +async fn fmt_accepts_a_relative_path_resolved_against_the_cwd() { + let result: Result<(), EuvError> = fmt_mode(FmtArgs::new(PathBuf::from("."), true)).await; + let _: Result<(), EuvError> = result; +} + +#[tokio::test] +async fn format_dir_reaches_a_nested_source_file() { + let root: PathBuf = scratch("fmt-nested"); + let file: PathBuf = root.join("page").join("view").join("fn.rs"); + write_file(&file, UNFORMATTED); + let _: () = format_dir(&root, FmtMode::Write) + .await + .expect("walk succeeds"); + assert_ne!( + fs::read_to_string(&file).expect("read back"), + UNFORMATTED, + "the walker must descend into nested directories" + ); +} + +#[tokio::test] +async fn format_dir_ignores_non_rust_files() { + let root: PathBuf = scratch("fmt-ext"); + let source: PathBuf = root.join("view.rs"); + let other: PathBuf = root.join("notes.md"); + write_file(&source, UNFORMATTED); + write_file(&other, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&root, FmtMode::Check).await; + let error: EuvError = result.expect_err("the unformatted .rs file must be reported"); + assert_eq!( + format!("{error}"), + "1 file(s) need formatting. Run `euv fmt` to fix.", + "a .md file in the tree must not be counted or rewritten" + ); +} + +#[tokio::test] +async fn format_dir_skips_the_build_output_directory() { + let root: PathBuf = scratch("fmt-skip-target"); + let generated: PathBuf = root.join("target").join("fn.rs"); + write_file(&generated, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&root, FmtMode::Check).await; + let _: () = result.expect("build output must be left alone, not flagged"); + assert_eq!( + fs::read_to_string(&generated).expect("read back"), + UNFORMATTED, + "a generated file under target must not be rewritten" + ); +} + +#[tokio::test] +async fn format_dir_skips_the_node_modules_directory() { + let root: PathBuf = scratch("fmt-skip-node"); + let vendored: PathBuf = root.join("node_modules").join("fn.rs"); + write_file(&vendored, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&root, FmtMode::Check).await; + let _: () = result.expect("node_modules must be left alone, not flagged"); + assert_eq!( + fs::read_to_string(&vendored).expect("read back"), + UNFORMATTED, + "a vendored file under node_modules must not be rewritten" + ); +} + +#[tokio::test] +async fn format_dir_on_a_single_file_touches_only_that_file() { + let root: PathBuf = scratch("fmt-single"); + let dirty: PathBuf = root.join("dirty.rs"); + let clean: PathBuf = root.join("clean.rs"); + write_file(&dirty, UNFORMATTED); + write_file(&clean, FORMATTED); + let result: Result<(), EuvError> = format_dir(&dirty, FmtMode::Check).await; + let _: EuvError = result.expect_err("the dirty file must be reported"); + assert_eq!( + fs::read_to_string(&clean).expect("read back"), + FORMATTED, + "a file path must not be treated as a directory to walk" + ); +} + +#[tokio::test] +async fn format_dir_reports_a_single_file_by_its_own_name() { + let root: PathBuf = scratch("fmt-single-name"); + let dirty: PathBuf = root.join("dirty.rs"); + write_file(&dirty, UNFORMATTED); + let result: Result<(), EuvError> = format_dir(&dirty, FmtMode::Check).await; + let error: EuvError = result.expect_err("the dirty file must be reported"); + let rendered: String = format!("{error}"); + assert!( + rendered.contains("dirty.rs"), + "a single-file check must name the file, got: {rendered}" + ); + assert!( + !rendered.contains("file(s)"), + "a single-file check must not use the directory-wide plural wording, got: {rendered}" + ); +} + +#[test] +fn the_banner_accepts_both_actions_without_a_format_argument() { + print_banner(Action::Run); + print_banner(Action::Build); +} + +#[test] +fn logger_init_sets_the_global_max_level() { + Logger::init(log::LevelFilter::Warn); + assert_eq!( + log::max_level(), + log::LevelFilter::Warn, + "init must apply the level filter even when a logger is already installed" + ); + Logger::init(log::LevelFilter::Info); + assert_eq!(log::max_level(), log::LevelFilter::Info); +} + +#[test] +fn the_global_logger_is_a_zero_sized_singleton() { + assert_eq!( + mem::size_of_val(&Logger), + 0, + "Logger is a unit struct, so the static costs nothing at runtime" + ); +} + +#[test] +fn the_io_error_keeps_its_message_path_and_cause() { + let cause: io::Error = io::Error::new(io::ErrorKind::NotFound, "boom"); + let path: PathBuf = PathBuf::from("/tmp/missing"); + let error: EuvError = EuvError::IoPath { + message: "Invalid crate-path".to_string(), + path: path.clone(), + error: cause, + }; + let rendered: String = format!("{error}"); + assert!( + rendered.contains("Invalid crate-path"), + "the human message must survive, got: {rendered}" + ); + assert!( + rendered.contains("missing"), + "the path must survive, got: {rendered}" + ); + assert!( + rendered.contains("boom"), + "the underlying cause must survive, got: {rendered}" + ); +} + +#[test] +fn the_utf8_error_keeps_its_message_and_cause() { + let invalid: Vec = vec![0xF0, 0x28, 0x8C, 0x28]; + let cause: FromUtf8Error = String::from_utf8(invalid).expect_err("bytes are not utf-8"); + let error: EuvError = EuvError::Utf8 { + message: "Custom index.html is not valid UTF-8".to_string(), + error: cause, + }; + let rendered: String = format!("{error}"); + assert!( + rendered.contains("Custom index.html is not valid UTF-8"), + "{rendered}" + ); +} + +#[test] +fn a_bare_message_error_renders_verbatim() { + let error: EuvError = EuvError::Message("something went wrong".to_string()); + assert_eq!(format!("{error}"), "something went wrong"); +} + +#[test] +fn a_server_error_renders_its_payload() { + let error: EuvError = EuvError::Server("server not ready".to_string()); + assert_eq!(format!("{error}"), "server not ready"); +} + +#[tokio::test] +async fn a_build_that_cannot_create_its_out_dir_names_the_offending_path() { + let root: PathBuf = scratch("build-outdir-blocked"); + let blocker: PathBuf = root.join("blocker"); + write_file(&blocker, "x"); + let out: PathBuf = blocker.join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + let result: Result<(), EuvError> = build_wasm(&args).await; + match result { + Err(EuvError::IoPath { + message, + path, + error, + }) => { + assert!( + message.contains("output directory"), + "the message must say which step failed, got {message:?}" + ); + assert_eq!( + path, out, + "the error must carry the directory it could not create" + ); + assert_eq!( + error.kind(), + io::ErrorKind::NotADirectory, + "a regular file standing where a directory belongs is what makes this fail" + ); + } + Err(other) => panic!("expected a path-carrying io error, got {other:?}"), + Ok(()) => panic!("building under a regular file must not succeed"), + } +} + +#[tokio::test] +async fn a_build_only_pipeline_stops_at_the_blocked_out_dir() { + let root: PathBuf = scratch("build-only-outdir-blocked"); + let blocker: PathBuf = root.join("blocker"); + write_file(&blocker, "x"); + let out: PathBuf = blocker.join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + let result: Result<(), EuvError> = run_build_only_pipeline(&args).await; + assert!( + matches!(result, Err(EuvError::IoPath { .. })), + "a pipeline that cannot lay down its output must not report success" + ); +} + +#[tokio::test] +async fn a_full_pipeline_still_emits_html_when_the_build_fails() { + let root: PathBuf = scratch("full-pipeline-build-fails"); + let out: PathBuf = root.join("build").join("pkg"); + let args: ModeArgs = mode_args(&[ + "--crate-path", + &root.to_string_lossy(), + "--", + "--out-dir", + &out.to_string_lossy(), + ]); + match run_build_pipeline(&args, None).await { + Ok(html) => { + assert!( + !html.is_empty(), + "a failed build still has to leave the dev server something to serve" + ); + let produced: Vec = fs::read_dir(&out) + .map(|entries: fs::ReadDir| { + entries + .filter_map(|entry: io::Result| entry.ok()) + .map(|entry: fs::DirEntry| entry.path()) + .collect() + }) + .unwrap_or_default(); + assert!( + produced.is_empty(), + "the build left artifacts behind, so this never exercised the failure \ + path and proves nothing: {produced:?}" + ); + } + Err(other) => panic!( + "a failed build is swallowed on purpose so the dev server still has a page; \ + surfacing {other:?} here means the pipeline no longer does that" + ), + } +} diff --git a/cli/tests/cli_api/mod.rs b/cli/tests/cli_api/mod.rs new file mode 100644 index 00000000..3e893750 --- /dev/null +++ b/cli/tests/cli_api/mod.rs @@ -0,0 +1,6 @@ +mod r#const; +mod r#fn; + +use r#const::*; + +use super::*; diff --git a/cli/tests/mod.rs b/cli/tests/mod.rs index 746114a2..cf9d677c 100644 --- a/cli/tests/mod.rs +++ b/cli/tests/mod.rs @@ -1,5 +1,24 @@ +mod build; +mod build_args; +mod cli_api; mod fmt; mod hmr; mod inline; +mod mode_args; +mod run_mode; +mod server; +mod serving_path; -use euv_cli::*; +use std::{ + collections::HashSet, + env, + env::temp_dir, + fs, + fs::{create_dir_all, remove_dir_all, write}, + io, mem, + path::{Path, PathBuf}, + process, + string::FromUtf8Error, +}; + +use {clap::Parser, euv_cli::*}; diff --git a/cli/tests/mode_args/fn.rs b/cli/tests/mode_args/fn.rs new file mode 100644 index 00000000..f1bc8c53 --- /dev/null +++ b/cli/tests/mode_args/fn.rs @@ -0,0 +1,439 @@ +use super::*; + +fn mode_args(argv: &[&str]) -> ModeArgs { + let mut full: Vec = vec!["euv".to_string()]; + for arg in argv { + full.push((*arg).to_string()); + } + ModeArgs::parse_from(full) +} + +fn debug_of(args: &ModeArgs) -> String { + format!("{args:?}") +} + +fn field(args: &ModeArgs, name: &str) -> String { + let rendered: String = debug_of(args); + let needle: String = format!("{name}: "); + let start: usize = match rendered.find(&needle) { + Some(value) => value + needle.len(), + None => return String::new(), + }; + let rest: &str = &rendered[start..]; + if rest.starts_with('[') { + return match rest.find(']') { + Some(value) => rest[..=value].trim().to_string(), + None => rest.trim().to_string(), + }; + } + let end: usize = match rest.find([',', '}']) { + Some(value) => value, + None => rest.len(), + }; + rest[..end].trim().to_string() +} + +#[test] +fn an_empty_argv_takes_every_documented_default() { + let args: ModeArgs = mode_args(&[]); + assert_eq!( + field(&args, "crate_path"), + "\".\"", + "the crate path defaults to the working directory" + ); + assert_eq!( + field(&args, "port"), + "80", + "the dev server defaults to port 80" + ); + assert_eq!( + field(&args, "www_dir"), + "\"www\"", + "the asset dir defaults to www" + ); + assert_eq!( + field(&args, "index_html"), + "None", + "no index override by default" + ); +} + +#[test] +fn no_build_flag_means_no_build_mode_requested() { + let args: ModeArgs = mode_args(&[]); + assert_eq!(field(&args, "dev"), "false", "dev is not implied"); + assert_eq!(field(&args, "release"), "false", "release is not implied"); + assert_eq!( + field(&args, "profiling"), + "false", + "profiling is not implied" + ); + assert_eq!( + field(&args, "no_gitignore"), + "false", + "the gitignore is kept by default" + ); +} + +#[test] +fn the_crate_path_flag_overrides_the_default() { + let args: ModeArgs = mode_args(&["--crate-path", "app"]); + assert_eq!(field(&args, "crate_path"), "\"app\"", "the long flag wins"); +} + +#[test] +fn the_short_crate_path_flag_is_accepted() { + let args: ModeArgs = mode_args(&["-c", "app"]); + assert_eq!( + field(&args, "crate_path"), + "\"app\"", + "the short form reaches the same field" + ); +} + +#[test] +fn the_port_flag_overrides_the_default() { + let args: ModeArgs = mode_args(&["--port", "3000"]); + assert_eq!(field(&args, "port"), "3000", "the port flag wins"); +} + +#[test] +fn the_www_dir_flag_overrides_the_default() { + let args: ModeArgs = mode_args(&["--www-dir", "assets"]); + assert_eq!( + field(&args, "www_dir"), + "\"assets\"", + "the www dir flag wins" + ); +} + +#[test] +fn the_index_html_flag_records_an_override() { + let args: ModeArgs = mode_args(&["--index-html", "start.html"]); + assert!( + field(&args, "index_html").contains("start.html"), + "the index override is recorded when supplied, got {}", + field(&args, "index_html") + ); +} + +#[test] +fn each_build_flag_sets_exactly_its_own_booleans() { + let dev: ModeArgs = mode_args(&["--dev"]); + let release: ModeArgs = mode_args(&["--release"]); + let profiling: ModeArgs = mode_args(&["--profiling"]); + assert_eq!(field(&dev, "dev"), "true", "--dev sets dev alone"); + assert_eq!( + field(&dev, "release"), + "false", + "--dev leaves release alone" + ); + assert_eq!( + field(&release, "release"), + "true", + "--release sets release alone" + ); + assert_eq!( + field(&profiling, "profiling"), + "true", + "--profiling sets profiling alone" + ); + assert_eq!( + field(&profiling, "dev"), + "false", + "--profiling leaves dev alone" + ); +} + +#[test] +fn the_no_gitignore_flag_is_recorded() { + let args: ModeArgs = mode_args(&["--no-gitignore"]); + assert_eq!( + field(&args, "no_gitignore"), + "true", + "the flag turns the behaviour off" + ); +} + +#[test] +fn trailing_wasm_pack_args_are_captured_verbatim() { + let args: ModeArgs = mode_args(&["--", "--target", "web"]); + let rendered: String = field(&args, "wasm_pack_args"); + assert!( + rendered.contains("--target") && rendered.contains("web"), + "everything past the separator is forwarded, in order, got {rendered}" + ); + assert!( + !rendered.contains("--dev"), + "euv's own flags never leak into the wasm-pack list, got {rendered}" + ); +} + +#[test] +fn without_a_separator_the_wasm_pack_args_are_empty() { + let args: ModeArgs = mode_args(&["--dev"]); + assert_eq!( + field(&args, "wasm_pack_args"), + "[]", + "with no separator there is nothing to forward" + ); +} + +#[test] +fn no_build_flag_resolves_to_the_dev_build() { + let args: ModeArgs = mode_args(&[]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Dev, + "an unqualified invocation builds in dev mode" + ); +} + +#[test] +fn the_release_flag_resolves_to_the_release_build() { + let args: ModeArgs = mode_args(&["--release"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Release, + "--release wins" + ); +} + +#[test] +fn the_profiling_flag_resolves_to_the_profiling_build() { + let args: ModeArgs = mode_args(&["--profiling"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Profiling, + "--profiling wins" + ); +} + +#[test] +fn profiling_outranks_release_when_both_are_given() { + let args: ModeArgs = mode_args(&["--release", "--profiling"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Profiling, + "profiling is checked first, so it wins the tie" + ); +} + +#[test] +fn release_outranks_dev_when_both_are_given() { + let args: ModeArgs = mode_args(&["--dev", "--release"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Release, + "release is checked before dev, so it wins the tie" + ); +} + +#[test] +fn a_release_flag_inside_the_wasm_pack_args_still_resolves() { + let args: ModeArgs = mode_args(&["--", "--release"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Release, + "a build flag forwarded past the separator is still honoured" + ); +} + +#[test] +fn a_profiling_flag_inside_the_wasm_pack_args_still_resolves() { + let args: ModeArgs = mode_args(&["--", "--profiling"]); + assert_eq!( + resolve_build_mode(&args), + BuildMode::Profiling, + "the wasm-pack side is consulted too" + ); +} + +#[test] +fn a_parsed_argv_round_trips_through_the_out_name_resolver() { + let args: ModeArgs = mode_args(&["--crate-path", "."]); + let observed: String = resolve_out_name(&args); + assert!( + observed.ends_with(".js"), + "the out name is always a js bundle name, got {observed}" + ); +} + +#[test] +fn the_out_dir_defaults_under_the_asset_dir() { + let args: ModeArgs = mode_args(&[]); + let observed: PathBuf = resolve_out_dir(&args); + assert!( + observed.ends_with("www/pkg"), + "with no --out-dir the bundle lands under www/pkg, got {observed:?}" + ); +} + +#[test] +fn the_out_dir_flag_moves_the_bundle() { + let args: ModeArgs = mode_args(&["--", "--out-dir", "dist"]); + let observed: PathBuf = resolve_out_dir(&args); + assert!( + observed.ends_with("dist"), + "an explicit --out-dir is honoured, got {observed:?}" + ); +} + +#[test] +fn a_relative_out_dir_is_anchored_to_the_crate_path() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist"]); + let observed: PathBuf = resolve_out_dir(&args); + assert_eq!( + observed, + PathBuf::from("app/dist"), + "a relative out dir is resolved against the crate, not the cwd" + ); +} + +#[test] +fn an_absolute_out_dir_is_left_alone() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "/tmp/bundle"]); + let observed: PathBuf = resolve_out_dir(&args); + assert_eq!( + observed, + PathBuf::from("/tmp/bundle"), + "an absolute path is not re-anchored" + ); +} + +#[test] +fn a_www_dir_inside_the_crate_yields_a_relative_route_prefix() { + let args: ModeArgs = mode_args(&["--crate-path", "app"]); + let observed: String = resolve_serving_route_prefix(&args); + assert_eq!( + observed, "www", + "the default www bundle under the crate serves from the asset dir itself" + ); +} + +#[test] +fn an_out_dir_outside_www_yields_its_parent_as_the_prefix() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist/pkg"]); + let observed: String = resolve_serving_route_prefix(&args); + assert_eq!( + observed, "dist", + "serving from outside www means serving the out dir's parent" + ); +} + +#[test] +fn the_route_prefix_is_empty_when_the_out_dir_is_at_the_crate_root() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "pkg"]); + let observed: String = resolve_serving_route_prefix(&args); + assert_eq!( + observed, "", + "a bundle sitting at the crate root is served from the root itself" + ); +} + +#[test] +fn reconcile_promotes_a_forwarded_crate_path_onto_the_field() { + let mut args: ModeArgs = mode_args(&["--", "--crate-path", "app"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\"app\"", + "a value past the separator wins over the clap default" + ); +} + +#[test] +fn reconcile_accepts_the_equals_form_of_a_forwarded_flag() { + let mut args: ModeArgs = mode_args(&["--", "--crate-path=app"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\"app\"", + "the = form is recognised here, same as in filter_euv_args" + ); +} + +#[test] +fn reconcile_ignores_a_port_that_is_not_a_number() { + let mut args: ModeArgs = mode_args(&["--", "--port", "not-a-port"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "port"), + "80", + "an unparseable port leaves the default alone rather than zeroing it" + ); +} + +#[test] +fn reconcile_ignores_a_equals_port_that_is_not_a_number() { + let mut args: ModeArgs = mode_args(&["--", "--port=not-a-port"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "port"), + "80", + "the = form is equally guarded against a bad value" + ); +} + +#[test] +fn reconcile_promotes_the_forwarded_build_flags() { + let mut args: ModeArgs = mode_args(&["--", "--release", "--no-gitignore"]); + reconcile_args(&mut args); + assert_eq!(field(&args, "release"), "true", "--release is promoted"); + assert_eq!( + field(&args, "no_gitignore"), + "true", + "--no-gitignore is promoted" + ); +} + +#[test] +fn reconcile_leaves_unmentioned_fields_at_their_clap_defaults() { + let mut args: ModeArgs = mode_args(&["--", "--target", "web"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\".\"", + "an unrelated wasm-pack arg must not disturb the euv fields" + ); + assert_eq!(field(&args, "port"), "80", "nor the port"); +} + +#[test] +fn reconcile_applies_the_last_value_when_a_flag_is_repeated() { + let mut args: ModeArgs = mode_args(&["--", "--crate-path", "first", "--crate-path", "second"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "crate_path"), + "\"second\"", + "a repeated flag resolves to its last occurrence" + ); +} + +#[test] +fn a_reconciled_argv_drives_the_build_mode_the_same_way_as_the_direct_flag() { + let mut via_reconcile: ModeArgs = mode_args(&["--", "--release"]); + reconcile_args(&mut via_reconcile); + let direct: ModeArgs = mode_args(&["--release"]); + assert_eq!( + resolve_build_mode(&via_reconcile), + resolve_build_mode(&direct), + "a forwarded build flag must resolve identically to a direct one" + ); +} + +#[test] +fn a_reconciled_port_feeds_the_out_name_resolver() { + let mut args: ModeArgs = mode_args(&["--crate-path", ".", "--", "--port", "9000"]); + reconcile_args(&mut args); + assert_eq!( + field(&args, "port"), + "9000", + "the reconciled port is stored" + ); + let observed: String = resolve_out_name(&args); + assert!( + observed.ends_with(".js"), + "and the resolver still produces a bundle name, got {observed}" + ); +} diff --git a/cli/tests/mode_args/mod.rs b/cli/tests/mode_args/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/mode_args/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/cli/tests/run_mode/fn.rs b/cli/tests/run_mode/fn.rs new file mode 100644 index 00000000..9d11d711 --- /dev/null +++ b/cli/tests/run_mode/fn.rs @@ -0,0 +1,102 @@ +use super::*; + +fn absent_crate_path(tag: &str) -> PathBuf { + let candidate: PathBuf = env::temp_dir() + .join("euv-run-mode-tests") + .join(format!("{tag}-{}", process::id())); + let _: io::Result<()> = fs::remove_dir_all(&candidate); + assert!( + !candidate.exists(), + "the fixture path must be absent before the run, otherwise canonicalize would succeed" + ); + candidate +} + +#[tokio::test] +async fn a_crate_path_that_does_not_exist_fails_before_any_server_is_started() { + let missing: PathBuf = absent_crate_path("absent-crate"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + missing.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = run_mode(args).await; + + match outcome { + Err(EuvError::IoPath { path, .. }) => assert_eq!( + path, missing, + "the error must name the crate path that could not be resolved" + ), + Err(other) => panic!("a missing crate path must surface as an IoPath error, got {other:?}"), + Ok(()) => panic!("run_mode must not report success for a crate path that does not exist"), + } +} + +#[tokio::test] +async fn a_missing_crate_path_is_rejected_without_reaching_the_build_pipeline() { + let missing: PathBuf = absent_crate_path("absent-crate-nested"); + let nested: PathBuf = missing.join("does").join("not").join("exist"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + nested.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = run_mode(args).await; + + match outcome { + Err(EuvError::IoPath { path, .. }) => assert_eq!( + path, nested, + "the reported path must be the one the caller asked for, not the deepest existing prefix" + ), + Err(other) => panic!("a missing crate path must surface as an IoPath error, got {other:?}"), + Ok(()) => panic!("run_mode must not report success for a crate path that does not exist"), + } +} + +#[tokio::test] +async fn a_build_mode_crate_path_that_does_not_exist_fails_before_the_pipeline_starts() { + let missing: PathBuf = absent_crate_path("absent-build-crate"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + missing.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = build_mode(args).await; + + match outcome { + Err(EuvError::IoPath { path, .. }) => assert_eq!( + path, missing, + "build mode has to name the crate path it could not resolve, the same way run mode \ + does; a build that reports success while having built nothing is the failure this \ + error exists to prevent" + ), + Err(other) => panic!("a missing crate path must surface as an IoPath error, got {other:?}"), + Ok(()) => panic!("build_mode must not report success for a crate path that does not exist"), + } +} + +#[tokio::test] +async fn a_build_mode_crate_path_nested_under_a_missing_directory_is_also_rejected() { + let missing: PathBuf = absent_crate_path("absent-build-nested"); + let nested: PathBuf = missing.join("does").join("not").join("exist"); + + let args: ModeArgs = ModeArgs::parse_from(vec![ + String::from("euv"), + String::from("--crate-path"), + nested.to_string_lossy().to_string(), + ]); + + let outcome: Result<(), EuvError> = build_mode(args).await; + + assert!( + outcome.is_err(), + "a path whose parent does not exist cannot be canonicalized either, and it has to be \ + rejected here rather than part-way through a build that already wrote files" + ); +} diff --git a/cli/tests/run_mode/mod.rs b/cli/tests/run_mode/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/run_mode/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/cli/tests/server/fn.rs b/cli/tests/server/fn.rs new file mode 100644 index 00000000..cb696b94 --- /dev/null +++ b/cli/tests/server/fn.rs @@ -0,0 +1,88 @@ +use super::*; + +fn outside_file(name: &str, file: &str) -> PathBuf { + let root: PathBuf = temp_dir().join(name); + create_dir_all(&root).expect("outside dir"); + let target: PathBuf = root.join(file); + write(&target, "outside the served root").expect("outside file"); + target +} + +fn scratch_root(name: &str) -> PathBuf { + let root: PathBuf = temp_dir().join(format!("euv-cli-server-{name}")); + let _ = remove_dir_all(&root); + create_dir_all(root.join("assets")).expect("scratch root"); + create_dir_all(root.join("assets/nested")).expect("scratch assets"); + write(root.join("assets/index.html"), "").expect("scratch file"); + write(root.join("assets/nested/deep.txt"), "deep").expect("scratch file"); + root +} + +#[tokio::test] +async fn a_file_inside_the_base_directory_resolves() { + let root: PathBuf = scratch_root("inside"); + let resolved: Option = resolve_file_in_base(&root, "assets/index.html").await; + assert_eq!(resolved, Some(root.join("assets/index.html"))); +} + +#[tokio::test] +async fn a_nested_file_inside_the_base_directory_resolves() { + let root: PathBuf = scratch_root("nested"); + let resolved: Option = resolve_file_in_base(&root, "assets/nested/deep.txt").await; + assert_eq!(resolved, Some(root.join("assets/nested/deep.txt"))); +} + +#[tokio::test] +async fn a_path_that_climbs_one_level_out_of_the_base_is_refused() { + let victim: PathBuf = outside_file("euv-cli-server-victim-one", "secret.txt"); + let root: PathBuf = scratch_root("climb-one"); + let resolved: Option = + resolve_file_in_base(&root, "../euv-cli-server-victim-one/secret.txt").await; + assert!( + resolved.is_none(), + "the file really exists, so only the containment check can refuse it; got {resolved:?} \ + for victim {victim:?}" + ); +} + +#[tokio::test] +async fn a_path_that_climbs_two_levels_out_of_the_base_is_refused() { + let _victim: PathBuf = outside_file("euv-cli-server-victim-two", "secret.txt"); + let root: PathBuf = scratch_root("climb-two"); + let resolved: Option = + resolve_file_in_base(&root, "assets/../../euv-cli-server-victim-two/secret.txt").await; + assert!( + resolved.is_none(), + "a climb that passes back through the base's own subtree is still an escape; got {resolved:?}" + ); +} + +#[tokio::test] +async fn a_sibling_directory_whose_name_starts_with_the_base_is_refused() { + let _victim: PathBuf = outside_file("euv-cli-server-sibling-evil", "loot.txt"); + let root: PathBuf = scratch_root("sibling"); + let resolved: Option = + resolve_file_in_base(&root, "../euv-cli-server-sibling-evil/loot.txt").await; + assert!( + resolved.is_none(), + "a string prefix match is not containment; got {resolved:?}" + ); +} + +#[tokio::test] +async fn a_path_that_does_not_exist_is_refused() { + let root: PathBuf = scratch_root("missing"); + let resolved: Option = resolve_file_in_base(&root, "assets/nope.html").await; + assert!(resolved.is_none(), "got {resolved:?}"); +} + +#[tokio::test] +async fn a_directory_itself_resolves_because_it_exists() { + let root: PathBuf = scratch_root("dir"); + let resolved: Option = resolve_file_in_base(&root, "assets").await; + assert_eq!( + resolved, + Some(root.join("assets")), + "this guard checks containment, not that the target is a regular file" + ); +} diff --git a/cli/tests/server/mod.rs b/cli/tests/server/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/server/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/cli/tests/serving_path/fn.rs b/cli/tests/serving_path/fn.rs new file mode 100644 index 00000000..8ed507e9 --- /dev/null +++ b/cli/tests/serving_path/fn.rs @@ -0,0 +1,202 @@ +use super::*; + +fn mode_args(argv: &[&str]) -> ModeArgs { + let mut full: Vec = vec!["euv".to_string()]; + for arg in argv { + full.push((*arg).to_string()); + } + ModeArgs::parse_from(full) +} + +fn scratch(tag: &str) -> PathBuf { + let dir: PathBuf = env::temp_dir() + .join("euv-serving-path-tests") + .join(format!("{tag}-{}", process::id())); + let _: io::Result<()> = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("scratch dir"); + dir +} + +fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + let _: io::Result<()> = fs::create_dir_all(parent); + } + let mut file = fs::File::create(path).expect("scratch file"); + let _: io::Result<()> = io::Write::write_all(&mut file, contents.as_bytes()); +} + +#[test] +fn the_pkg_dir_is_wherever_the_out_dir_resolves_to() { + let args: ModeArgs = mode_args(&[]); + let observed: PathBuf = resolve_pkg_dir(&args); + assert_eq!( + observed, + resolve_out_dir(&args), + "resolve_pkg_dir is a thin alias over resolve_out_dir and must not drift" + ); +} + +#[test] +fn the_pkg_dir_follows_an_explicit_out_dir() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist"]); + let observed: PathBuf = resolve_pkg_dir(&args); + assert!( + observed.ends_with("dist"), + "an explicit out dir wins, got {observed:?}" + ); +} + +#[test] +fn the_import_path_points_at_the_bundle_from_the_serving_root() { + let args: ModeArgs = mode_args(&["--crate-path", "app"]); + let observed: String = resolve_import_path(&args); + assert!( + observed.starts_with("./"), + "a wasm import specifier is relative, got {observed}" + ); + assert!( + observed.ends_with(".js"), + "and names the generated bundle, got {observed}" + ); +} + +#[test] +fn the_import_path_of_a_sibling_bundle_never_climbs_out_of_the_serving_root() { + let args: ModeArgs = mode_args(&["--crate-path", "app", "--", "--out-dir", "dist/pkg"]); + let observed: String = resolve_import_path(&args); + assert!( + !observed.contains(".."), + "the serving root is chosen as an ancestor of the out dir, so the relative \ + specifier never has to climb out; got {observed}" + ); + assert!( + observed.starts_with("./"), + "and it is still relative, got {observed}" + ); + assert!( + observed.ends_with(".js"), + "and it still names the bundle, got {observed}" + ); +} + +#[test] +fn a_file_directly_inside_the_base_is_served() { + let dir: PathBuf = scratch("inside"); + let block: PathBuf = dir.join("block.js"); + write_file(&block, "export const x = 1;"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "block.js")); + match observed { + Some(path) => assert_eq!(path, block, "a file inside the base is served"), + None => panic!("a file inside the base must resolve"), + } + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_file_in_a_subdirectory_of_the_base_is_served() { + let dir: PathBuf = scratch("subdir"); + let block: PathBuf = dir.join("nested").join("deep.js"); + write_file(&block, "export const y = 2;"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "nested/deep.js")); + assert!( + observed.is_some(), + "a file below the base is still inside it" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_relative_escape_out_of_the_base_is_refused() { + let dir: PathBuf = scratch("escape"); + let secret: PathBuf = dir.parent().expect("parent").join("euv-secret.txt"); + write_file(&secret, "classified"); + let base: PathBuf = dir.join("public"); + let _: io::Result<()> = fs::create_dir_all(&base); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "../euv-secret.txt")); + assert_eq!( + observed, None, + "a ../ escape must never resolve, or the dev server leaks files above its root" + ); + let _: io::Result<()> = fs::remove_file(&secret); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_deep_relative_escape_is_refused() { + let dir: PathBuf = scratch("deep-escape"); + let secret: PathBuf = dir.join("secret.txt"); + write_file(&secret, "classified"); + let base: PathBuf = dir.join("a").join("b"); + let _: io::Result<()> = fs::create_dir_all(&base); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "../../secret.txt")); + assert_eq!( + observed, None, + "a multi-level climb out of the base is refused too" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_missing_file_is_not_served() { + let dir: PathBuf = scratch("missing"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "nothing-here.js")); + assert_eq!( + observed, None, + "a path that does not exist resolves to nothing" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn an_absolute_request_path_cannot_escape_the_base() { + let dir: PathBuf = scratch("absolute"); + let secret: PathBuf = dir.join("secret.txt"); + write_file(&secret, "classified"); + let base: PathBuf = dir.join("public"); + let _: io::Result<()> = fs::create_dir_all(&base); + let requested: String = secret.to_string_lossy().to_string(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, &requested)); + assert_eq!( + observed, None, + "an absolute path outside the base is refused even though it exists" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} + +#[test] +fn a_dot_segment_request_still_resolves_inside_the_base() { + let dir: PathBuf = scratch("dot-segment"); + let block: PathBuf = dir.join("block.js"); + write_file(&block, "export const z = 3;"); + let base: PathBuf = dir.clone(); + let observed: Option = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(resolve_file_in_base(&base, "./block.js")); + assert!( + observed.is_some(), + "a leading ./ stays inside the base and must not be treated as an escape" + ); + let _: io::Result<()> = fs::remove_dir_all(&dir); +} diff --git a/cli/tests/serving_path/mod.rs b/cli/tests/serving_path/mod.rs new file mode 100644 index 00000000..e41822a8 --- /dev/null +++ b/cli/tests/serving_path/mod.rs @@ -0,0 +1,3 @@ +mod r#fn; + +use super::*; diff --git a/core/src/event/handler/impl.rs b/core/src/event/handler/impl.rs index c45ef1f6..52c237df 100644 --- a/core/src/event/handler/impl.rs +++ b/core/src/event/handler/impl.rs @@ -30,7 +30,7 @@ impl NativeEventHandler { /// # Arguments /// /// - `Event` - The event to pass to the callback. - pub fn handle(&self, event: Event) { + pub(crate) fn handle(&self, event: Event) { let callback: &mut Box = unsafe { &mut *self.get_callback().get() }; callback(event); } diff --git a/core/src/event/handler/mod.rs b/core/src/event/handler/mod.rs index ea58b04a..7c96b2e5 100644 --- a/core/src/event/handler/mod.rs +++ b/core/src/event/handler/mod.rs @@ -2,8 +2,6 @@ mod r#impl; mod r#struct; mod r#type; -pub use r#struct::*; - -pub use r#type::*; +pub use {r#struct::*, r#type::*}; use super::*; diff --git a/core/src/event/handler/struct.rs b/core/src/event/handler/struct.rs index f9f1c8b6..a413871b 100644 --- a/core/src/event/handler/struct.rs +++ b/core/src/event/handler/struct.rs @@ -7,11 +7,15 @@ use super::*; /// borrow checking overhead in the single-threaded WASM context. /// The `Rc` provides automatic memory management (freed when last reference drops). #[derive(Clone, CustomDebug, Data, New)] +#[new(pub(crate))] pub struct NativeEventHandler { /// The name of the event (e.g., "click", "input"). #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) event_name: &'static str, /// Shared reference to the callback closure. /// `UnsafeCell` allows mutable access without RefCell overhead. @@ -20,5 +24,8 @@ pub struct NativeEventHandler { #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) callback: SharedEventCallback, } diff --git a/core/src/lib.rs b/core/src/lib.rs index e4be853d..07de4af6 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -18,6 +18,7 @@ pub use std::{ collections::{HashMap, HashSet, VecDeque}, fmt::{self, Debug, Display, Formatter}, hash::{Hash, Hasher}, + iter::Iterator, marker::PhantomData, mem::{swap, take, zeroed}, panic::{AssertUnwindSafe, catch_unwind}, @@ -27,22 +28,27 @@ pub use std::{ pub use {js_sys::*, lombok_macros::*, wasm_bindgen::prelude::*, web_sys::*}; -pub use bin_encode_decode::{Charset, EncodeError}; +pub(crate) use bin_encode_decode::{Charset, EncodeError}; /// The wall clock [`now_micros`] reads on a host build. On wasm the clock /// comes from JS instead, so importing this there is an `unused_imports` /// warning — the mirror of the dead-const warning the host build would give /// the other half of the same function. #[cfg(not(target_arch = "wasm32"))] -pub use std::time::{SystemTime, UNIX_EPOCH}; +pub(crate) use std::time::{SystemTime, UNIX_EPOCH}; pub(crate) use renderer::*; use std::{ any::Any, - cell::{Cell, Ref, RefCell, UnsafeCell}, + cell::{Cell, Ref, RefCell, RefMut, UnsafeCell}, num::ParseIntError, rc::Rc, - sync::atomic::{AtomicBool, AtomicUsize, Ordering}, + str::from_utf8, + sync::{ + PoisonError, + atomic::{AtomicBool, AtomicUsize, Ordering}, + }, + thread::AccessError, vec::Vec, }; diff --git a/core/src/noderef/impl.rs b/core/src/noderef/impl.rs index 2dd95566..f5f8d90d 100644 --- a/core/src/noderef/impl.rs +++ b/core/src/noderef/impl.rs @@ -34,9 +34,9 @@ impl NodeRef { /// /// Use this when you only need the underlying DOM element without /// caring about its concrete type (e.g., passing it to a third-party - /// JS interop function). For type-safe access, use [`get_cloned`]. + /// JS interop function). For type-safe access, use [`NodeRef::get_cloned`]. /// - /// [`get_cloned`]: NodeRef::get_cloned + /// [`NodeRef::get_cloned`]: NodeRef::get_cloned /// /// # Returns /// @@ -105,10 +105,10 @@ impl NodeRef { /// Clears the currently attached element, if any. /// /// Called by the renderer when a node is unmounted. After `clear`, - /// [`get`] and [`get_cloned`] both return `None` until the next + /// [`NodeRef::get`] and [`NodeRef::get_cloned`] both return `None` until the next /// `set` call. /// - /// [`get`]: NodeRef::get + /// [`NodeRef::get`]: NodeRef::get pub fn clear(&self) { let cell: *mut Option = self.get_inner_ref().get(); unsafe { @@ -128,7 +128,7 @@ impl NodeRef { } } -// Blanket impl over the unsized `web_sys::Node` is what most users want, +// Blanket impl over the unsized `Node` is what most users want, // but the macro passes a `JsValue` and the user chooses `T` per use site, // so we don't constrain `T` here — `get_cloned`'s `JsCast` bound is the // single point where the type check happens. diff --git a/core/src/reactive/cache/impl.rs b/core/src/reactive/cache/impl.rs index 27da00f4..0877ee17 100644 --- a/core/src/reactive/cache/impl.rs +++ b/core/src/reactive/cache/impl.rs @@ -159,8 +159,8 @@ where /// /// # Returns /// - /// - `impl std::iter::Iterator impl std::iter::Iterator { + /// - `impl Iterator` - An iterator over the entries in most-recently-used-first order. + pub fn iter(&self) -> impl Iterator { // We can't return the VecDeque order directly // because the entries would be in order-deque // order, not MRU-first order. Actually they @@ -177,8 +177,8 @@ where /// /// # Returns /// - /// - `impl std::iter::Iterator impl std::iter::Iterator { + /// - `impl Iterator` - An iterator over the keys in most-recently-used-first order. + pub fn keys(&self) -> impl Iterator { self.get_order().iter() } @@ -187,8 +187,8 @@ where /// /// # Returns /// - /// - `impl std::iter::Iterator impl std::iter::Iterator { + /// - `impl Iterator` - An iterator over the values in most-recently-used-first order. + pub fn values(&self) -> impl Iterator { self.get_order() .iter() .filter_map(|k: &K| self.get_map().get(k)) diff --git a/core/src/reactive/cache/mod.rs b/core/src/reactive/cache/mod.rs index d2a0a4e2..4dfc79f4 100644 --- a/core/src/reactive/cache/mod.rs +++ b/core/src/reactive/cache/mod.rs @@ -1,5 +1,6 @@ mod r#impl; mod r#struct; -use super::*; pub use r#struct::*; + +use super::*; diff --git a/core/src/reactive/cache/struct.rs b/core/src/reactive/cache/struct.rs index c19fbb0c..f67beaa7 100644 --- a/core/src/reactive/cache/struct.rs +++ b/core/src/reactive/cache/struct.rs @@ -39,14 +39,23 @@ where /// The maximum number of entries before eviction /// kicks in. #[get(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) capacity: usize, /// The current entries, keyed by K. Default-initialised /// via `#[new(skip)]` (`HashMap::new()`). #[new(skip)] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) map: HashMap, /// The MRU-first order. Front = most recently used, /// back = least recently used. Default-initialised /// via `#[new(skip)]` (`VecDeque::new()`). #[new(skip)] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) order: VecDeque, } diff --git a/core/src/reactive/cast/impl.rs b/core/src/reactive/cast/impl.rs index 5a43a8eb..6d9009c6 100644 --- a/core/src/reactive/cast/impl.rs +++ b/core/src/reactive/cast/impl.rs @@ -198,8 +198,15 @@ impl From for AttributeValue { /// # Arguments /// /// - `NativeEventHandler` - Input value to convert from. + /// + /// This is the base case of the `NativeEventHandler` -> + /// `AttrValueAdapter` -> `NativeEventHandler` + /// conversion cycle. Calling `.into()` on the adapter instead would + /// land back in this impl through the generic + /// `From>` (which unwraps and calls `T::into()`), + /// recursing forever. fn from(handler: NativeEventHandler) -> Self { - AttrValueAdapter::new(handler).into() + AttrValueAdapter::new(handler).into_callback_named(CALLBACK_EVENT_NAME) } } @@ -220,7 +227,9 @@ impl From> for AttributeValue { /// - `Option` - Input value to convert from. fn from(handler: Option) -> Self { match handler { - Some(event_handler) => AttrValueAdapter::new(event_handler).into(), + Some(event_handler) => { + AttrValueAdapter::new(event_handler).into_callback_named(CALLBACK_EVENT_NAME) + } None => AttributeValue::Text(String::new()), } } diff --git a/core/src/reactive/hook/impl.rs b/core/src/reactive/hook/impl.rs index 985cee95..fe171286 100644 --- a/core/src/reactive/hook/impl.rs +++ b/core/src/reactive/hook/impl.rs @@ -58,7 +58,7 @@ impl HookContext { /// # Returns /// /// - `NodeRef` - A `NodeRef` value. - pub fn noderef() -> NodeRef + pub(crate) fn noderef() -> NodeRef where T: ?Sized + 'static, { @@ -129,7 +129,7 @@ impl IntervalHandle { /// /// Panics if `window()` is unavailable on the current platform. pub fn clear(&self) { - if let Some(cleanup_window) = web_sys::window() { + if let Some(cleanup_window) = window() { cleanup_window.clear_interval_with_handle(self.get_interval_id()); } } @@ -206,7 +206,7 @@ impl HookContext { /// # Returns /// /// - `Signal` - A reactive signal containing the initialized or existing value. - pub fn signal(init: F) -> Signal + pub(crate) fn signal(init: F) -> Signal where T: Clone + PartialEq + 'static, F: FnOnce() -> T, @@ -246,7 +246,7 @@ impl HookContext { /// # Arguments /// /// - `F` - The cleanup callback to execute on context teardown. - pub fn cleanup(cleanup: F) + pub(crate) fn cleanup(cleanup: F) where F: FnOnce() + 'static, { @@ -279,7 +279,7 @@ impl HookContext { /// /// - `E` - The event name to listen for (e.g., "hashchange", "popstate", "resize"). /// - `F` - The callback to invoke when the event fires. - pub fn window_event(event_name: E, callback: F) + pub(crate) fn window_event(event_name: E, callback: F) where E: AsRef, F: FnMut() + 'static, @@ -327,7 +327,7 @@ impl HookContext { /// # Panics /// /// Panics if `window()` is unavailable on the current platform. - pub fn interval(millis: i32, callback: F) -> IntervalHandle + pub(crate) fn interval(millis: i32, callback: F) -> IntervalHandle where F: FnMut() + 'static, { @@ -343,11 +343,11 @@ impl HookContext { return *existing; } let closure: Closure = Closure::wrap(Box::new(callback)); - let Some(window) = window() else { + let Some(outer_window) = window() else { closure.forget(); return IntervalHandle::new(0); }; - let Ok(interval_id) = window.set_interval_with_callback_and_timeout_and_arguments_0( + let Ok(interval_id) = outer_window.set_interval_with_callback_and_timeout_and_arguments_0( closure.as_ref().unchecked_ref(), millis, ) else { @@ -357,7 +357,7 @@ impl HookContext { closure.forget(); let handle: IntervalHandle = IntervalHandle::new(interval_id); inner.get_mut_cleanups().push(Box::new(move || { - let Some(cleanup_window) = web_sys::window() else { + let Some(cleanup_window) = window() else { return; }; cleanup_window.clear_interval_with_handle(interval_id); diff --git a/core/src/reactive/hook/struct.rs b/core/src/reactive/hook/struct.rs index d71beb89..632700ff 100644 --- a/core/src/reactive/hook/struct.rs +++ b/core/src/reactive/hook/struct.rs @@ -12,8 +12,10 @@ pub struct HookContextInner { #[debug(skip)] pub hooks: Vec>, /// The match arm index from the last render. - #[get(type(copy))] - pub arm_changed: usize, + #[get(pub(crate), type(copy))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) arm_changed: usize, /// Current hook index, incremented on each hook call and reset per render. #[get(type(copy))] pub hook_index: usize, @@ -21,7 +23,10 @@ pub struct HookContextInner { /// be executed when the hook context is cleared due to a `match` arm /// switch. #[debug(skip)] - pub cleanups: Vec>, + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] + pub(crate) cleanups: Vec>, } /// Manages hook state across render cycles for a DynamicNode. @@ -46,5 +51,8 @@ pub struct HookContext { pub struct IntervalHandle { /// The interval ID assigned by the browser. #[get(type(copy))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) interval_id: i32, } diff --git a/core/src/reactive/schedule/impl.rs b/core/src/reactive/schedule/impl.rs index 7dcee72f..5ca1829d 100644 --- a/core/src/reactive/schedule/impl.rs +++ b/core/src/reactive/schedule/impl.rs @@ -67,7 +67,7 @@ impl Scheduler { /// Whether a JS `Window` is reachable on this host. /// - /// `web_sys::window()` resolves the JS global through a + /// `window()` resolves the JS global through a /// process-wide `once_cell::Lazy` inside `js_sys`. On a non-WASM host /// (where `cargo test` runs) there is no JS global, so the lookup /// **panics** — and because the `Lazy` is process-wide, that one diff --git a/core/src/reactive/schedule/static.rs b/core/src/reactive/schedule/static.rs index 65697c03..87c066e2 100644 --- a/core/src/reactive/schedule/static.rs +++ b/core/src/reactive/schedule/static.rs @@ -4,11 +4,11 @@ use super::*; /// /// Set to `true` when `schedule_update()` queues a microtask, /// and reset to `false` when the dispatch callback fires. -pub static SCHEDULED: AtomicBool = AtomicBool::new(false); +pub(crate) static SCHEDULED: AtomicBool = AtomicBool::new(false); /// Suppress flag to prevent `schedule_update()` from dispatching /// during internal operations such as `batch`. -pub static SUPPRESS_SCHEDULE: AtomicBool = AtomicBool::new(false); +pub(crate) static SUPPRESS_SCHEDULE: AtomicBool = AtomicBool::new(false); thread_local! { /// The currently active `HookContext` for this thread. @@ -22,7 +22,7 @@ thread_local! { /// process with a refcount underflow. Per-thread storage makes the /// save/restore pair atomic with respect to other threads by /// construction. - pub static CURRENT_HOOK_CONTEXT: RefCell> = + pub(crate) static CURRENT_HOOK_CONTEXT: RefCell> = const { RefCell::new(None) }; } @@ -33,7 +33,7 @@ thread_local! { /// can register the dependency. `usize::MAX` means "no tracking active". /// /// SAFETY: Must only be accessed from the main thread (WASM single-threaded context). -pub static CURRENT_TRACKING_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(usize::MAX); +pub(crate) static CURRENT_TRACKING_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(usize::MAX); thread_local! { /// The persistent dispatch `Closure`, kept alive for the lifetime of the @@ -44,7 +44,7 @@ thread_local! { /// `schedule_update` call to schedule a fresh dispatch; if /// this never ran, the flag would stay `true` forever and every reactive /// update would be silently dropped. - pub static DISPATCH_CLOSURE: Closure = + pub(crate) static DISPATCH_CLOSURE: Closure = Closure::wrap(Box::new(|| { SCHEDULED.store(false, Ordering::Relaxed); Scheduler::dispatch_updates(); @@ -63,6 +63,6 @@ thread_local! { /// restoring the borrow check the `unsafe` had bypassed. It holds no /// raw pointer and no `!Send` payload, so thread-local storage is a /// pure win rather than a restriction. - pub static MICROTASK_CACHE: RefCell = + pub(crate) static MICROTASK_CACHE: RefCell = RefCell::new(MicrotaskCache { queue_microtask: None }); } diff --git a/core/src/reactive/schedule/struct.rs b/core/src/reactive/schedule/struct.rs index 26b3b893..63a06bae 100644 --- a/core/src/reactive/schedule/struct.rs +++ b/core/src/reactive/schedule/struct.rs @@ -26,6 +26,9 @@ pub(crate) struct MicrotaskCache { /// browser does not expose `queueMicrotask` (the dispatch path /// then falls through to `setTimeout` / `requestAnimationFrame`). #[debug(skip)] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) queue_microtask: Option, } diff --git a/core/src/reactive/signal/mod.rs b/core/src/reactive/signal/mod.rs index c6bba3e9..d530da9a 100644 --- a/core/src/reactive/signal/mod.rs +++ b/core/src/reactive/signal/mod.rs @@ -6,8 +6,6 @@ mod r#type; pub use r#struct::*; -pub(crate) use r#static::*; -pub(crate) use r#trait::*; -pub(crate) use r#type::*; +pub(crate) use {r#static::*, r#trait::*, r#type::*}; use super::*; diff --git a/core/src/reactive/signal/static.rs b/core/src/reactive/signal/static.rs index 305bbf3a..2b6e2330 100644 --- a/core/src/reactive/signal/static.rs +++ b/core/src/reactive/signal/static.rs @@ -23,5 +23,5 @@ thread_local! { /// construction, and `RefCell` restores the aliasing check that the old /// `unsafe` bypassed. - pub static SIGNAL_SLAB: RefCell = RefCell::new(SignalSlab::new()); + pub(crate) static SIGNAL_SLAB: RefCell = RefCell::new(SignalSlab::new()); } diff --git a/core/src/reactive/signal/struct.rs b/core/src/reactive/signal/struct.rs index c5efdcc9..25c622ef 100644 --- a/core/src/reactive/signal/struct.rs +++ b/core/src/reactive/signal/struct.rs @@ -72,6 +72,7 @@ where /// semantics are safe because only the slot index is copied — the actual /// slot is owned by the slab and is never freed or recycled. #[derive(CustomDebug, Data, Eq, Hash, New, Ord, PartialEq, PartialOrd)] +#[new(pub(crate))] pub struct Signal where T: Clone + PartialEq + 'static, @@ -81,6 +82,8 @@ where #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) inner: usize, /// Marker for the generic type parameter (uses fn pointer to be `Copy` /// regardless of `T`). @@ -88,6 +91,9 @@ where #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) _marker: PhantomData T>, } @@ -98,6 +104,7 @@ where /// variable, but concurrent access from multiple threads would be /// undefined behavior. #[derive(CustomDebug, Data, New)] +#[new(pub(crate))] pub struct SignalCell where T: Clone + PartialEq + 'static, @@ -107,6 +114,9 @@ where #[get(pub(crate))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) inner: UnsafeCell>>, } @@ -148,5 +158,8 @@ pub struct FireHandle { #[get(type(copy))] #[get_mut(pub(crate))] #[set(pub(crate))] + #[get(pub(crate))] + #[get_mut(pub(crate))] + #[set(pub(crate))] pub(crate) inner: usize, } diff --git a/core/src/renderer/dom/trait.rs b/core/src/renderer/dom/trait.rs index 97ed6795..64d3d08c 100644 --- a/core/src/renderer/dom/trait.rs +++ b/core/src/renderer/dom/trait.rs @@ -1,9 +1,9 @@ /// Extension trait for `Element` providing DOM attribute/property manipulation methods. /// /// Since Rust's orphan rules prevent adding inherent methods to foreign types like -/// `web_sys::Element`, this trait provides the same functionality through an extension +/// `Element`, this trait provides the same functionality through an extension /// trait pattern. All methods are available on any `Element` reference via trait dispatch. -pub trait ElementExt { +pub(crate) trait ElementExt { /// Removes or clears a DOM attribute/property, depending on the attribute name. /// /// For `value`, sets the DOM property to an empty string rather than calling diff --git a/core/src/renderer/dom_ops/const.rs b/core/src/renderer/dom_ops/const.rs index bf929359..e2d793fc 100644 --- a/core/src/renderer/dom_ops/const.rs +++ b/core/src/renderer/dom_ops/const.rs @@ -4,8 +4,8 @@ /// recognisable to euv itself, and to a human reading `globalThis` in a /// debugger. What follows the prefix is built once per page load from a /// microsecond clock and an encoded suffix, so the *full* name is not a -/// stable, pre-knowable target. See [`DomOpNames`] for the shape and -/// [`dom_op_names`] for the one-time construction. +/// stable, pre-knowable target. See [`DomOpNames`](crate::DomOpNames) for the shape and +/// `dom_op_names` for the one-time construction. pub(crate) const JS_DOM_OP_NAME_PREFIX: &str = "__euv_"; /// Character set handed to `bin-encode-decode` when encoding the suffix. @@ -53,3 +53,11 @@ pub(crate) const JS_GLOBAL_THIS: &str = "globalThis"; /// happen on every `cargo clippy` that is not targeting wasm32. #[cfg(target_arch = "wasm32")] pub(crate) const JS_PERFORMANCE_NOW_FRACTION: &str = "performance.now() % 1"; + +/// The byte every printable character is offset from when a token is masked +/// for the DOM-operation lookup table. +pub(crate) const DOM_OPS_MASK_DIGIT: u8 = b'0'; + +/// The byte the mask starts from, so a printable run never contains the +/// separator the table keys on. +pub(crate) const DOM_OPS_MASK_FILL: u8 = b'!'; diff --git a/core/src/renderer/dom_ops/fn.rs b/core/src/renderer/dom_ops/fn.rs index e1ba5633..c8d77f10 100644 --- a/core/src/renderer/dom_ops/fn.rs +++ b/core/src/renderer/dom_ops/fn.rs @@ -84,12 +84,11 @@ pub(crate) fn ensure_dom_op_table() -> Option { // Cache the resolved table. A refused borrow only costs one extra // `Reflect::get` on the next patch, so the failure is ignored rather // than propagated. - let _: Result<(), std::thread::AccessError> = - DOM_OP_TABLE.try_with(|cell: &RefCell>| { - if let Ok(mut guard) = cell.try_borrow_mut() { - *guard = Some(table.clone()); - } - }); + let _: Result<(), AccessError> = DOM_OP_TABLE.try_with(|cell: &RefCell>| { + if let Ok(mut guard) = cell.try_borrow_mut() { + *guard = Some(table.clone()); + } + }); Some(table) } @@ -234,7 +233,7 @@ pub(crate) fn is_property_attr(name: &str) -> bool { /// single JS-side function call. /// /// On any failure (table unavailable, JS exception), the function falls -/// back to per-op `web_sys::Element::set_attribute` calls. +/// back to per-op `Element::set_attribute` calls. /// /// # Arguments /// @@ -274,7 +273,7 @@ pub(crate) fn apply_set_attr_batch(element: &Element, ops: &[(String, String)]) /// single JS-side function call. /// /// On any failure (table unavailable, JS exception), the function falls -/// back to per-op `web_sys::Element::remove_attribute` calls. +/// back to per-op `Element::remove_attribute` calls. /// /// # Arguments /// @@ -432,13 +431,13 @@ pub(crate) fn encoded_name_suffix() -> String { let mixed: u64 = micros.wrapping_mul(JS_DOM_OP_NAME_MIX); let printable: [u8; 6] = { let bytes: [u8; 8] = mixed.to_le_bytes(); - let mut masked: [u8; 6] = [b'0'; 6]; + let mut masked: [u8; 6] = [DOM_OPS_MASK_DIGIT; 6]; for (slot, byte) in masked.iter_mut().zip(bytes.iter().take(6)) { - *slot = b'!' + (byte % 94); + *slot = DOM_OPS_MASK_FILL + (byte % 94); } masked }; - let raw: &str = std::str::from_utf8(&printable).unwrap_or(JS_DOM_OP_NAME_FALLBACK_SUFFIX); + let raw: &str = from_utf8(&printable).unwrap_or(JS_DOM_OP_NAME_FALLBACK_SUFFIX); let encoded: Result = Charset::new().charset(JS_DOM_OP_NAME_CHARSET).encode(raw); match encoded { diff --git a/core/src/renderer/dom_ops/struct.rs b/core/src/renderer/dom_ops/struct.rs index 3418e7d5..07d2484a 100644 --- a/core/src/renderer/dom_ops/struct.rs +++ b/core/src/renderer/dom_ops/struct.rs @@ -34,7 +34,7 @@ thread_local! { /// `Reflect::get(globalThis, "__euv_dom_ops__")` (or installed if /// missing); subsequent patches reuse the cached functions without any /// further global lookup. - pub static DOM_OP_TABLE: RefCell> = const { RefCell::new(None) }; + pub(crate) static DOM_OP_TABLE: RefCell> = const { RefCell::new(None) }; } /// The per-load set of names the batched DOM-op helpers are published under. @@ -50,7 +50,7 @@ thread_local! { /// drawing independent ones, so the whole set is still a single random draw /// and the four names stay visibly related when inspecting `globalThis`. /// -/// Constructed once and cached in [`DOM_OP_NAMES`]; read through +/// Constructed once and cached in `DOM_OP_NAMES`; read through /// [`DomOpNames::get`]. #[derive(Clone)] pub(crate) struct DomOpNames { diff --git a/core/src/renderer/mod.rs b/core/src/renderer/mod.rs index 1b7fb75a..cd8e4fa1 100644 --- a/core/src/renderer/mod.rs +++ b/core/src/renderer/mod.rs @@ -3,6 +3,6 @@ mod dom_ops; mod registry; mod render; -pub(crate) use {dom::*, registry::*, render::*}; +pub(crate) use {dom::*, dom_ops::*, registry::*, render::*}; use super::*; diff --git a/core/src/renderer/registry/const.rs b/core/src/renderer/registry/const.rs index b83226ba..3801c06e 100644 --- a/core/src/renderer/registry/const.rs +++ b/core/src/renderer/registry/const.rs @@ -1,3 +1,5 @@ +use super::*; + /// The DOM attribute name used to store the unique euv identifier on an element. /// /// This attribute is set on every element that registers an event listener @@ -115,9 +117,9 @@ thread_local! { /// records "looked, and the global is absent". That way a host which /// strips the helper costs one lookup per page instead of one per /// event, and the fallback path can be taken without a second probe. - pub static EVENT_ID_CHAIN_FN: std::cell::RefCell< - Option>, - > = const { std::cell::RefCell::new(None) }; + pub(crate) static EVENT_ID_CHAIN_FN: RefCell< + Option>, + > = const { RefCell::new(None) }; } /// The JS global object name resolved when no direct handle is available. diff --git a/core/src/renderer/registry/fn.rs b/core/src/renderer/registry/fn.rs index 3bc25e12..a8eb5edf 100644 --- a/core/src/renderer/registry/fn.rs +++ b/core/src/renderer/registry/fn.rs @@ -48,7 +48,7 @@ fn global_this() -> Option { { return Some(value); } - let window_value: web_sys::Window = window()?; + let window_value: Window = window()?; Some(window_value.into()) } @@ -101,7 +101,7 @@ fn event_id_chain_fn() -> Option { }) .filter(|value: &JsValue| value.is_function()) .and_then(|value: JsValue| value.dyn_into::().ok()); - let mut slot: std::cell::RefMut<'_, Option>> = cell.borrow_mut(); + let mut slot: RefMut<'_, Option>> = cell.borrow_mut(); *slot = Some(resolved.clone()); resolved }) @@ -174,21 +174,21 @@ pub(crate) fn euv_event_collect_id_chain(event: &JsValue, max_depth: usize) -> F /// /// - `Float64Array` - The parsed `data-euv-id` values in walk order. fn collect_id_chain_rust(event: &JsValue, max_depth: usize) -> Float64Array { - let event_target: Option = + let event_target: Option = js_sys::Reflect::get(event, &JsValue::from_str(EVENT_TARGET_PROP)) .ok() - .and_then(|value: JsValue| value.dyn_into::().ok()); + .and_then(|value: JsValue| value.dyn_into::().ok()); let Some(target) = event_target else { return Float64Array::new_with_length(0); }; let mut ids: Vec = Vec::new(); - let mut node: Option = Some(target.unchecked_into::()); + let mut node: Option = Some(target.unchecked_into::()); let mut depth: usize = 0; while let Some(current) = node { if max_depth != 0 && depth >= max_depth { break; } - let element: Option<&web_sys::Element> = current.dyn_ref::(); + let element: Option<&Element> = current.dyn_ref::(); if let Some(el) = element && let Some(id_str) = el.get_attribute(DATA_EUV_ID) && let Ok(parsed) = id_str.parse::() diff --git a/core/src/renderer/registry/static.rs b/core/src/renderer/registry/static.rs index 5b4df9ba..535cfd67 100644 --- a/core/src/renderer/registry/static.rs +++ b/core/src/renderer/registry/static.rs @@ -1,16 +1,16 @@ use super::*; /// Global auto-incrementing ID counter for DOM elements. -pub static NEXT_EUV_ID: AtomicUsize = AtomicUsize::new(0); +pub(crate) static NEXT_EUV_ID: AtomicUsize = AtomicUsize::new(0); /// Global auto-incrementing ID counter for DynamicNode placeholder elements. -pub static NEXT_EUV_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(0); +pub(crate) static NEXT_EUV_DYNAMIC_ID: AtomicUsize = AtomicUsize::new(0); /// Whether `dispatch_updates` is currently executing. -pub static SIGNAL_UPDATE_DISPATCHING: AtomicBool = AtomicBool::new(false); +pub(crate) static SIGNAL_UPDATE_DISPATCHING: AtomicBool = AtomicBool::new(false); /// Global auto-incrementing ID counter for window event handler entries. -pub static NEXT_WINDOW_HANDLER_ID: AtomicUsize = AtomicUsize::new(0); +pub(crate) static NEXT_WINDOW_HANDLER_ID: AtomicUsize = AtomicUsize::new(0); thread_local! { /// Set of dynamic node IDs marked dirty since the last dispatch drain. @@ -43,7 +43,7 @@ thread_local! { /// `HashSet` corrupted its bucket array. Thread-local storage makes /// each thread's dirty set structurally invisible to the others, and /// `RefCell` restores the borrow check the `unsafe` had bypassed. - pub static DIRTY_UPDATE_IDS: RefCell> = RefCell::new(HashSet::new()); + pub(crate) static DIRTY_UPDATE_IDS: RefCell> = RefCell::new(HashSet::new()); /// Global handler registry, mapping (element_id, event_name) to HandlerEntry. /// @@ -52,21 +52,21 @@ thread_local! { /// `&'static mut HandlerRegistryMap` behind an unsound `unsafe impl /// Sync`, so a parallel test run could observe two live /// `&mut` references to the same map. - pub static HANDLER_REGISTRY: RefCell = RefCell::new(HashMap::new()); + pub(crate) static HANDLER_REGISTRY: RefCell = RefCell::new(HashMap::new()); /// Global set of event names that have already been delegated at the window level. /// /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashSet` across threads. - pub static DELEGATED_EVENTS: RefCell> = RefCell::new(HashSet::new()); + pub(crate) static DELEGATED_EVENTS: RefCell> = RefCell::new(HashSet::new()); /// Global signal update callback registry, mapping keys to SignalUpdateEntry. /// /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a /// racing initialisation and aliased a `HashMap` across threads. - pub static SIGNAL_UPDATE_REGISTRY: RefCell> = + pub(crate) static SIGNAL_UPDATE_REGISTRY: RefCell> = RefCell::new(HashMap::new()); /// Global window event proxy registry, mapping event names to handler lists. @@ -74,7 +74,7 @@ thread_local! { /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashMap` across threads. - pub static WINDOW_EVENT_REGISTRY: RefCell = + pub(crate) static WINDOW_EVENT_REGISTRY: RefCell = RefCell::new(HashMap::new()); /// Global `NodeRef` registry used to clear `NodeRef` handles when the @@ -89,7 +89,7 @@ thread_local! { /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashMap` across threads. - pub static NODEREF_REGISTRY: RefCell = RefCell::new(HashMap::new()); + pub(crate) static NODEREF_REGISTRY: RefCell = RefCell::new(HashMap::new()); /// Global binding-cleanup registry, mapping `euv_id` to the teardown thunks /// of the signal bindings installed on that element. @@ -103,5 +103,5 @@ thread_local! { /// Thread-local for the same reason as [`DIRTY_UPDATE_IDS`]: the old /// `static mut LazyLock` poisoned on a racing /// initialisation and aliased a `HashMap` across threads. - pub static BINDING_CLEANUPS: RefCell = RefCell::new(HashMap::new()); + pub(crate) static BINDING_CLEANUPS: RefCell = RefCell::new(HashMap::new()); } diff --git a/core/src/renderer/registry/struct.rs b/core/src/renderer/registry/struct.rs index cb215f6b..f5d0a9b2 100644 --- a/core/src/renderer/registry/struct.rs +++ b/core/src/renderer/registry/struct.rs @@ -40,6 +40,7 @@ pub(crate) struct SignalUpdateSlot { /// The callback to invoke when signal update events fire. #[debug(skip)] #[get(skip)] + #[get_mut(pub(crate))] #[set(pub(crate))] pub(crate) callback: Option>, /// Whether this slot has been marked for removal. diff --git a/core/src/renderer/registry/type.rs b/core/src/renderer/registry/type.rs index 51e3b204..cc92adff 100644 --- a/core/src/renderer/registry/type.rs +++ b/core/src/renderer/registry/type.rs @@ -5,14 +5,14 @@ use super::*; /// Stores a raw pointer to a heap-allocated `HandlerSlot`. The allocation /// is owned by the registry and freed during cleanup. Direct pointer access /// avoids `Rc>` overhead in the event dispatch hot path. -pub type HandlerEntry = *mut HandlerSlot; +pub(crate) type HandlerEntry = *mut HandlerSlot; /// Type alias for the signal update registry value. /// /// Stores a raw pointer to a heap-allocated `SignalUpdateSlot`. The allocation /// is owned by the registry and freed during cleanup or sweep. Direct pointer /// access avoids `Rc>` overhead in the signal dispatch hot path. -pub type SignalUpdateEntry = *mut SignalUpdateSlot; +pub(crate) type SignalUpdateEntry = *mut SignalUpdateSlot; /// Type alias for the handler registry map. /// @@ -20,27 +20,27 @@ pub type SignalUpdateEntry = *mut SignalUpdateSlot; /// single `HashMap::remove(&euv_id)` instead of a full-registry scan. Uses /// `&'static str` for event names to avoid allocation on every dispatch lookup. /// Known event names are compile-time constants; custom names are leaked once via `as_str()`. -pub type HandlerRegistryMap = HashMap>; +pub(crate) type HandlerRegistryMap = HashMap>; /// Type alias for a single window event handler entry in the proxy registry. /// /// Each entry holds a unique handler ID and a raw pointer to a heap-allocated /// callback. The ID allows targeted removal during cleanup without disrupting /// other handlers. -pub type WindowEventHandlerEntry = (usize, *mut Box); +pub(crate) type WindowEventHandlerEntry = (usize, *mut Box); /// Type alias for the window event proxy registry map. /// /// Maps event names to a list of handler entries. All handlers for the same /// event name share a single `window.addEventListener` listener (the proxy), /// which iterates this list and invokes each callback on every event. -pub type WindowEventRegistryMap = HashMap>; +pub(crate) type WindowEventRegistryMap = HashMap>; /// Type alias for a single `NodeRef` registration in the unmount-clear registry. /// /// NP-3: holds a clone of the `NodeRef`'s interior cell so `cleanup_subtree` /// can call `clear()` on every handle that pointed at a now-unmounted element. -pub type NodeRefEntry = Rc>>; +pub(crate) type NodeRefEntry = Rc>>; /// Type alias for the `NodeRef` unmount-clear registry. /// @@ -49,7 +49,7 @@ pub type NodeRefEntry = Rc>>; /// element. Removing an element from the DOM causes a single /// `HashMap::remove(&euv_id)` followed by iterating the entries to call /// `clear()` on each cell. -pub type NodeRefRegistryMap = HashMap>; +pub(crate) type NodeRefRegistryMap = HashMap>; /// Type alias for a single binding-teardown thunk. /// @@ -58,11 +58,11 @@ pub type NodeRefRegistryMap = HashMap>; /// exactly one subscription via [`Signal::unsubscribe`], so removing a DOM /// subtree tears its bindings down without touching the source signal's /// other listeners or its `alive` flag. -pub type BindingCleanup = Box; +pub(crate) type BindingCleanup = Box; /// Type alias for the binding-cleanup registry. /// /// Maps `euv_id` to the teardown thunks of every signal binding installed on /// that element. Drained by `cleanup_subtree` when the element leaves the /// DOM. -pub type BindingCleanupsMap = HashMap>; +pub(crate) type BindingCleanupsMap = HashMap>; diff --git a/core/src/renderer/render/const.rs b/core/src/renderer/render/const.rs index 9e769200..6689c6a6 100644 --- a/core/src/renderer/render/const.rs +++ b/core/src/renderer/render/const.rs @@ -59,6 +59,6 @@ thread_local! { /// Single-threaded cache for the page's `Document`. SAFETY: euv /// runs on the main thread in WASM contexts, and `Document::clone` /// is a cheap reference count bump on the underlying `JsValue`. - pub static DOCUMENT_CACHE: UnsafeCell> = + pub(crate) static DOCUMENT_CACHE: UnsafeCell> = const { UnsafeCell::new(None) }; } diff --git a/core/src/renderer/render/enum.rs b/core/src/renderer/render/enum.rs index 25af1bae..ad8b2cac 100644 --- a/core/src/renderer/render/enum.rs +++ b/core/src/renderer/render/enum.rs @@ -4,7 +4,7 @@ use super::*; /// [`compute_child_ops_plan`](super::compute_child_ops_plan). /// /// The enum is consumed verbatim by `patch_children_keyed`, which -/// translates each variant into a [`crate::dom_ops::ChildOp`]. The +/// translates each variant into a [`crate::renderer::dom_ops::ChildOp`]. The /// plan is generated in execution order: every `Remove` for a /// disappearing old key is emitted before any `Move` / `Insert`, so /// the renderer's removal pass can detach stale nodes first. After diff --git a/core/src/renderer/render/fn.rs b/core/src/renderer/render/fn.rs index c6909893..4df40bfe 100644 --- a/core/src/renderer/render/fn.rs +++ b/core/src/renderer/render/fn.rs @@ -189,7 +189,10 @@ where } return; } - let mut iter = nodes.into_iter(); + // `I` is generic over `IntoIterator`, so the concrete iterator type is an + // associated type rather than a concrete one; `::IntoIter` + // is the nameable form of it. + let mut iter: ::IntoIter = nodes.into_iter(); let Some(first) = iter.next() else { return; }; @@ -496,7 +499,7 @@ pub(crate) fn euv_collect_subtree_ids(root: &Element) -> Float64Array { // typical cleanup_subtree calls (5–30 nodes) the Rust loop pays the // same crossings in aggregate without a separate JS module load. let mut out: Vec = Vec::new(); - let mut stack: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); stack.push(root.clone()); while let Some(node) = stack.pop() { let euv_attr: Option = node.get_attribute(DATA_EUV_ID); diff --git a/core/src/renderer/render/impl.rs b/core/src/renderer/render/impl.rs index 7d850ef8..01f5ec7b 100644 --- a/core/src/renderer/render/impl.rs +++ b/core/src/renderer/render/impl.rs @@ -64,7 +64,7 @@ impl Renderer { /// # Arguments /// /// - `VirtualNode` - The new virtual DOM tree to render. - pub fn render(&mut self, vnode: VirtualNode) { + pub(crate) fn render(&mut self, vnode: VirtualNode) { let new_unwrapped: VirtualNode = Self::unwrap_component_owned(vnode); let old_tree: Option = take(self.get_mut_current_tree()); if let Some(old_vnode) = old_tree.as_ref() { @@ -90,7 +90,7 @@ impl Renderer { /// # Arguments /// /// - `VirtualNode` - The new virtual DOM tree to render. - pub fn render_full_replace(&mut self, vnode: VirtualNode) { + pub(crate) fn render_full_replace(&mut self, vnode: VirtualNode) { let new_unwrapped: VirtualNode = Self::unwrap_component_owned(vnode); while let Some(child) = self.get_root().first_child() { if let Some(element) = child.dyn_ref::() { @@ -366,7 +366,7 @@ impl Renderer { _ => None, }) .any(|old_callback: &SharedEventCallback| { - std::ptr::eq(Rc::as_ptr(old_callback), Rc::as_ptr(new_callback)) + Rc::ptr_eq(old_callback, new_callback) }); if !already_attached { self.attach_event_listener(element, handler); diff --git a/core/src/renderer/render/mod.rs b/core/src/renderer/render/mod.rs index 3ffe0377..e5f38a93 100644 --- a/core/src/renderer/render/mod.rs +++ b/core/src/renderer/render/mod.rs @@ -4,7 +4,6 @@ mod r#fn; mod r#impl; mod r#struct; -pub(crate) use super::dom_ops::*; pub(crate) use {r#const::*, r#enum::*, r#fn::*, r#struct::*}; use super::*; diff --git a/core/src/vdom/attribute/enum.rs b/core/src/vdom/attribute/enum.rs index 5a46b10a..9ad266cb 100644 --- a/core/src/vdom/attribute/enum.rs +++ b/core/src/vdom/attribute/enum.rs @@ -38,7 +38,7 @@ pub enum AttributeValue { /// A raw HTML fragment assigned via the `inner_html:` attribute. /// /// Replaces the element's children wholesale via - /// [`web_sys::Element::set_inner_html`]. Unlike `Text` (which the + /// [`Element::set_inner_html`]. Unlike `Text` (which the /// browser escapes), this variant trusts the input string and runs /// any embedded `