Skip to main content

euv_cli/build/
fn.rs

1use super::*;
2
3/// Checks whether `wasm_pack_args` already contains a build mode flag.
4///
5/// Returns `true` if any of `--dev`, `--release`, or `--profiling`
6/// is present in the arguments list.
7///
8/// # Arguments
9///
10/// - `&[String]` - The wasm-pack arguments to search.
11///
12/// # Returns
13///
14/// - `bool` - Whether a build mode flag is already present.
15pub fn has_build_mode_flag(wasm_pack_args: &[String]) -> bool {
16    wasm_pack_args
17        .iter()
18        .any(|arg: &String| arg == DEV_FLAG || arg == RELEASE_FLAG || arg == PROFILING_FLAG)
19}
20
21/// Filters out euv-specific arguments from the wasm-pack arguments.
22///
23/// First locates the genuine passthrough arguments by taking everything
24/// after the last `--` separator (or the full list if no `--` is present).
25/// Then removes all known euv-specific flags and their values so that
26/// only wasm-pack-compatible arguments remain.
27///
28/// # Arguments
29///
30/// - `&[String]` - The raw wasm-pack arguments to filter.
31///
32/// # Returns
33///
34/// - `Vec<String>` - The filtered arguments safe for wasm-pack.
35pub fn filter_euv_args(wasm_pack_args: &[String]) -> Vec<String> {
36    let raw_args: &[String] = if let Some(position) = wasm_pack_args
37        .iter()
38        .rposition(|arg: &String| arg == DOUBLE_DASH)
39    {
40        &wasm_pack_args[position + 1..]
41    } else {
42        wasm_pack_args
43    };
44    let mut filtered: Vec<String> = Vec::new();
45    let mut skip_next: bool = false;
46    for arg in raw_args {
47        if skip_next {
48            skip_next = false;
49            continue;
50        }
51        if EUV_ARGS.contains(&arg.as_str()) {
52            if arg.contains('=') {
53                continue;
54            }
55            skip_next = true;
56            continue;
57        }
58        filtered.push(arg.clone());
59    }
60    filtered
61}
62
63/// Reconciles euv-specific arguments that may have been collected into
64/// `wasm_pack_args` (e.g. when placed after `--`) back into the
65/// corresponding `ModeArgs` fields.
66///
67/// Because clap's `trailing_var_arg` collects all unrecognized arguments
68/// into `wasm_pack_args`, any euv flag placed after `--` is not parsed
69/// by clap into its dedicated field. This function scans `wasm_pack_args`
70/// for known euv flags and overwrites the `ModeArgs` fields so that the
71/// rest of the codebase can rely on the typed accessors regardless of
72/// argument order.
73///
74/// # Arguments
75///
76/// - `&mut ModeArgs` - The CLI arguments to reconcile in-place.
77pub fn reconcile_args(args: &mut ModeArgs) {
78    let wasm_pack_args: Vec<String> = args.get_wasm_pack_args().clone();
79    let mut crate_path: Option<PathBuf> = None;
80    let mut port: Option<u16> = None;
81    let mut www_dir: Option<String> = None;
82    let mut index_html: Option<Option<PathBuf>> = None;
83    let mut no_gitignore: Option<bool> = None;
84    let mut dev: Option<bool> = None;
85    let mut release: Option<bool> = None;
86    let mut profiling: Option<bool> = None;
87    let mut iter: Iter<String> = wasm_pack_args.iter();
88    while let Some(arg) = iter.next() {
89        match arg.as_str() {
90            CRATE_PATH_ARG | CRATE_PATH_ARG_SHORT => {
91                if let Some(value) = iter.next() {
92                    crate_path = Some(PathBuf::from(value));
93                }
94            }
95            PORT_ARG | PORT_ARG_SHORT => {
96                if let Some(value) = iter.next()
97                    && let Ok(parsed_port) = value.parse::<u16>()
98                {
99                    port = Some(parsed_port);
100                }
101            }
102            WWW_DIR_ARG => {
103                if let Some(value) = iter.next() {
104                    www_dir = Some(value.clone());
105                }
106            }
107            INDEX_HTML_ARG => {
108                if let Some(value) = iter.next() {
109                    index_html = Some(Some(PathBuf::from(value)));
110                }
111            }
112            NO_GITIGNORE_ARG => {
113                no_gitignore = Some(true);
114            }
115            DEV_FLAG => {
116                dev = Some(true);
117            }
118            RELEASE_FLAG => {
119                release = Some(true);
120            }
121            PROFILING_FLAG => {
122                profiling = Some(true);
123            }
124            other => {
125                if let Some(value) = other.strip_prefix(&format!("{CRATE_PATH_ARG}=")) {
126                    crate_path = Some(PathBuf::from(value));
127                } else if let Some(value) = other.strip_prefix(&format!("{PORT_ARG}=")) {
128                    if let Ok(parsed_port) = value.parse::<u16>() {
129                        port = Some(parsed_port);
130                    }
131                } else if let Some(value) = other.strip_prefix(&format!("{WWW_DIR_ARG}=")) {
132                    www_dir = Some(value.to_string());
133                } else if let Some(value) = other.strip_prefix(&format!("{INDEX_HTML_ARG}=")) {
134                    index_html = Some(Some(PathBuf::from(value)));
135                }
136            }
137        }
138    }
139    if let Some(value) = crate_path {
140        args.set_crate_path(value);
141    }
142    if let Some(value) = port {
143        args.set_port(value);
144    }
145    if let Some(value) = www_dir {
146        args.set_www_dir(value);
147    }
148    if let Some(value) = index_html {
149        args.set_index_html(value);
150    }
151    if let Some(value) = no_gitignore {
152        args.set_no_gitignore(value);
153    }
154    if let Some(value) = dev {
155        args.set_dev(value);
156    }
157    if let Some(value) = release {
158        args.set_release(value);
159    }
160    if let Some(value) = profiling {
161        args.set_profiling(value);
162    }
163}
164
165/// Resolves the build mode from CLI arguments.
166///
167/// First checks the explicit `--dev`, `--release`, and `--profiling` flags on `ModeArgs`.
168/// If none of those are set, inspects `wasm_pack_args` for any build mode flag
169/// that may have been forwarded by the user.
170/// Defaults to `BuildMode::Dev` if no build mode flag is found anywhere.
171///
172/// # Arguments
173///
174/// - `&ModeArgs` - The CLI arguments containing the build mode flags and wasm_pack_args.
175///
176/// # Returns
177///
178/// - `BuildMode` - The resolved build mode.
179pub fn resolve_build_mode(args: &ModeArgs) -> BuildMode {
180    if args.get_profiling() {
181        BuildMode::Profiling
182    } else if args.get_release() {
183        BuildMode::Release
184    } else if args.get_dev() {
185        BuildMode::Dev
186    } else if args
187        .get_wasm_pack_args()
188        .iter()
189        .any(|arg: &String| arg == PROFILING_FLAG)
190    {
191        BuildMode::Profiling
192    } else if args
193        .get_wasm_pack_args()
194        .iter()
195        .any(|arg: &String| arg == RELEASE_FLAG)
196    {
197        BuildMode::Release
198    } else {
199        BuildMode::Dev
200    }
201}
202
203/// Converts a `BuildMode` to the corresponding wasm-pack flag string.
204///
205/// # Arguments
206///
207/// - `BuildMode` - The build mode to convert.
208///
209/// # Returns
210///
211/// - `&'static str` - The wasm-pack command-line flag.
212pub fn build_mode_to_flag(build_mode: BuildMode) -> &'static str {
213    match build_mode {
214        BuildMode::Dev => DEV_FLAG,
215        BuildMode::Release => RELEASE_FLAG,
216        BuildMode::Profiling => PROFILING_FLAG,
217    }
218}
219
220/// Builds a `Gitignore` matcher from the `.gitignore` file at the given root path.
221///
222/// # Arguments
223///
224/// - `&PathBuf` - The root directory where `.gitignore` is located.
225///
226/// # Returns
227///
228/// - `Gitignore` - The compiled gitignore matcher.
229async fn build_gitignore(root: &PathBuf) -> Gitignore {
230    let gitignore_path: PathBuf = root.join(GITIGNORE_FILE_NAME);
231    let mut builder: GitignoreBuilder = GitignoreBuilder::new(root);
232    let gitignore_exists: bool = metadata(&gitignore_path).await.is_ok();
233    if gitignore_exists && let Some(error) = builder.add(&gitignore_path) {
234        log::warn!("Failed to load .gitignore: {error}");
235    }
236    match builder.build() {
237        Ok(gitignore) => {
238            if gitignore_exists {
239                log::info!("Loaded .gitignore to filter file change events");
240            }
241            gitignore
242        }
243        Err(error) => {
244            log::warn!("Failed to build gitignore matcher: {error}");
245            GitignoreBuilder::new(root)
246                .build()
247                .unwrap_or_else(|_error: ignore::Error| Gitignore::empty())
248        }
249    }
250}
251
252/// Extracts the value of `--out-name` from the wasm-pack arguments.
253///
254/// Returns `None` if `--out-name` is not specified.
255///
256/// # Arguments
257///
258/// - `&[String]` - The wasm-pack arguments to search.
259///
260/// # Returns
261///
262/// - `Option<String>` - The value of `--out-name` if found.
263fn extract_out_name(wasm_pack_args: &[String]) -> Option<String> {
264    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
265    while let Some(arg) = iter.next() {
266        if arg == OUT_NAME_ARG {
267            return iter.next().cloned();
268        }
269        if let Some(value) = arg.strip_prefix(&format!("{OUT_NAME_ARG}=")) {
270            return Some(value.to_string());
271        }
272    }
273    None
274}
275
276/// Extracts the value of `--out-dir` from the wasm-pack arguments.
277///
278/// Returns `None` if `--out-dir` is not specified.
279///
280/// # Arguments
281///
282/// - `&[String]` - The wasm-pack arguments to search.
283///
284/// # Returns
285///
286/// - `Option<String>` - The value of `--out-dir` if found.
287fn extract_out_dir(wasm_pack_args: &[String]) -> Option<String> {
288    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
289    while let Some(arg) = iter.next() {
290        if arg == OUT_DIR_ARG {
291            return iter.next().cloned();
292        }
293        if let Some(value) = arg.strip_prefix(&format!("{OUT_DIR_ARG}=")) {
294            return Some(value.to_string());
295        }
296    }
297    None
298}
299
300/// Resolves the output JS filename for HTML generation.
301///
302/// Uses `--out-name` from wasm-pack args if specified,
303/// otherwise reads the crate name from `Cargo.toml` `[package] name` field
304/// and replaces hyphens with underscores (matching wasm-pack behavior).
305/// Appends `.js` extension to form the complete JS filename.
306///
307/// # Arguments
308///
309/// - `&ModeArgs` - The CLI arguments containing crate_path and wasm_pack_args.
310///
311/// # Returns
312///
313/// - `String` - The resolved JS filename with `.js` extension (e.g. `euv_example.js`).
314pub fn resolve_out_name(args: &ModeArgs) -> String {
315    let name: String = if let Some(out_name) = extract_out_name(args.get_wasm_pack_args()) {
316        out_name.replace(STR_HYPHEN, STR_UNDERSCORE)
317    } else {
318        let cargo_toml_path: PathBuf = args.get_crate_path().join(CARGO_TOML_FILE_NAME);
319        read_crate_name_from_toml(&cargo_toml_path)
320            .unwrap_or_else(|| {
321                args.get_crate_path()
322                    .file_name()
323                    .unwrap_or_default()
324                    .to_string_lossy()
325                    .to_string()
326            })
327            .replace(STR_HYPHEN, STR_UNDERSCORE)
328    };
329    format!("{name}{JS_EXTENSION}")
330}
331
332/// Reads the `name` field from a Cargo.toml file.
333///
334/// Parses the file line-by-line looking for `name = "..."` within the `[package]` section.
335///
336/// # Arguments
337///
338/// - `&Path` - The path to the Cargo.toml file.
339///
340/// # Returns
341///
342/// - `Option<String>` - The crate name if found.
343fn read_crate_name_from_toml(path: &Path) -> Option<String> {
344    let content: String = std::fs::read_to_string(path).ok()?;
345    let mut in_package: bool = false;
346    for line in content.lines() {
347        let trimmed: &str = line.trim();
348        if trimmed.starts_with('[') {
349            in_package = trimmed == "[package]";
350            continue;
351        }
352        if in_package
353            && trimmed.starts_with("name")
354            && let Some(value) = trimmed.strip_prefix("name")
355        {
356            let value: &str = value.trim().strip_prefix('=')?.trim();
357            let value: &str = value.strip_prefix('"')?.strip_suffix('"')?;
358            return Some(value.to_string());
359        }
360    }
361    None
362}
363
364/// Computes the relative path from a base directory to a target directory.
365///
366/// Compares the component sequences of both paths to find the common prefix,
367/// then emits `..` for each remaining base component followed by the remaining
368/// target components.
369///
370/// # Arguments
371///
372/// - `&Path` - The base directory path.
373/// - `&Path` - The target directory path.
374///
375/// # Returns
376///
377/// - `PathBuf` - The relative path from base to target.
378fn compute_relative_path(base: &Path, target: &Path) -> PathBuf {
379    let base_components: Vec<Component> = base.components().collect();
380    let target_components: Vec<Component> = target.components().collect();
381    let common_len: usize = base_components
382        .iter()
383        .zip(target_components.iter())
384        .take_while(
385            |(base_component, target_component): &(&Component, &Component)| {
386                base_component == target_component
387            },
388        )
389        .count();
390    let mut result: PathBuf = PathBuf::new();
391    for _ in &base_components[common_len..] {
392        result.push("..");
393    }
394    for component in &target_components[common_len..] {
395        if let Component::Normal(os_str) = component {
396            result.push(os_str);
397        }
398    }
399    result
400}
401
402/// Resolves the serving root directory for the development server.
403///
404/// When the output directory is inside the www directory, returns the resolved www directory.
405/// When the output directory is outside the www directory, returns the parent of the output directory
406/// so that `index.html` and WASM artifacts are co-located under the same serving root.
407///
408/// # Arguments
409///
410/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
411///
412/// # Returns
413///
414/// - `PathBuf` - The resolved serving root directory.
415pub async fn resolve_serving_root(args: &ModeArgs) -> PathBuf {
416    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
417    let out_dir_absolute: PathBuf = resolve_out_dir(args);
418    if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
419        resolve_www_dir(&www_absolute).await
420    } else {
421        out_dir_absolute
422            .parent()
423            .map(|p: &Path| p.to_path_buf())
424            .unwrap_or_else(|| www_absolute)
425    }
426}
427
428/// Resolves the serving route prefix relative to the crate path.
429///
430/// Returns the forward-slash-separated path of the serving root relative to the crate path.
431/// Used for server route registration and URL display.
432///
433/// # Arguments
434///
435/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
436///
437/// # Returns
438///
439/// - `String` - The serving route prefix (e.g. `www` or `wwws`).
440pub fn resolve_serving_route_prefix(args: &ModeArgs) -> String {
441    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
442    let out_dir_absolute: PathBuf = resolve_out_dir(args);
443    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
444        www_absolute
445    } else {
446        out_dir_absolute
447            .parent()
448            .map(|p: &Path| p.to_path_buf())
449            .unwrap_or_else(|| www_absolute)
450    };
451    serving_root
452        .strip_prefix(args.get_crate_path())
453        .map(|rel: &Path| {
454            rel.to_string_lossy()
455                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
456        })
457        .unwrap_or_else(|_| {
458            args.get_www_dir()
459                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
460        })
461}
462
463/// Resolves the JS import path for HTML generation.
464///
465/// Computes the relative path from the serving root to the output directory,
466/// then appends the JS filename (from `resolve_out_name`, which includes `.js`)
467/// to form the full import path (e.g. `./pkg/euv.js` or `./pksg/cc.js`).
468///
469/// # Arguments
470///
471/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
472///
473/// # Returns
474///
475/// - `String` - The resolved JS import path relative to the serving root.
476pub fn resolve_import_path(args: &ModeArgs) -> String {
477    let out_name: String = resolve_out_name(args);
478    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
479    let out_dir_absolute: PathBuf = resolve_out_dir(args);
480    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
481        www_absolute
482    } else {
483        out_dir_absolute
484            .parent()
485            .map(|p: &Path| p.to_path_buf())
486            .unwrap_or_else(|| www_absolute)
487    };
488    let relative: PathBuf = compute_relative_path(&serving_root, &out_dir_absolute);
489    let mut components: Vec<String> = relative
490        .components()
491        .filter_map(|component: Component| match component {
492            Component::Normal(os_str) => os_str.to_str().map(|text: &str| text.to_string()),
493            Component::ParentDir => Some(PARENT_DIR.to_string()),
494            _ => None,
495        })
496        .collect();
497    components.push(out_name);
498    format!("{RELATIVE_PATH_PREFIX}{}", components.join(PATH_SEPARATOR))
499}
500
501/// Resolves the output directory for wasm-pack artifacts.
502///
503/// Uses `--out-dir` from wasm-pack args if specified,
504/// otherwise defaults to `{www_dir}/pkg` so that build artifacts
505/// are placed directly inside the www directory served
506/// by the development server.
507///
508/// # Arguments
509///
510/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
511///
512/// # Returns
513///
514/// - `PathBuf` - The resolved output directory (absolute if crate_path is joined).
515pub fn resolve_out_dir(args: &ModeArgs) -> PathBuf {
516    let out_dir_path: PathBuf = PathBuf::from(
517        extract_out_dir(args.get_wasm_pack_args())
518            .unwrap_or_else(|| format!("{}/{PKG_DIR_NAME}", args.get_www_dir())),
519    );
520    if out_dir_path.is_absolute() {
521        out_dir_path
522    } else {
523        args.get_crate_path().join(&out_dir_path)
524    }
525}
526
527/// Executes a build-only pipeline: formats euv macros, cleans output directory,
528/// builds WASM, and generates HTML.
529///
530/// Unlike `run_build_pipeline`, this cleans the output directory before building
531/// and skips reload notifications — only the essential WASM build artifacts are kept.
532///
533/// # Arguments
534///
535/// - `&ModeArgs` - The CLI arguments.
536///
537/// # Returns
538///
539/// - `Result<(), EuvError>` - Indicates success or failure of the build.
540pub async fn run_build_only_pipeline(args: &ModeArgs) -> Result<(), EuvError> {
541    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
542    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
543        log::warn!("euv fmt error: {error}");
544    }
545    let out_dir: PathBuf = resolve_out_dir(args);
546    clean_out_dir(&out_dir).await;
547    build_wasm(args).await?;
548    log::info!("WASM build completed successfully");
549    let html_config: HtmlConfig = HtmlConfig::new(
550        resolve_serving_root(args).await,
551        resolve_import_path(args),
552        resolve_build_mode(args) == BuildMode::Release,
553        args.try_get_index_html().clone(),
554        false,
555    );
556    generate_html(&html_config).await?;
557    Ok(())
558}
559
560/// Cleans the output directory before a fresh build.
561///
562/// Removes all files and subdirectories within the output directory
563/// so that stale artifacts from previous builds do not remain.
564/// The directory itself is preserved (recreated if missing).
565///
566/// # Arguments
567///
568/// - `&Path` - The output directory to clean.
569pub async fn clean_out_dir(out_dir: &Path) {
570    let mut entries: ReadDir = match read_dir(out_dir).await {
571        Ok(dir) => dir,
572        Err(_) => return,
573    };
574    while let Ok(Some(entry)) = entries.next_entry().await {
575        let path: PathBuf = entry.path();
576        if path.is_dir() {
577            if let Err(error) = remove_dir_all(&path).await {
578                log::warn!("Failed to remove directory '{}': {error}", path.display());
579            }
580        } else if let Err(error) = remove_file(&path).await {
581            log::warn!("Failed to remove file '{}': {error}", path.display());
582        }
583    }
584}
585
586/// Executes a full build pipeline: euv fmt, build wasm, generate HTML.
587/// After the serial pipeline completes, hyperlane-cli fmt is spawned in the
588/// background so it does not block the caller.
589/// Notifies the reload channel on build success or failure.
590///
591/// # Arguments
592///
593/// - `&ModeArgs` - The CLI arguments.
594/// - `Option<&broadcast::Sender<ReloadEvent>>` - Optional reload channel for notifying clients.
595///
596/// # Returns
597///
598/// - `Result<String, EuvError>` - The generated HTML with reload script injected on success.
599pub async fn run_build_pipeline(
600    args: &ModeArgs,
601    reload_tx: Option<&broadcast::Sender<ReloadEvent>>,
602) -> Result<String, EuvError> {
603    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
604    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
605        log::warn!("euv fmt error: {error}");
606    }
607    match build_wasm(args).await {
608        Ok(()) => {
609            log::info!("WASM build completed successfully");
610            if let Some(sender) = reload_tx {
611                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
612                    sender.send(ReloadEvent::Reload);
613            }
614        }
615        Err(error) => {
616            log::error!("WASM build failed: {error}");
617            if let Some(sender) = reload_tx {
618                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
619                    sender.send(ReloadEvent::Error(error.to_string()));
620            }
621        }
622    }
623    let html_config: HtmlConfig = HtmlConfig::new(
624        resolve_serving_root(args).await,
625        resolve_import_path(args),
626        resolve_build_mode(args) == BuildMode::Release,
627        args.try_get_index_html().clone(),
628        true,
629    );
630    let html: String = generate_html(&html_config).await?;
631    spawn(async move {
632        if let Err(error) = run_hyperlane_fmt().await {
633            log::warn!("hyperlane-cli fmt error: {error}");
634        }
635    });
636    Ok(html)
637}
638
639/// Watches source files and triggers WASM builds.
640///
641/// # Arguments
642///
643/// - `Arc<AppState>` - The shared application state.
644///
645/// # Returns
646///
647/// - `Result<(), EuvError>` - Indicates success or failure of the file watcher.
648pub(crate) async fn watch_and_build(state: Arc<AppState>) -> Result<(), EuvError> {
649    let crate_path: PathBuf = state.get_args().get_crate_path().clone();
650    let src_path: PathBuf = crate_path.join(SRC_DIR_NAME);
651    let gitignore: Gitignore = build_gitignore(&crate_path).await;
652    let (tx, mut rx): (Sender<Event>, Receiver<Event>) = channel(32);
653    let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
654        move |result: Result<Event, notify::Error>| {
655            if let Ok(event) = result {
656                let _: Result<(), tokio::sync::mpsc::error::SendError<Event>> =
657                    tx.blocking_send(event);
658            }
659        },
660        Config::default(),
661    )?;
662    watcher.watch(&src_path, RecursiveMode::Recursive)?;
663    log::info!("Watching {} for changes...", src_path.display());
664    let mut debounce: Interval = interval(Duration::from_millis(500));
665    debounce.tick().await;
666    while let Some(event) = rx.recv().await {
667        let filtered_paths: Vec<String> = event
668            .paths
669            .iter()
670            .filter(|path: &&PathBuf| !gitignore.matched(*path, path.is_dir()).is_ignore())
671            .map(|path: &PathBuf| path.display().to_string())
672            .collect();
673        if filtered_paths.is_empty() {
674            continue;
675        }
676        log::warn!("File change detected: {}", filtered_paths.join(", "));
677        debounce.reset();
678        sleep(Duration::from_millis(300)).await;
679        let mut building: RwLockWriteGuard<bool> = state.get_is_building().write().await;
680        if *building {
681            continue;
682        }
683        *building = true;
684        drop(building);
685        let state_for_build: Arc<AppState> = Arc::clone(&state);
686        spawn(async move {
687            let args: ModeArgs = state_for_build.get_args().clone();
688            let reload_tx: broadcast::Sender<ReloadEvent> = state_for_build.get_reload_tx().clone();
689            match run_build_pipeline(&args, Some(&reload_tx)).await {
690                Ok(html) => {
691                    let mut content: RwLockWriteGuard<String> =
692                        state_for_build.get_html_content().write().await;
693                    *content = html;
694                }
695                Err(error) => {
696                    log::error!("Build pipeline error: {error}");
697                }
698            }
699            let mut building: RwLockWriteGuard<bool> =
700                state_for_build.get_is_building().write().await;
701            *building = false;
702        });
703    }
704    Ok(())
705}
706
707/// Runs wasm-pack build for the target crate.
708///
709/// All arguments in `args.wasm_pack_args` are transparently forwarded
710/// to `wasm-pack build`. If `--out-dir` is not specified by the user,
711/// `--out-dir {www_dir}/pkg` is automatically injected so that build artifacts
712/// are placed inside the www directory served by the development server.
713///
714/// # Arguments
715///
716/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
717///
718/// # Returns
719///
720/// - `Result<(), EuvError>` - Indicates success or failure of the wasm-pack build.
721pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> {
722    let build_mode: BuildMode = resolve_build_mode(args);
723    let build_mode_flag: &str = build_mode_to_flag(build_mode);
724    let filtered_args: Vec<String> = filter_euv_args(args.get_wasm_pack_args());
725    let has_existing_build_mode: bool = has_build_mode_flag(&filtered_args);
726    let default_out_dir: String = format!("{}/{PKG_DIR_NAME}", args.get_www_dir());
727    let mut command: Command = Command::new(WASM_PACK_COMMAND);
728    command.arg(WASM_PACK_BUILD_SUBCOMMAND);
729    if !has_existing_build_mode {
730        command.arg(build_mode_flag);
731    }
732    command
733        .args(&filtered_args)
734        .env(RUST_MIN_STACK_ENV, RUST_MIN_STACK_VALUE);
735    let has_out_dir: bool = extract_out_dir(&filtered_args).is_some();
736    if !has_out_dir {
737        command.arg(OUT_DIR_ARG).arg(&default_out_dir);
738    }
739    let has_target: bool = filtered_args
740        .iter()
741        .any(|arg: &String| arg == TARGET_ARG || arg.starts_with(&format!("{TARGET_ARG}=")));
742    if !has_target {
743        command.arg(TARGET_ARG).arg(TARGET_WEB);
744    }
745    command.current_dir(args.get_crate_path());
746    command.stdout(Stdio::piped()).stderr(Stdio::piped());
747    let display_args: Vec<String> = (if has_existing_build_mode {
748        filtered_args.to_vec()
749    } else {
750        std::iter::once(build_mode_flag.to_string())
751            .chain(filtered_args.iter().cloned())
752            .collect::<Vec<String>>()
753    })
754    .into_iter()
755    .chain(if has_out_dir {
756        Vec::new()
757    } else {
758        vec![OUT_DIR_ARG.to_string(), default_out_dir.clone()]
759    })
760    .chain(if has_target {
761        Vec::new()
762    } else {
763        vec![TARGET_ARG.to_string(), TARGET_WEB.to_string()]
764    })
765    .collect();
766    let out_dir_absolute: PathBuf = resolve_out_dir(args);
767    create_dir_all(&out_dir_absolute)
768        .await
769        .map_err(|error: io::Error| EuvError::IoPath {
770            message: String::from("Failed to create output directory"),
771            path: out_dir_absolute.clone(),
772            error,
773        })?;
774    log::info!(
775        "Running: {WASM_PACK_COMMAND} {WASM_PACK_BUILD_SUBCOMMAND} {} ...",
776        display_args.join(" ")
777    );
778    let output: Output = command
779        .output()
780        .await
781        .map_err(|error: io::Error| EuvError::Io {
782            message: String::from("Failed to execute wasm-pack"),
783            error,
784        })?;
785    let stdout: String = String::from_utf8_lossy(&output.stdout).to_string();
786    let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
787    if args.get_no_gitignore() {
788        let gitignore_path: PathBuf = out_dir_absolute.join(GITIGNORE_FILE_NAME);
789        if gitignore_path.exists()
790            && let Err(error) = remove_file(&gitignore_path).await
791        {
792            log::warn!("Failed to remove '{}': {error}", gitignore_path.display());
793        }
794    }
795    for line in stdout.lines().filter(|line: &&str| !line.is_empty()) {
796        log::info!("{line}");
797    }
798    if output.status.success() {
799        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
800            log::info!("{line}");
801        }
802    } else {
803        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
804            log::error!("{line}");
805        }
806        return Err(EuvError::Message(String::from("wasm-pack build failed")));
807    }
808    Ok(())
809}
810
811/// Prints the startup banner and command information.
812///
813/// # Arguments
814///
815/// - `Action` - The action to perform (run or build).
816pub fn print_banner(action: Action) {
817    let version: &str = env!("CARGO_PKG_VERSION");
818    if version.is_empty() {
819        log::warn!("Failed to parse version from root Cargo.toml");
820    } else {
821        log::info!("euv v{version}");
822    }
823    let action_name: &str = match action {
824        Action::Run => ACTION_RUN,
825        Action::Build => ACTION_BUILD,
826    };
827    log::info!("Mode: {action_name}");
828    log::info!(
829        "Use .gitignore to filter file change events; pass --no-gitignore to remove .gitignore from output"
830    );
831}
832
833/// Enumerates all network interface IP addresses and prints each server URL
834/// along with its corresponding QR code to the console.
835///
836/// Includes both loopback (127.0.0.1) and all private/public IPv4 addresses
837/// bound to the host's network interfaces. Each address produces one URL line
838/// followed by a Unicode QR code rendered with half-block characters,
839/// where every line carries the standard log prefix (timestamp + level).
840///
841/// # Arguments
842///
843/// - `&ServerUrlConfig` - The server URL configuration.
844pub(crate) fn print_server_urls(config: &ServerUrlConfig) {
845    let port: u16 = config.get_port();
846    let route_prefix: &str = config.get_route_prefix();
847    let index_html_file_name: &str = config.get_index_html_file_name();
848    let mut addresses: Vec<IpAddr> = Vec::new();
849    match if_addrs::get_if_addrs() {
850        Ok(interfaces) => {
851            for interface in interfaces {
852                let ip: IpAddr = interface.addr.ip();
853                if !addresses.contains(&ip) {
854                    addresses.push(ip);
855                }
856            }
857        }
858        Err(error) => {
859            log::warn!("Failed to enumerate network interfaces: {error}");
860        }
861    }
862    if addresses.is_empty() {
863        addresses.push(IpAddr::V4(Ipv4Addr::LOCALHOST));
864    }
865    for ip in addresses {
866        let host: String = match ip {
867            IpAddr::V6(_) => format!("[{ip}]"),
868            IpAddr::V4(_) => format!("{ip}"),
869        };
870        let url: String =
871            format!("{HTTP_SCHEME}://{host}:{port}/{route_prefix}/{index_html_file_name}");
872        log::info!("Server: {url}");
873        match QrCode::new(url.as_str()) {
874            Ok(code) => {
875                let string: String = code.render::<Dense1x2>().quiet_zone(false).build();
876                for line in string.lines() {
877                    log::info!("{line}");
878                }
879            }
880            Err(error) => {
881                log::warn!("Failed to generate QR code: {error}");
882            }
883        }
884    }
885}
886
887/// Executes `hyperlane-cli fmt` via the library API to format Rust source files.
888///
889/// # Returns
890///
891/// - `Result<(), EuvError>` - Indicates success or failure of the formatting operation.
892pub async fn run_hyperlane_fmt() -> Result<(), EuvError> {
893    let mut command: Command = Command::new(CARGO_COMMAND);
894    command.arg(FMT_SUBCOMMAND);
895    command.stdout(Stdio::piped()).stderr(Stdio::piped());
896    let output: Output = command
897        .output()
898        .await
899        .map_err(|error: io::Error| EuvError::Io {
900            message: String::from(FMT_ERROR_MESSAGE),
901            error,
902        })?;
903    let stdout: String = String::from_utf8_lossy(&output.stdout).trim().to_string();
904    let stderr: String = String::from_utf8_lossy(&output.stderr).trim().to_string();
905    if !output.status.success() {
906        return Err(EuvError::Io {
907            message: String::from(FMT_ERROR_MESSAGE),
908            error: io::Error::other(format!("{stdout} {stderr}")),
909        });
910    }
911    Ok(())
912}
913
914/// Reads the wasm-bindgen JS bridge produced by `wasm-pack build
915/// --target web` and returns a synchronous IIFE that inlines the bridge
916/// and its snippet modules, fetches the wasm module from `wasm_url`,
917/// then calls `main()` once the instance is ready.
918///
919/// The bridge's own top-level `import` lines (the tiny snippet helpers
920/// under `./snippets/`) are stripped and their bodies inlined, so the
921/// result is self-contained and the browser makes zero extra requests
922/// for the bridge file.
923/// # Arguments
924///
925/// - `&Path` - Directory holding the wasm-pack output.
926/// - `&str` - File name of the generated JS bridge inside it.
927/// - `&str` - URL the page will fetch the `.wasm` module from.
928///
929/// # Returns
930///
931/// - `Result<String, EuvError>` - The IIFE source, or an error if the
932///   bridge or one of its snippet imports cannot be read.
933pub(crate) async fn build_inline_bridge(
934    pkg_dir: &Path,
935    js_name: &str,
936    wasm_url: &str,
937) -> Result<String, EuvError> {
938    let js_path: std::path::PathBuf = pkg_dir.join(js_name);
939    let bridge_source: String =
940        read_to_string(&js_path)
941            .await
942            .map_err(|error: io::Error| EuvError::IoPath {
943                message: String::from("Failed to read wasm-bindgen JS bridge"),
944                path: js_path.clone(),
945                error,
946            })?;
947    let snippet_bodies: String = collect_snippet_bodies(&bridge_source, pkg_dir).await?;
948    let stripped: String = strip_module_imports(&bridge_source);
949    let js_only: String = strip_trailing_module_exports(&stripped);
950    let wasm_url_json: String = serde_json_wasm_url(wasm_url);
951    Ok(format!(
952        "(function() {{\n\
953         {snippet_bodies}\n\
954         {js_only}\n\
955         var __euv_wasm_url = {wasm_url_json};\n\
956         if (typeof __wbg_init === 'function') {{\n\
957         var __euv_base = (document.querySelector('base[href]') && document.baseURI) || location.href;\n         __wbg_init(new URL(__euv_wasm_url, __euv_base).toString()).then(function() {{\n\
958         if (typeof main === 'function') main();\n\
959         }}).catch(function(e) {{ console.error('[euv] inline bridge init failed:', e); }});\n\
960         }} else if (typeof initSync === 'function') {{\n\
961         initSync();\n\
962         if (typeof main === 'function') main();\n\
963         }} else {{\n\
964         console.error('[euv] inline bridge: no __wbg_init or initSync exported');\n\
965         }}\n\
966         }})();\n"
967    ))
968}
969
970/// Returns the inline `<script>` body to use when JS bridge inlining is
971/// disabled, restoring the classic module import as a fallback during
972/// wasm-pack output transitions.
973///
974/// # Arguments
975///
976/// - `&str` - The module import path the browser should resolve.
977///
978/// # Returns
979///
980/// - `String` - The `<script>` body, ready to be embedded in the page.
981pub(crate) fn build_module_fallback_bridge(import_path: &str) -> String {
982    format!(
983        "    import init, {{ main }} from '{import_path}';\n    \
984         await init();\n    \
985         main();\n"
986    )
987}
988
989/// Returns `true` when the inline bridge should be skipped.
990///
991/// # Returns
992///
993/// - `bool` - `true` if the env var is set to a non-empty value.
994pub(crate) fn inline_bridge_disabled() -> bool {
995    matches!(std::env::var(EUV_NO_INLINE_BRIDGE_ENV), Ok(value) if !value.is_empty())
996}
997
998/// Collects the source of every module the bridge imports, inlined in
999/// dependency order, and materialises each namespace import as a `var`
1000/// object so the bridge body keeps working without the module graph.
1001///
1002/// # Arguments
1003///
1004/// - `&str` - The bridge source whose `import` lines drive the scan.
1005/// - `&Path` - Directory holding the wasm-pack output, used to resolve
1006///   each relative specifier.
1007///
1008/// # Returns
1009///
1010/// - `Result<String, EuvError>` - The concatenated snippet bodies, or an
1011///   error if a specifier escapes the package directory.
1012async fn collect_snippet_bodies(bridge_source: &str, pkg_dir: &Path) -> Result<String, EuvError> {
1013    let mut bodies: String = String::new();
1014    let mut namespace_imports: Vec<NamespaceImport> = Vec::new();
1015    for line in bridge_source.lines() {
1016        let trimmed: &str = line.trim();
1017        if !trimmed.starts_with("import ") {
1018            continue;
1019        }
1020        let rest: &str = match trimmed.strip_prefix("import ") {
1021            Some(value) => value,
1022            None => continue,
1023        };
1024        let spec: &str = match extract_import_spec(rest) {
1025            Some(value) => value,
1026            None => continue,
1027        };
1028        if is_namespace_import(rest)
1029            && let Some(alias) = extract_namespace_alias(rest)
1030        {
1031            namespace_imports.push(NamespaceImport {
1032                alias: alias.to_string(),
1033                spec: spec.to_string(),
1034            });
1035        }
1036        let body: Option<String> = read_snippet_module(pkg_dir, spec).await?;
1037        if let Some(body) = body {
1038            if !bodies.is_empty() {
1039                bodies.push('\n');
1040            }
1041            bodies.push_str(&body);
1042        }
1043    }
1044    for ns in &namespace_imports {
1045        let snippet_path: std::path::PathBuf = resolve_snippet_path(pkg_dir, &ns.spec)?;
1046        let exports: Vec<String> = if snippet_path.exists() {
1047            let raw: String = read_to_string(&snippet_path)
1048                .await
1049                .map_err(|error: io::Error| EuvError::IoPath {
1050                    message: String::from(
1051                        "Failed to read wasm-pack snippet module for namespace export scan",
1052                    ),
1053                    path: snippet_path.clone(),
1054                    error,
1055                })?;
1056            extract_exported_function_names(&raw)
1057        } else {
1058            Vec::new()
1059        };
1060        if !bodies.is_empty() {
1061            bodies.push('\n');
1062        }
1063        bodies.push_str(&format!("var {} = {{", ns.alias));
1064        for (i, name) in exports.iter().enumerate() {
1065            if i > 0 {
1066                bodies.push(',');
1067            }
1068            bodies.push_str(name);
1069            bodies.push(':');
1070            bodies.push_str(name);
1071        }
1072        bodies.push_str("};\n");
1073    }
1074    Ok(bodies)
1075}
1076
1077/// Scans a snippet module for `export function NAME(` declarations and
1078/// returns their names. The IIFE relies on function declarations being
1079/// hoisted, so these names resolve to the same bindings that
1080/// wasm-bindgen's `__wbg_get_imports` expects.
1081///
1082/// # Arguments
1083///
1084/// - `&str` - The snippet module source to scan.
1085///
1086/// # Returns
1087///
1088/// - `Vec<String>` - Every exported function name, in source order.
1089pub fn extract_exported_function_names(source: &str) -> Vec<String> {
1090    let mut names: Vec<String> = Vec::new();
1091    for line in source.lines() {
1092        let trimmed: &str = line.trim();
1093        if let Some(rest) = trimmed.strip_prefix("export function ") {
1094            let name_end: usize =
1095                match rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') {
1096                    Some(value) => value,
1097                    None => rest.len(),
1098                };
1099            let name: &str = &rest[..name_end];
1100            if !name.is_empty() {
1101                names.push(name.to_string());
1102            }
1103        }
1104    }
1105    names
1106}
1107
1108/// Returns `true` when the clause is a namespace import (`import * as X`).
1109///
1110/// # Arguments
1111///
1112/// - `&str` - The import clause, without its leading `import` keyword.
1113///
1114/// # Returns
1115///
1116/// - `bool` - `true` for `* as <alias>` clauses.
1117pub fn is_namespace_import(rest: &str) -> bool {
1118    rest.trim_start().starts_with('*')
1119}
1120
1121/// Extracts the local binding a namespace import introduces.
1122///
1123/// # Arguments
1124///
1125/// - `&str` - The import clause, without its leading `import` keyword.
1126///
1127/// # Returns
1128///
1129/// - `Option<&str>` - The alias, or `None` if the clause is not a
1130///   namespace import or carries no name.
1131pub fn extract_namespace_alias(rest: &str) -> Option<&str> {
1132    let rest: &str = rest.trim_end_matches(';').trim();
1133    if !rest.starts_with('*') {
1134        return None;
1135    }
1136    let rest: &str = rest.strip_prefix('*')?.trim();
1137    let rest: &str = rest.strip_prefix("as")?.trim();
1138    let end: usize = match rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') {
1139        Some(value) => value,
1140        None => rest.len(),
1141    };
1142    let alias: &str = &rest[..end];
1143    if alias.is_empty() { None } else { Some(alias) }
1144}
1145
1146/// Extracts the relative specifier an import clause resolves against.
1147///
1148/// Bare specifiers are rejected: only `./` and `../` targets are inlined,
1149/// because anything else would drag in a module this code does not
1150/// control.
1151///
1152/// # Arguments
1153///
1154/// - `&str` - The import clause, without its leading `import` keyword.
1155///
1156/// # Returns
1157///
1158/// - `Option<&str>` - The specifier with its quotes stripped, or `None`
1159///   when the clause has no `from` or names a non-relative module.
1160pub fn extract_import_spec(rest: &str) -> Option<&str> {
1161    let rest: &str = rest.trim_end_matches(';').trim();
1162    let from_idx: usize = rest.find(" from ")?;
1163    let spec_part: &str = rest[from_idx + " from ".len()..].trim();
1164    let spec: &str = spec_part
1165        .trim_start_matches('\'')
1166        .trim_start_matches('"')
1167        .trim_end_matches('\'')
1168        .trim_end_matches('"');
1169    if spec.starts_with("./") || spec.starts_with("../") {
1170        Some(spec)
1171    } else {
1172        None
1173    }
1174}
1175
1176/// Reads one snippet module and returns its body with the `export` keyword
1177/// stripped, so it can be concatenated into the IIFE as a plain
1178/// declaration.
1179///
1180/// # Arguments
1181///
1182/// - `&Path` - Directory holding the wasm-pack output.
1183/// - `&str` - Relative specifier of the snippet to read.
1184///
1185/// # Returns
1186///
1187/// - `Result<Option<String>, EuvError>` - The stripped body, `None` if the
1188///   file does not exist, or an error if it cannot be read or decoded.
1189async fn read_snippet_module(pkg_dir: &Path, spec: &str) -> Result<Option<String>, EuvError> {
1190    let snippet_path: std::path::PathBuf = resolve_snippet_path(pkg_dir, spec)?;
1191    if !snippet_path.exists() {
1192        return Ok(None);
1193    }
1194    let bytes: Vec<u8> =
1195        read(&snippet_path)
1196            .await
1197            .map_err(|error: io::Error| EuvError::IoPath {
1198                message: String::from("Failed to read wasm-pack snippet module"),
1199                path: snippet_path.clone(),
1200                error,
1201            })?;
1202    let raw: String = String::from_utf8(bytes).map_err(|error: FromUtf8Error| EuvError::Utf8 {
1203        message: String::from("Snippet module is not valid UTF-8"),
1204        error,
1205    })?;
1206    Ok(Some(strip_snippet_export(&raw)))
1207}
1208
1209/// Resolves a relative specifier to a path inside `pkg_dir`.
1210///
1211/// # Arguments
1212///
1213/// - `&Path` - Directory holding the wasm-pack output.
1214/// - `&str` - Relative specifier taken from the bridge's import clause.
1215///
1216/// # Returns
1217///
1218/// - `Result<std::path::PathBuf, EuvError>` - The resolved path, or an error if the
1219///   specifier climbs above `pkg_dir`.
1220fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result<std::path::PathBuf, EuvError> {
1221    let base: &str = spec.trim_start_matches('.').trim_start_matches('/');
1222    let mut path: std::path::PathBuf = pkg_dir.to_path_buf();
1223    for segment in base.split('/') {
1224        if segment.is_empty() || segment == "." {
1225            continue;
1226        }
1227        if segment == ".." {
1228            return Err(EuvError::Message(String::from(
1229                "Snippet import spec escapes pkg directory",
1230            )));
1231        }
1232        path.push(segment);
1233    }
1234    Ok(path)
1235}
1236
1237/// Rewrites a snippet module's source into bare declarations by removing
1238/// the `export ` prefix from every line that carries one.
1239///
1240/// # Arguments
1241///
1242/// - `&str` - The snippet module source.
1243///
1244/// # Returns
1245///
1246/// - `String` - The same source with the `export ` keywords removed.
1247fn strip_snippet_export(raw: &str) -> String {
1248    let mut out: String = String::with_capacity(raw.len());
1249    for line in raw.lines() {
1250        let trimmed: &str = line.trim();
1251        if trimmed.starts_with("export ") {
1252            if let Some(rest) = trimmed.strip_prefix("export ") {
1253                out.push_str(rest);
1254                out.push('\n');
1255            }
1256        } else {
1257            out.push_str(line);
1258            out.push('\n');
1259        }
1260    }
1261    out
1262}
1263
1264/// Removes the bridge's own `import` lines and any line referencing
1265/// `import.meta.url`, since the inlined IIFE has neither.
1266///
1267/// # Arguments
1268///
1269/// - `&str` - The bridge source.
1270///
1271/// # Returns
1272///
1273/// - `String` - The source with those lines dropped.
1274fn strip_module_imports(source: &str) -> String {
1275    let mut out: String = String::with_capacity(source.len());
1276    for line in source.lines() {
1277        let trimmed: &str = line.trim();
1278        if trimmed.starts_with("import ") {
1279            continue;
1280        }
1281        if trimmed.contains("import.meta.url") {
1282            continue;
1283        }
1284        out.push_str(line);
1285        out.push('\n');
1286    }
1287    out
1288}
1289
1290/// Removes the bridge's trailing `export { ... }` and `export function`
1291/// lines, turning each exported alias into the assignment that keeps the
1292/// local function reachable under its exported name.
1293///
1294/// # Arguments
1295///
1296/// - `&str` - The bridge source, after [`strip_module_imports`].
1297///
1298/// # Returns
1299///
1300/// - `String` - The source with its module-level export syntax removed.
1301fn strip_trailing_module_exports(source: &str) -> String {
1302    let mut out: String = String::with_capacity(source.len());
1303    for line in source.lines() {
1304        let trimmed: &str = line.trim();
1305        if trimmed.starts_with("export ") && trimmed.contains('{') && trimmed.contains('}') {
1306            let inner: &str = &trimmed["export ".len()..];
1307            let stripped: &str = match inner.strip_prefix('{') {
1308                Some(value) => value,
1309                None => {
1310                    out.push_str(line);
1311                    out.push('\n');
1312                    continue;
1313                }
1314            };
1315            let close: usize = match stripped.rfind('}') {
1316                Some(value) => value,
1317                None => {
1318                    out.push_str(line);
1319                    out.push('\n');
1320                    continue;
1321                }
1322            };
1323            let names_block: &str = &stripped[..close];
1324            let inlined: String = inline_export_aliases(names_block.trim());
1325            if !inlined.is_empty() {
1326                out.push_str(&inlined);
1327                out.push('\n');
1328            }
1329            continue;
1330        }
1331        if trimmed.starts_with("export function ") {
1332            let rest: &str = match trimmed.strip_prefix("export function ") {
1333                Some(value) => value,
1334                None => {
1335                    out.push_str(line);
1336                    out.push('\n');
1337                    continue;
1338                }
1339            };
1340            let name_end: usize = match rest.find(|c: char| !c.is_alphanumeric() && c != '_') {
1341                Some(value) => value,
1342                None => rest.len(),
1343            };
1344            let name: &str = &rest[..name_end];
1345            if !name.is_empty() {
1346                out.push_str("function ");
1347                out.push_str(name);
1348                out.push_str(&rest[name_end..]);
1349                out.push('\n');
1350                continue;
1351            }
1352        }
1353        out.push_str(line);
1354        out.push('\n');
1355    }
1356    out
1357}
1358
1359/// Converts the body of an `export { a, b as c }` clause into the
1360/// assignments that keep each local function reachable under its
1361/// exported name. Exports of `wasm` are dropped: the IIFE is a plain
1362/// `<script>`, so there is no `wasm` binding in scope.
1363///
1364/// # Arguments
1365///
1366/// - `&str` - The comma-separated body between the export braces.
1367///
1368/// # Returns
1369///
1370/// - `String` - The assignments, one per line, or empty when every alias
1371///   is a no-op.
1372fn inline_export_aliases(block: &str) -> String {
1373    let mut out: String = String::new();
1374    for entry in block.split(',') {
1375        let entry: &str = entry.trim();
1376        if entry.is_empty() {
1377            continue;
1378        }
1379        if let Some((original, exported)) = entry.split_once(" as ") {
1380            let original: &str = original.trim();
1381            let exported: &str = exported.trim();
1382            if original == exported || exported == "default" || original == "wasm" {
1383                continue;
1384            }
1385            out.push_str(exported);
1386            out.push_str(" = ");
1387            out.push_str(original);
1388            out.push_str(";\n");
1389        }
1390    }
1391    out
1392}
1393
1394/// Encodes a URL as a JSON string literal, including the surrounding
1395/// quotes.
1396///
1397/// Hand-rolled rather than `serde_json::to_string` because the value is
1398/// interpolated into a template that is already assembling JavaScript,
1399/// and the only characters that can appear here are URL characters.
1400///
1401/// # Arguments
1402///
1403/// - `&str` - The URL to encode.
1404///
1405/// # Returns
1406///
1407/// - `String` - A quoted, escaped JSON string.
1408fn serde_json_wasm_url(url: &str) -> String {
1409    let mut out: String = String::with_capacity(url.len() + 2);
1410    out.push('"');
1411    for ch in url.chars() {
1412        match ch {
1413            '"' => out.push_str("\\\""),
1414            '\\' => out.push_str("\\\\"),
1415            '\n' => out.push_str("\\n"),
1416            '\r' => out.push_str("\\r"),
1417            '\t' => out.push_str("\\t"),
1418            '\u{08}' => out.push_str("\\b"),
1419            '\u{0C}' => out.push_str("\\f"),
1420            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1421            c => out.push(c),
1422        }
1423    }
1424    out.push('"');
1425    out
1426}