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        // Compare the flag NAME, not the whole argument: clap accepts both
52        // `--port 3000` and `--port=3000`, and only the first form ever
53        // matched `EUV_ARGS` exactly. Without the split, `--port=3000` was
54        // forwarded to wasm-pack even though `reconcile_args` had already
55        // consumed its value — the same string both used and leaked.
56        let name: &str = arg.split('=').next().unwrap_or(arg.as_str());
57        if EUV_ARGS.contains(&name) {
58            if arg.contains('=') || name == NO_GITIGNORE_ARG {
59                // `--no-gitignore` is a bare boolean: there is no value to
60                // skip, so treating it like `--port 3000` ate whatever
61                // argument followed it.
62                continue;
63            }
64            skip_next = true;
65            continue;
66        }
67        filtered.push(arg.clone());
68    }
69    filtered
70}
71
72/// Reconciles euv-specific arguments that may have been collected into
73/// `wasm_pack_args` (e.g. when placed after `--`) back into the
74/// corresponding `ModeArgs` fields.
75///
76/// Because clap's `trailing_var_arg` collects all unrecognized arguments
77/// into `wasm_pack_args`, any euv flag placed after `--` is not parsed
78/// by clap into its dedicated field. This function scans `wasm_pack_args`
79/// for known euv flags and overwrites the `ModeArgs` fields so that the
80/// rest of the codebase can rely on the typed accessors regardless of
81/// argument order.
82///
83/// # Arguments
84///
85/// - `&mut ModeArgs` - The CLI arguments to reconcile in-place.
86pub fn reconcile_args(args: &mut ModeArgs) {
87    let wasm_pack_args: Vec<String> = args.get_wasm_pack_args().clone();
88    let mut crate_path: Option<PathBuf> = None;
89    let mut port: Option<u16> = None;
90    let mut www_dir: Option<String> = None;
91    let mut index_html: Option<Option<PathBuf>> = None;
92    let mut no_gitignore: Option<bool> = None;
93    let mut dev: Option<bool> = None;
94    let mut release: Option<bool> = None;
95    let mut profiling: Option<bool> = None;
96    let mut iter: Iter<String> = wasm_pack_args.iter();
97    while let Some(arg) = iter.next() {
98        match arg.as_str() {
99            CRATE_PATH_ARG | CRATE_PATH_ARG_SHORT => {
100                if let Some(value) = iter.next() {
101                    crate_path = Some(PathBuf::from(value));
102                }
103            }
104            PORT_ARG | PORT_ARG_SHORT => {
105                if let Some(value) = iter.next()
106                    && let Ok(parsed_port) = value.parse::<u16>()
107                {
108                    port = Some(parsed_port);
109                }
110            }
111            WWW_DIR_ARG => {
112                if let Some(value) = iter.next() {
113                    www_dir = Some(value.clone());
114                }
115            }
116            INDEX_HTML_ARG => {
117                if let Some(value) = iter.next() {
118                    index_html = Some(Some(PathBuf::from(value)));
119                }
120            }
121            NO_GITIGNORE_ARG => {
122                no_gitignore = Some(true);
123            }
124            DEV_FLAG => {
125                dev = Some(true);
126            }
127            RELEASE_FLAG => {
128                release = Some(true);
129            }
130            PROFILING_FLAG => {
131                profiling = Some(true);
132            }
133            other => {
134                if let Some(value) = other.strip_prefix(&format!("{CRATE_PATH_ARG}=")) {
135                    crate_path = Some(PathBuf::from(value));
136                } else if let Some(value) = other.strip_prefix(&format!("{PORT_ARG}=")) {
137                    if let Ok(parsed_port) = value.parse::<u16>() {
138                        port = Some(parsed_port);
139                    }
140                } else if let Some(value) = other.strip_prefix(&format!("{WWW_DIR_ARG}=")) {
141                    www_dir = Some(value.to_string());
142                } else if let Some(value) = other.strip_prefix(&format!("{INDEX_HTML_ARG}=")) {
143                    index_html = Some(Some(PathBuf::from(value)));
144                }
145            }
146        }
147    }
148    if let Some(value) = crate_path {
149        args.set_crate_path(value);
150    }
151    if let Some(value) = port {
152        args.set_port(value);
153    }
154    if let Some(value) = www_dir {
155        args.set_www_dir(value);
156    }
157    if let Some(value) = index_html {
158        args.set_index_html(value);
159    }
160    if let Some(value) = no_gitignore {
161        args.set_no_gitignore(value);
162    }
163    if let Some(value) = dev {
164        args.set_dev(value);
165    }
166    if let Some(value) = release {
167        args.set_release(value);
168    }
169    if let Some(value) = profiling {
170        args.set_profiling(value);
171    }
172}
173
174/// Resolves the build mode from CLI arguments.
175///
176/// First checks the explicit `--dev`, `--release`, and `--profiling` flags on `ModeArgs`.
177/// If none of those are set, inspects `wasm_pack_args` for any build mode flag
178/// that may have been forwarded by the user.
179/// Defaults to `BuildMode::Dev` if no build mode flag is found anywhere.
180///
181/// # Arguments
182///
183/// - `&ModeArgs` - The CLI arguments containing the build mode flags and wasm_pack_args.
184///
185/// # Returns
186///
187/// - `BuildMode` - The resolved build mode.
188pub fn resolve_build_mode(args: &ModeArgs) -> BuildMode {
189    if args.get_profiling() {
190        BuildMode::Profiling
191    } else if args.get_release() {
192        BuildMode::Release
193    } else if args.get_dev() {
194        BuildMode::Dev
195    } else if args
196        .get_wasm_pack_args()
197        .iter()
198        .any(|arg: &String| arg == PROFILING_FLAG)
199    {
200        BuildMode::Profiling
201    } else if args
202        .get_wasm_pack_args()
203        .iter()
204        .any(|arg: &String| arg == RELEASE_FLAG)
205    {
206        BuildMode::Release
207    } else {
208        BuildMode::Dev
209    }
210}
211
212/// Converts a `BuildMode` to the corresponding wasm-pack flag string.
213///
214/// # Arguments
215///
216/// - `BuildMode` - The build mode to convert.
217///
218/// # Returns
219///
220/// - `&'static str` - The wasm-pack command-line flag.
221pub fn build_mode_to_flag(build_mode: BuildMode) -> &'static str {
222    match build_mode {
223        BuildMode::Dev => DEV_FLAG,
224        BuildMode::Release => RELEASE_FLAG,
225        BuildMode::Profiling => PROFILING_FLAG,
226    }
227}
228
229/// Builds a `Gitignore` matcher from the `.gitignore` file at the given root path.
230///
231/// # Arguments
232///
233/// - `&PathBuf` - The root directory where `.gitignore` is located.
234///
235/// # Returns
236///
237/// - `Gitignore` - The compiled gitignore matcher.
238async fn build_gitignore(root: &PathBuf) -> Gitignore {
239    let gitignore_path: PathBuf = root.join(GITIGNORE_FILE_NAME);
240    let mut builder: GitignoreBuilder = GitignoreBuilder::new(root);
241    let gitignore_exists: bool = metadata(&gitignore_path).await.is_ok();
242    if gitignore_exists && let Some(error) = builder.add(&gitignore_path) {
243        log::warn!("Failed to load .gitignore: {error}");
244    }
245    match builder.build() {
246        Ok(gitignore) => {
247            if gitignore_exists {
248                log::info!("Loaded .gitignore to filter file change events");
249            }
250            gitignore
251        }
252        Err(error) => {
253            log::warn!("Failed to build gitignore matcher: {error}");
254            GitignoreBuilder::new(root)
255                .build()
256                .unwrap_or_else(|_error: ignore::Error| Gitignore::empty())
257        }
258    }
259}
260
261/// Extracts the value of `--out-name` from the wasm-pack arguments.
262///
263/// Returns `None` if `--out-name` is not specified.
264///
265/// # Arguments
266///
267/// - `&[String]` - The wasm-pack arguments to search.
268///
269/// # Returns
270///
271/// - `Option<String>` - The value of `--out-name` if found.
272fn extract_out_name(wasm_pack_args: &[String]) -> Option<String> {
273    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
274    while let Some(arg) = iter.next() {
275        if arg == OUT_NAME_ARG {
276            return iter.next().cloned();
277        }
278        if let Some(value) = arg.strip_prefix(&format!("{OUT_NAME_ARG}=")) {
279            return Some(value.to_string());
280        }
281    }
282    None
283}
284
285/// Extracts the value of `--out-dir` from the wasm-pack arguments.
286///
287/// Returns `None` if `--out-dir` is not specified.
288///
289/// # Arguments
290///
291/// - `&[String]` - The wasm-pack arguments to search.
292///
293/// # Returns
294///
295/// - `Option<String>` - The value of `--out-dir` if found.
296fn extract_out_dir(wasm_pack_args: &[String]) -> Option<String> {
297    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
298    while let Some(arg) = iter.next() {
299        if arg == OUT_DIR_ARG {
300            return iter.next().cloned();
301        }
302        if let Some(value) = arg.strip_prefix(&format!("{OUT_DIR_ARG}=")) {
303            return Some(value.to_string());
304        }
305    }
306    None
307}
308
309/// Resolves the output JS filename for HTML generation.
310///
311/// Uses `--out-name` from wasm-pack args if specified,
312/// otherwise reads the crate name from `Cargo.toml` `[package] name` field
313/// and replaces hyphens with underscores (matching wasm-pack behavior).
314/// Appends `.js` extension to form the complete JS filename.
315///
316/// # Arguments
317///
318/// - `&ModeArgs` - The CLI arguments containing crate_path and wasm_pack_args.
319///
320/// # Returns
321///
322/// - `String` - The resolved JS filename with `.js` extension (e.g. `euv_example.js`).
323pub fn resolve_out_name(args: &ModeArgs) -> String {
324    let name: String = if let Some(out_name) = extract_out_name(args.get_wasm_pack_args()) {
325        out_name.replace(STR_HYPHEN, STR_UNDERSCORE)
326    } else {
327        let cargo_toml_path: PathBuf = args.get_crate_path().join(CARGO_TOML_FILE_NAME);
328        read_crate_name_from_toml(&cargo_toml_path)
329            .unwrap_or_else(|| {
330                args.get_crate_path()
331                    .file_name()
332                    .unwrap_or_default()
333                    .to_string_lossy()
334                    .to_string()
335            })
336            .replace(STR_HYPHEN, STR_UNDERSCORE)
337    };
338    format!("{name}{JS_EXTENSION}")
339}
340
341/// Reads the `name` field from a Cargo.toml file.
342///
343/// Parses the file line-by-line looking for `name = "..."` within the `[package]` section.
344///
345/// # Arguments
346///
347/// - `&Path` - The path to the Cargo.toml file.
348///
349/// # Returns
350///
351/// - `Option<String>` - The crate name if found.
352fn read_crate_name_from_toml(path: &Path) -> Option<String> {
353    let content: String = sync_read_to_string(path).ok()?;
354    let mut in_package: bool = false;
355    for line in content.lines() {
356        let trimmed: &str = line.trim();
357        if trimmed.starts_with('[') {
358            in_package = trimmed == "[package]";
359            continue;
360        }
361        if in_package
362            && trimmed.starts_with("name")
363            && let Some(value) = trimmed.strip_prefix("name")
364        {
365            let value: &str = value.trim().strip_prefix('=')?.trim();
366            let value: &str = value.strip_prefix('"')?.strip_suffix('"')?;
367            return Some(value.to_string());
368        }
369    }
370    None
371}
372
373/// Computes the relative path from a base directory to a target directory.
374///
375/// Compares the component sequences of both paths to find the common prefix,
376/// then emits `..` for each remaining base component followed by the remaining
377/// target components.
378///
379/// # Arguments
380///
381/// - `&Path` - The base directory path.
382/// - `&Path` - The target directory path.
383///
384/// # Returns
385///
386/// - `PathBuf` - The relative path from base to target.
387fn compute_relative_path(base: &Path, target: &Path) -> PathBuf {
388    let base_components: Vec<Component> = base.components().collect();
389    let target_components: Vec<Component> = target.components().collect();
390    let common_len: usize = base_components
391        .iter()
392        .zip(target_components.iter())
393        .take_while(
394            |(base_component, target_component): &(&Component, &Component)| {
395                base_component == target_component
396            },
397        )
398        .count();
399    let mut result: PathBuf = PathBuf::new();
400    for _ in &base_components[common_len..] {
401        result.push("..");
402    }
403    for component in &target_components[common_len..] {
404        if let Component::Normal(os_str) = component {
405            result.push(os_str);
406        }
407    }
408    result
409}
410
411/// Resolves the serving root directory for the development server.
412///
413/// When the output directory is inside the www directory, returns the resolved www directory.
414/// When the output directory is outside the www directory, returns the parent of the output directory
415/// so that `index.html` and WASM artifacts are co-located under the same serving root.
416///
417/// # Arguments
418///
419/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
420///
421/// # Returns
422///
423/// - `PathBuf` - The resolved serving root directory.
424pub async fn resolve_serving_root(args: &ModeArgs) -> PathBuf {
425    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
426    let out_dir_absolute: PathBuf = resolve_out_dir(args);
427    if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
428        resolve_www_dir(&www_absolute).await
429    } else {
430        out_dir_absolute
431            .parent()
432            .map(|p: &Path| p.to_path_buf())
433            .unwrap_or_else(|| www_absolute)
434    }
435}
436
437/// Resolves the serving route prefix relative to the crate path.
438///
439/// Returns the forward-slash-separated path of the serving root relative to the crate path.
440/// Used for server route registration and URL display.
441///
442/// # Arguments
443///
444/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
445///
446/// # Returns
447///
448/// - `String` - The serving route prefix (e.g. `www` or `wwws`).
449pub fn resolve_serving_route_prefix(args: &ModeArgs) -> String {
450    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
451    let out_dir_absolute: PathBuf = resolve_out_dir(args);
452    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
453        www_absolute
454    } else {
455        out_dir_absolute
456            .parent()
457            .map(|p: &Path| p.to_path_buf())
458            .unwrap_or_else(|| www_absolute)
459    };
460    serving_root
461        .strip_prefix(args.get_crate_path())
462        .map(|rel: &Path| {
463            rel.to_string_lossy()
464                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
465        })
466        .unwrap_or_else(|_| {
467            serving_root
468                .file_name()
469                .and_then(|name: &ffi::OsStr| name.to_str())
470                .unwrap_or_default()
471                .to_string()
472        })
473}
474
475/// Resolves the JS import path for HTML generation.
476///
477/// Computes the relative path from the serving root to the output directory,
478/// then appends the JS filename (from `resolve_out_name`, which includes `.js`)
479/// to form the full import path (e.g. `./pkg/euv.js` or `./pksg/cc.js`).
480///
481/// # Arguments
482///
483/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
484///
485/// # Returns
486///
487/// - `String` - The resolved JS import path relative to the serving root.
488pub fn resolve_import_path(args: &ModeArgs) -> String {
489    let out_name: String = resolve_out_name(args);
490    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
491    let out_dir_absolute: PathBuf = resolve_out_dir(args);
492    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
493        www_absolute
494    } else {
495        out_dir_absolute
496            .parent()
497            .map(|p: &Path| p.to_path_buf())
498            .unwrap_or_else(|| www_absolute)
499    };
500    let relative: PathBuf = compute_relative_path(&serving_root, &out_dir_absolute);
501    let mut components: Vec<String> = relative
502        .components()
503        .filter_map(|component: Component| match component {
504            Component::Normal(os_str) => os_str.to_str().map(|text: &str| text.to_string()),
505            Component::ParentDir => Some(PARENT_DIR.to_string()),
506            _ => None,
507        })
508        .collect();
509    components.push(out_name);
510    format!("{RELATIVE_PATH_PREFIX}{}", components.join(PATH_SEPARATOR))
511}
512
513/// Resolves the output directory for wasm-pack artifacts.
514///
515/// Uses `--out-dir` from wasm-pack args if specified,
516/// otherwise defaults to `{www_dir}/pkg` so that build artifacts
517/// are placed directly inside the www directory served
518/// by the development server.
519///
520/// # Arguments
521///
522/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
523///
524/// # Returns
525///
526/// - `PathBuf` - The resolved output directory (absolute if crate_path is joined).
527pub fn resolve_out_dir(args: &ModeArgs) -> PathBuf {
528    let out_dir_path: PathBuf = PathBuf::from(
529        extract_out_dir(args.get_wasm_pack_args())
530            .unwrap_or_else(|| format!("{}/{PKG_DIR_NAME}", args.get_www_dir())),
531    );
532    if out_dir_path.is_absolute() {
533        out_dir_path
534    } else {
535        args.get_crate_path().join(&out_dir_path)
536    }
537}
538
539/// Formats euv macros, builds WASM, writes HTML, then removes generated glue.
540///
541/// The generated `.js`, `.d.ts`, `.js.map` and `snippets/` files are removed
542/// only after the HTML carrying their inlined bootstrap has been written, so a
543/// failed build leaves its artifacts on disk for diagnosis rather than deleting
544/// the evidence and reporting success.
545///
546/// # Arguments
547///
548/// - `&ModeArgs` - The CLI arguments.
549///
550/// # Returns
551///
552/// - `Result<(), EuvError>` - Indicates success or failure of the build.
553pub async fn run_build_only_pipeline(args: &ModeArgs) -> Result<(), EuvError> {
554    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
555    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
556        log::warn!("euv fmt error: {error}");
557    }
558    build_wasm(args).await?;
559    log::info!("WASM build completed successfully");
560    finalize_html_output(args, false).await?;
561    Ok(())
562}
563
564/// Writes the page and removes the JavaScript it inlined, in that order.
565///
566/// Every failure propagates: a build that cannot inline its bridge, cannot
567/// write its page, or cannot delete the JavaScript it just inlined reports the
568/// failure rather than shipping a page that imports a deleted file.
569///
570/// # Arguments
571///
572/// - `&ModeArgs` - The CLI arguments.
573/// - `bool` - Whether the page is being generated for the dev server, which
574///   injects a `<base href>` tag.
575///
576/// # Returns
577///
578/// - `Result<String, EuvError>` - The generated HTML.
579pub(crate) async fn finalize_html_output(
580    args: &ModeArgs,
581    is_dev_server: bool,
582) -> Result<String, EuvError> {
583    let html_config: HtmlConfig = HtmlConfig::new(
584        resolve_serving_root(args).await,
585        resolve_import_path(args),
586        resolve_build_mode(args) == BuildMode::Release,
587        args.try_get_index_html().clone(),
588        is_dev_server.then(|| resolve_serving_route_prefix(args)),
589    );
590    let html: String = generate_html(&html_config).await?;
591    let pkg_dir: PathBuf = resolve_out_dir(args);
592    let removed: Vec<PathBuf> = remove_generated_js(&pkg_dir).await?;
593    if !removed.is_empty() {
594        log::info!(
595            "Inlined {} generated JavaScript file(s) into {} and removed them from {}",
596            removed.len(),
597            INDEX_HTML_FILE_NAME,
598            pkg_dir.display()
599        );
600    }
601    assert_no_js_artifacts(&pkg_dir).await?;
602    Ok(html)
603}
604
605/// Fails when the wasm-pack output directory still holds a JavaScript file
606/// this build is responsible for.
607///
608/// Catches the case where a delete silently missed a file: the page would
609/// still boot, but the deployed tree would carry the glue the build just
610/// claimed to have removed.
611///
612/// # Arguments
613///
614/// - `&Path` - The wasm-pack output directory.
615///
616/// # Returns
617///
618/// - `Result<(), EuvError>` - `Ok` when no generated JavaScript remains.
619async fn assert_no_js_artifacts(pkg_dir: &Path) -> Result<(), EuvError> {
620    let mut pending: Vec<PathBuf> = vec![pkg_dir.to_path_buf()];
621    while let Some(dir) = pending.pop() {
622        let mut entries: ReadDir =
623            read_dir(&dir)
624                .await
625                .map_err(|error: io::Error| EuvError::IoPath {
626                    message: String::from("Failed to inspect finalized WASM output"),
627                    path: dir.clone(),
628                    error,
629                })?;
630        while let Some(entry) =
631            entries
632                .next_entry()
633                .await
634                .map_err(|error: io::Error| EuvError::IoPath {
635                    message: String::from("Failed to inspect an entry of finalized WASM output"),
636                    path: dir.clone(),
637                    error,
638                })?
639        {
640            let path: PathBuf = entry.path();
641            let file_type: FileType =
642                entry
643                    .file_type()
644                    .await
645                    .map_err(|error: io::Error| EuvError::IoPath {
646                        message: String::from("Failed to inspect a finalized WASM output file"),
647                        path: path.clone(),
648                        error,
649                    })?;
650            if file_type.is_dir() {
651                pending.push(path);
652                continue;
653            }
654            if is_generated_js_artifact(&path) {
655                return Err(EuvError::Message(format!(
656                    "{} survived the cleanup, so this build would deploy JavaScript; \
657                     the page no longer imports it, and it cannot be served",
658                    path.display()
659                )));
660            }
661        }
662    }
663    Ok(())
664}
665
666/// Cleans the output directory before a fresh build.
667///
668/// Removes all files and subdirectories within the output directory
669/// so that stale artifacts from previous builds do not remain.
670/// The directory itself is preserved (recreated if missing).
671///
672/// # Arguments
673///
674/// - `&Path` - The output directory to clean.
675pub async fn clean_out_dir(out_dir: &Path) {
676    let mut entries: ReadDir = match read_dir(out_dir).await {
677        Ok(dir) => dir,
678        Err(_) => return,
679    };
680    while let Ok(Some(entry)) = entries.next_entry().await {
681        let path: PathBuf = entry.path();
682        if path.is_dir() {
683            if let Err(error) = remove_dir_all(&path).await {
684                log::warn!("Failed to remove directory '{}': {error}", path.display());
685            }
686        } else if let Err(error) = remove_file(&path).await {
687            log::warn!("Failed to remove file '{}': {error}", path.display());
688        }
689    }
690}
691
692/// Executes a full build pipeline: euv fmt, build wasm, generate HTML, then
693/// remove the JavaScript that was inlined into it.
694///
695/// Only a pipeline whose wasm build, page write and JavaScript cleanup all
696/// succeeded broadcasts a reload. A failure at any phase notifies the client
697/// of the error and leaves the previous page in memory, so a browser is never
698/// pointed at a page whose inlined bootstrap references a deleted file.
699///
700/// # Arguments
701///
702/// - `&ModeArgs` - The CLI arguments.
703/// - `Option<&broadcast::Sender<ReloadEvent>>` - Optional reload channel for notifying clients.
704///
705/// # Returns
706///
707/// - `Result<String, EuvError>` - The generated HTML with reload script injected on success.
708pub async fn run_build_pipeline(
709    args: &ModeArgs,
710    reload_tx: Option<&broadcast::Sender<ReloadEvent>>,
711) -> Result<String, EuvError> {
712    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
713    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
714        log::warn!("euv fmt error: {error}");
715    }
716    if let Err(error) = build_wasm(args).await {
717        log::error!("WASM build failed: {error}");
718        notify_reload(reload_tx, Err(error.to_string()));
719        return Err(error);
720    }
721    log::info!("WASM build completed successfully");
722    let html: String = match finalize_html_output(args, true).await {
723        Ok(html) => html,
724        Err(error) => {
725            log::error!("Page generation failed: {error}");
726            notify_reload(reload_tx, Err(error.to_string()));
727            return Err(error);
728        }
729    };
730    notify_reload(reload_tx, Ok(()));
731    Ok(html)
732}
733
734/// Sends one reload event to the dev server's clients.
735///
736/// # Arguments
737///
738/// - `Option<&broadcast::Sender<ReloadEvent>>` - The channel, when the caller has one.
739/// - `Result<(), String>` - `Ok` to ask clients to reload, `Err` to tell them
740///   why the rebuild failed.
741fn notify_reload(reload_tx: Option<&broadcast::Sender<ReloadEvent>>, outcome: Result<(), String>) {
742    let Some(sender) = reload_tx else {
743        return;
744    };
745    let event: ReloadEvent = match outcome {
746        Ok(()) => ReloadEvent::Reload,
747        Err(message) => ReloadEvent::Error(message),
748    };
749    let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
750        sender.send(event);
751}
752
753/// Watches source files and triggers WASM builds.
754///
755/// # Arguments
756///
757/// - `Arc<AppState>` - The shared application state.
758///
759/// # Returns
760///
761/// - `Result<(), EuvError>` - Indicates success or failure of the file watcher.
762pub(crate) async fn watch_and_build(state: Arc<AppState>) -> Result<(), EuvError> {
763    let crate_path: PathBuf = state.get_args().get_crate_path().clone();
764    let src_path: PathBuf = crate_path.join(SRC_DIR_NAME);
765    let gitignore: Gitignore = build_gitignore(&crate_path).await;
766    let (tx, mut rx): (Sender<Event>, Receiver<Event>) = channel(32);
767    let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
768        move |result: Result<Event, notify::Error>| {
769            if let Ok(event) = result {
770                let _: Result<(), tokio::sync::mpsc::error::SendError<Event>> =
771                    tx.blocking_send(event);
772            }
773        },
774        Config::default(),
775    )?;
776    watcher.watch(&src_path, RecursiveMode::Recursive)?;
777    log::info!("Watching {} for changes...", src_path.display());
778    let mut debounce: Interval = interval(Duration::from_millis(500));
779    debounce.tick().await;
780    while let Some(event) = rx.recv().await {
781        let filtered_paths: Vec<String> = event
782            .paths
783            .iter()
784            .filter(|path: &&PathBuf| !gitignore.matched(*path, path.is_dir()).is_ignore())
785            .map(|path: &PathBuf| path.display().to_string())
786            .collect();
787        if filtered_paths.is_empty() {
788            continue;
789        }
790        log::warn!("File change detected: {}", filtered_paths.join(", "));
791        debounce.reset();
792        sleep(Duration::from_millis(300)).await;
793        let mut building: RwLockWriteGuard<bool> = state.get_is_building().write().await;
794        if *building {
795            continue;
796        }
797        *building = true;
798        drop(building);
799        let state_for_build: Arc<AppState> = Arc::clone(&state);
800        spawn(async move {
801            let args: ModeArgs = state_for_build.get_args().clone();
802            let reload_tx: broadcast::Sender<ReloadEvent> = state_for_build.get_reload_tx().clone();
803            match run_build_pipeline(&args, Some(&reload_tx)).await {
804                Ok(html) => {
805                    let mut content: RwLockWriteGuard<String> =
806                        state_for_build.get_html_content().write().await;
807                    *content = html;
808                }
809                Err(error) => {
810                    log::error!("Build pipeline error: {error}");
811                }
812            }
813            let mut building: RwLockWriteGuard<bool> =
814                state_for_build.get_is_building().write().await;
815            *building = false;
816        });
817    }
818    Ok(())
819}
820
821/// Runs wasm-pack build for the target crate.
822///
823/// All arguments in `args.wasm_pack_args` are transparently forwarded
824/// to `wasm-pack build`. If `--out-dir` is not specified by the user,
825/// `--out-dir {www_dir}/pkg` is automatically injected so that build artifacts
826/// are placed inside the www directory served by the development server.
827///
828/// # Arguments
829///
830/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
831///
832/// # Returns
833///
834/// - `Result<(), EuvError>` - Indicates success or failure of the wasm-pack build.
835pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> {
836    let build_mode: BuildMode = resolve_build_mode(args);
837    let build_mode_flag: &str = build_mode_to_flag(build_mode);
838    let filtered_args: Vec<String> = filter_euv_args(args.get_wasm_pack_args());
839    let has_existing_build_mode: bool = has_build_mode_flag(&filtered_args);
840    let default_out_dir: String = format!("{}/{PKG_DIR_NAME}", args.get_www_dir());
841    let mut command: Command = Command::new(WASM_PACK_COMMAND);
842    command.arg(WASM_PACK_BUILD_SUBCOMMAND);
843    if !has_existing_build_mode {
844        command.arg(build_mode_flag);
845    }
846    command
847        .args(&filtered_args)
848        .env(RUST_MIN_STACK_ENV, RUST_MIN_STACK_VALUE);
849    let has_out_dir: bool = extract_out_dir(&filtered_args).is_some();
850    if !has_out_dir {
851        command.arg(OUT_DIR_ARG).arg(&default_out_dir);
852    }
853    let has_target: bool = filtered_args
854        .iter()
855        .any(|arg: &String| arg == TARGET_ARG || arg.starts_with(&format!("{TARGET_ARG}=")));
856    if !has_target {
857        command.arg(TARGET_ARG).arg(TARGET_WEB);
858    }
859    command.current_dir(args.get_crate_path());
860    command.stdout(Stdio::piped()).stderr(Stdio::piped());
861    let display_args: Vec<String> = (if has_existing_build_mode {
862        filtered_args.to_vec()
863    } else {
864        once(build_mode_flag.to_string())
865            .chain(filtered_args.iter().cloned())
866            .collect::<Vec<String>>()
867    })
868    .into_iter()
869    .chain(if has_out_dir {
870        Vec::new()
871    } else {
872        vec![OUT_DIR_ARG.to_string(), default_out_dir.clone()]
873    })
874    .chain(if has_target {
875        Vec::new()
876    } else {
877        vec![TARGET_ARG.to_string(), TARGET_WEB.to_string()]
878    })
879    .collect();
880    let out_dir_absolute: PathBuf = resolve_out_dir(args);
881    create_dir_all(&out_dir_absolute)
882        .await
883        .map_err(|error: io::Error| EuvError::IoPath {
884            message: String::from("Failed to create output directory"),
885            path: out_dir_absolute.clone(),
886            error,
887        })?;
888    log::info!(
889        "Running: {WASM_PACK_COMMAND} {WASM_PACK_BUILD_SUBCOMMAND} {} ...",
890        display_args.join(" ")
891    );
892    let output: Output = command
893        .output()
894        .await
895        .map_err(|error: io::Error| EuvError::Io {
896            message: String::from("Failed to execute wasm-pack"),
897            error,
898        })?;
899    let stdout: String = String::from_utf8_lossy(&output.stdout).to_string();
900    let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
901    if args.get_no_gitignore() {
902        let gitignore_path: PathBuf = out_dir_absolute.join(GITIGNORE_FILE_NAME);
903        if gitignore_path.exists()
904            && let Err(error) = remove_file(&gitignore_path).await
905        {
906            log::warn!("Failed to remove '{}': {error}", gitignore_path.display());
907        }
908    }
909    for line in stdout.lines().filter(|line: &&str| !line.is_empty()) {
910        log::info!("{line}");
911    }
912    if output.status.success() {
913        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
914            log::info!("{line}");
915        }
916    } else {
917        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
918            log::error!("{line}");
919        }
920        return Err(EuvError::Message(String::from("wasm-pack build failed")));
921    }
922    Ok(())
923}
924
925/// Prints the startup banner and command information.
926///
927/// # Arguments
928///
929/// - `Action` - The action to perform (run or build).
930pub fn print_banner(action: Action) {
931    let version: &str = env!("CARGO_PKG_VERSION");
932    if version.is_empty() {
933        log::warn!("Failed to parse version from root Cargo.toml");
934    } else {
935        log::info!("euv v{version}");
936    }
937    let action_name: &str = match action {
938        Action::Run => ACTION_RUN,
939        Action::Build => ACTION_BUILD,
940    };
941    log::info!("Mode: {action_name}");
942    log::info!(
943        "Use .gitignore to filter file change events; pass --no-gitignore to remove .gitignore from output"
944    );
945}
946
947/// Enumerates all network interface IP addresses and prints each server URL
948/// along with its corresponding QR code to the console.
949///
950/// Includes both loopback (127.0.0.1) and all private/public IPv4 addresses
951/// bound to the host's network interfaces. Each address produces one URL line
952/// followed by a Unicode QR code rendered with half-block characters,
953/// where every line carries the standard log prefix (timestamp + level).
954///
955/// # Arguments
956///
957/// - `&ServerUrlConfig` - The server URL configuration.
958pub(crate) fn print_server_urls(config: &ServerUrlConfig) {
959    let port: u16 = config.get_port();
960    let route_prefix: &str = config.get_route_prefix();
961    let index_html_file_name: &str = config.get_index_html_file_name();
962    let mut addresses: Vec<IpAddr> = Vec::new();
963    match if_addrs::get_if_addrs() {
964        Ok(interfaces) => {
965            for interface in interfaces {
966                let ip: IpAddr = interface.addr.ip();
967                if !addresses.contains(&ip) {
968                    addresses.push(ip);
969                }
970            }
971        }
972        Err(error) => {
973            log::warn!("Failed to enumerate network interfaces: {error}");
974        }
975    }
976    if addresses.is_empty() {
977        addresses.push(IpAddr::V4(Ipv4Addr::LOCALHOST));
978    }
979    for ip in addresses {
980        let host: String = match ip {
981            IpAddr::V6(_) => format!("[{ip}]"),
982            IpAddr::V4(_) => format!("{ip}"),
983        };
984        let url: String =
985            format!("{HTTP_SCHEME}://{host}:{port}/{route_prefix}/{index_html_file_name}");
986        log::info!("Server: {url}");
987        match QrCode::new(url.as_str()) {
988            Ok(code) => {
989                let string: String = code.render::<Dense1x2>().quiet_zone(false).build();
990                for line in string.lines() {
991                    log::info!("{line}");
992                }
993            }
994            Err(error) => {
995                log::warn!("Failed to generate QR code: {error}");
996            }
997        }
998    }
999}
1000
1001/// Reads the wasm-bindgen JS bridge produced by `wasm-pack build
1002/// --target web` and returns a self-contained IIFE that inlines the
1003/// bridge and every module it imports, fetches the wasm module from
1004/// `wasm_url`, then calls `main()` once the instance is ready.
1005///
1006/// The bridge's own top-level `import` lines (the snippet helpers under
1007/// `./snippets/`) are stripped and their bodies inlined, so the result
1008/// makes zero HTTP requests for JavaScript: the page carries the whole
1009/// module graph in one `<script>`.
1010///
1011/// Fails closed. A bridge whose bootstrap this cannot reproduce — one
1012/// whose `__wbg_init` uses `import.meta.url` on a line that carries
1013/// other statements, one that imports a non-relative or named-bound
1014/// module — returns an error naming the construct, never a bootstrap
1015/// that silently fails in the browser.
1016///
1017/// # Arguments
1018///
1019/// - `&Path` - Directory holding the wasm-pack output.
1020/// - `&str` - File name of the generated JS bridge inside it.
1021/// - `&str` - URL the page will fetch the `.wasm` module from.
1022///
1023/// # Returns
1024///
1025/// - `Result<String, EuvError>` - The IIFE source, or an error naming the
1026///   exact unsupported construct when the bridge cannot be inlined.
1027pub(crate) async fn build_inline_bridge(
1028    pkg_dir: &Path,
1029    js_name: &str,
1030    wasm_url: &str,
1031) -> Result<String, EuvError> {
1032    let js_path: PathBuf = pkg_dir.join(js_name);
1033    let bridge_source: String =
1034        read_to_string(&js_path)
1035            .await
1036            .map_err(|error: io::Error| EuvError::IoPath {
1037                message: String::from("Failed to read wasm-bindgen JS bridge"),
1038                path: js_path.clone(),
1039                error,
1040            })?;
1041    let inlined: String = inline_module_graph(&bridge_source, pkg_dir).await?;
1042    let js_only: String = strip_trailing_module_exports(&inlined);
1043    if !js_only.contains("function main(") {
1044        return Err(EuvError::Message(format!(
1045            "wasm-bindgen bridge '{js_name}' declares no main() for the bootstrap to call"
1046        )));
1047    }
1048    if !js_only.contains("__wbg_get_imports") {
1049        return Err(EuvError::Message(format!(
1050            "wasm-bindgen bridge '{js_name}' declares no __wbg_get_imports() \
1051             for the bootstrap to instantiate the module with"
1052        )));
1053    }
1054    let wasm_url_json: String = encode_js_string(wasm_url);
1055    Ok(format!(
1056        "(function() {{\n\
1057         {js_only}\n\
1058         var __euv_wasm_url = {wasm_url_json};\n\
1059         var __euv_base = (document.querySelector('base[href]') && document.baseURI) || location.href;\n\
1060         var __euv_url = new URL(__euv_wasm_url, __euv_base).toString();\n\
1061         function __euv_main() {{ if (typeof main === 'function') {{ main(); }} }}\n\
1062         if (typeof __wbg_init === 'function') {{\n\
1063         __wbg_init(__euv_url).then(__euv_main).catch(function(e) {{\n\
1064         console.error('[euv] inline bridge init failed:', e);\n\
1065         }});\n\
1066         }} else if (typeof initSync === 'function') {{\n\
1067         fetch(__euv_url).then(function(response) {{ return response.arrayBuffer(); }})\n\
1068         .then(function(bytes) {{ initSync({{ module: bytes }}); __euv_main(); }})\n\
1069         .catch(function(e) {{ console.error('[euv] inline bridge init failed:', e); }});\n\
1070         }} else {{\n\
1071         console.error('[euv] inline bridge: no __wbg_init or initSync to instantiate the module');\n\
1072         }}\n\
1073         }})();\n"
1074    ))
1075}
1076
1077/// Walks the bridge and every module it imports, returning their bodies
1078/// concatenated in dependency order with the bridge's own `import` lines
1079/// and namespace aliases materialised as plain `var` objects.
1080///
1081/// Fails closed: an import clause shape this inliner does not model, a
1082/// specifier that is not relative, a specifier that escapes the package
1083/// directory, a snippet that cannot be read, or a snippet that exports
1084/// nothing a namespace import can bind all produce an error instead of a
1085/// silently truncated bootstrap.
1086///
1087/// # Arguments
1088///
1089/// - `&str` - The bridge source.
1090/// - `&Path` - Directory holding the wasm-pack output.
1091///
1092/// # Returns
1093///
1094/// - `Result<String, EuvError>` - The concatenated module bodies.
1095async fn inline_module_graph(bridge_source: &str, pkg_dir: &Path) -> Result<String, EuvError> {
1096    let mut snippets: Vec<(PathBuf, String)> = Vec::new();
1097    let mut namespace_clauses: Vec<NamespaceImport> = Vec::new();
1098    for line in bridge_source.lines() {
1099        let trimmed: &str = line.trim();
1100        if !trimmed.starts_with("import ") {
1101            continue;
1102        }
1103        let rest: &str = trimmed.strip_prefix("import ").unwrap_or_default();
1104        let clause: ImportClause<'_> = parse_import_clause(rest)?;
1105        if clause.default_binding.is_some() || !clause.named.is_empty() {
1106            return Err(EuvError::Message(format!(
1107                "wasm-bindgen bridge imports a default or named binding from '{}', \
1108                 which a self-contained bootstrap cannot reproduce",
1109                clause.spec
1110            )));
1111        }
1112        if !(clause.spec.starts_with("./") || clause.spec.starts_with("../")) {
1113            return Err(EuvError::Message(format!(
1114                "wasm-bindgen bridge imports the bare module '{}', which a \
1115                 self-contained bootstrap cannot fetch",
1116                clause.spec
1117            )));
1118        }
1119        if let Some(alias) = clause.namespace {
1120            namespace_clauses.push(NamespaceImport {
1121                alias: alias.to_string(),
1122                spec: clause.spec.to_string(),
1123            });
1124        }
1125        let snippet_path: PathBuf = resolve_snippet_path(pkg_dir, clause.spec).await?;
1126        if snippets
1127            .iter()
1128            .any(|(path, _): &(PathBuf, String)| path == &snippet_path)
1129        {
1130            continue;
1131        }
1132        let raw: String = read_to_string(&snippet_path)
1133            .await
1134            .map_err(|error: io::Error| EuvError::IoPath {
1135                message: String::from("Failed to read wasm-pack snippet module"),
1136                path: snippet_path.clone(),
1137                error,
1138            })?;
1139        snippets.push((snippet_path, raw));
1140    }
1141    let mut out: String = String::new();
1142    for (snippet_path, raw) in &snippets {
1143        let body: String = strip_module_exports(raw).map_err(|error: EuvError| match error {
1144            EuvError::Message(message) => EuvError::Message(format!(
1145                "{} in snippet module '{}'",
1146                message,
1147                snippet_path.display()
1148            )),
1149            other => other,
1150        })?;
1151        if body.trim().is_empty() {
1152            return Err(EuvError::Message(format!(
1153                "snippet module '{}' has no inlinable body",
1154                snippet_path.display()
1155            )));
1156        }
1157        out.push_str(&body);
1158        out.push('\n');
1159    }
1160    for clause in &namespace_clauses {
1161        let snippet_path: PathBuf = resolve_snippet_path(pkg_dir, &clause.spec).await?;
1162        let raw: String = snippets
1163            .iter()
1164            .find(|(path, _): &&(PathBuf, String)| *path == snippet_path)
1165            .map(|(_, raw): &(PathBuf, String)| raw.clone())
1166            .unwrap_or_default();
1167        let names: Vec<String> = extract_exported_names(&raw);
1168        if names.is_empty() {
1169            return Err(EuvError::Message(format!(
1170                "snippet module '{}' exports nothing the namespace binding '{}' \
1171                 could reference",
1172                clause.spec, clause.alias
1173            )));
1174        }
1175        out.push_str(&render_namespace_object(&clause.alias, &names));
1176    }
1177    for line in bridge_source.lines() {
1178        let trimmed: &str = line.trim();
1179        if trimmed.starts_with("import ") {
1180            continue;
1181        }
1182        if trimmed.contains(IMPORT_META_URL_MARKER) {
1183            continue;
1184        }
1185        out.push_str(line);
1186        out.push('\n');
1187    }
1188    Ok(out)
1189}
1190
1191/// Parses one `import` clause, rejecting shapes the inliner cannot model.
1192///
1193/// # Arguments
1194///
1195/// - `&str` - The static import clause after the `import` keyword.
1196///
1197/// # Returns
1198///
1199/// - `Result<ImportClause<'_>, EuvError>` - Borrowed bindings and module specifier.
1200pub fn parse_import_clause(rest: &str) -> Result<ImportClause<'_>, EuvError> {
1201    let rest: &str = rest.trim().trim_end_matches(';').trim();
1202    let from_idx: usize = rest.find(" from ").ok_or_else(|| {
1203        EuvError::Message(format!(
1204            "bridge import '{rest}' is not a static `from` clause, which a \
1205             self-contained bootstrap cannot reproduce"
1206        ))
1207    })?;
1208    let head: &str = rest[..from_idx].trim();
1209    let quoted: &str = rest[from_idx + " from ".len()..].trim();
1210    let quote: char = match quoted.chars().next() {
1211        Some(quote @ ('\'' | '"')) => quote,
1212        _ => {
1213            return Err(EuvError::Message(format!(
1214                "bridge import specifier '{quoted}' is not a quoted string"
1215            )));
1216        }
1217    };
1218    let after: &str = &quoted[quote.len_utf8()..];
1219    let spec: &str = after.split(quote).next().unwrap_or_default();
1220    if spec.is_empty() {
1221        return Err(EuvError::Message(format!(
1222            "bridge import '{rest}' names an empty specifier"
1223        )));
1224    }
1225    if head.starts_with('*') {
1226        let alias: &str = head
1227            .strip_prefix('*')
1228            .map(|value: &str| value.trim())
1229            .and_then(|value: &str| value.strip_prefix("as "))
1230            .map(|value: &str| value.trim())
1231            .unwrap_or_default();
1232        if alias.is_empty() {
1233            return Err(EuvError::Message(format!(
1234                "bridge import '{rest}' is a namespace import without a binding"
1235            )));
1236        }
1237        return Ok(ImportClause {
1238            spec,
1239            namespace: Some(alias),
1240            default_binding: None,
1241            named: Vec::new(),
1242        });
1243    }
1244    if let Some(inner) = head.strip_prefix('{') {
1245        let inner: &str = inner.strip_suffix('}').ok_or_else(|| {
1246            EuvError::Message(format!(
1247                "bridge import '{rest}' has an unbalanced `{{` clause"
1248            ))
1249        })?;
1250        return Ok(ImportClause {
1251            spec,
1252            namespace: None,
1253            default_binding: None,
1254            named: inner
1255                .split(',')
1256                .map(|entry: &str| entry.trim())
1257                .filter(|entry: &&str| !entry.is_empty())
1258                .collect(),
1259        });
1260    }
1261    Ok(ImportClause {
1262        spec,
1263        namespace: None,
1264        default_binding: Some(head),
1265        named: Vec::new(),
1266    })
1267}
1268
1269/// Renders the `var <alias> = { a: a, ... };` statement a namespace import
1270/// becomes once the module graph is gone.
1271///
1272/// # Arguments
1273///
1274/// - `&str` - Local namespace binding.
1275/// - `&[String]` - Exported names to expose on the namespace object.
1276///
1277/// # Returns
1278///
1279/// - `String` - The JavaScript namespace declaration.
1280fn render_namespace_object(alias: &str, names: &[String]) -> String {
1281    let mut out: String = format!("var {alias} = {{");
1282    for (index, name) in names.iter().enumerate() {
1283        if index > 0 {
1284            out.push(',');
1285        }
1286        out.push_str(name);
1287        out.push(':');
1288        out.push_str(name);
1289    }
1290    out.push_str("};\n");
1291    out
1292}
1293
1294/// Strips the `export` keyword from a snippet module, turning it into bare
1295/// declarations, and rejects export forms a bare declaration cannot carry.
1296///
1297/// # Arguments
1298///
1299/// - `&str` - The snippet module source.
1300///
1301/// # Returns
1302///
1303/// - `Result<String, EuvError>` - The same source with its `export` keywords
1304///   removed, or an error naming the unsupported export form.
1305fn strip_module_exports(source: &str) -> Result<String, EuvError> {
1306    let mut out: String = String::with_capacity(source.len());
1307    for line in source.lines() {
1308        let trimmed: &str = line.trim();
1309        let Some(rest) = trimmed.strip_prefix("export ") else {
1310            out.push_str(line);
1311            out.push('\n');
1312            continue;
1313        };
1314        match classify_export(rest) {
1315            ExportForm::Aliases(clause) => {
1316                out.push_str(&render_export_aliases(clause)?);
1317            }
1318            ExportForm::Declaration(declaration) => {
1319                out.push_str(declaration);
1320                out.push('\n');
1321            }
1322            ExportForm::Unsupported => {
1323                return Err(EuvError::Message(format!(
1324                    "snippet exports '{rest}', a form a self-contained \
1325                     bootstrap cannot reproduce"
1326                )));
1327            }
1328        }
1329    }
1330    Ok(out)
1331}
1332
1333/// Classifies the text following an `export ` keyword.
1334///
1335/// # Arguments
1336///
1337/// - `&str` - Source immediately after the export keyword.
1338///
1339/// # Returns
1340///
1341/// - `ExportForm<'_>` - A declaration, alias list, or unsupported export.
1342fn classify_export(rest: &str) -> ExportForm<'_> {
1343    let trimmed: &str = rest.trim_end().trim_end_matches(';');
1344    if let Some(inner) = trimmed.strip_prefix('{')
1345        && inner.trim_end().ends_with('}')
1346    {
1347        return ExportForm::Aliases(inner.trim_end().trim_end_matches('}'));
1348    }
1349    if EXPORT_DECLARATION_KEYWORDS
1350        .iter()
1351        .any(|keyword: &&str| trimmed.starts_with(keyword))
1352    {
1353        return ExportForm::Declaration(trimmed);
1354    }
1355    ExportForm::Unsupported
1356}
1357
1358/// Renders an `{ a, b as c }` export clause as the assignments that keep
1359/// each local binding reachable under its exported name.
1360///
1361/// An export of `wasm`, or an alias whose two names match, is dropped:
1362/// the inlined bootstrap is a plain `<script>` with no module namespace
1363/// and no `wasm` binding in scope, and an identity alias needs no
1364/// assignment.
1365///
1366/// # Arguments
1367///
1368/// - `&str` - The clause body between the braces.
1369///
1370/// # Returns
1371///
1372/// - `Result<String, EuvError>` - The assignments, or an error naming a
1373///   non-identifier entry.
1374fn render_export_aliases(clause: &str) -> Result<String, EuvError> {
1375    let mut out: String = String::new();
1376    for entry in clause.split(',') {
1377        let entry: &str = entry.trim();
1378        if entry.is_empty() {
1379            continue;
1380        }
1381        let (original, exported): (&str, &str) = match entry.split_once(" as ") {
1382            Some((original, exported)) => (original.trim(), exported.trim()),
1383            None => (entry, entry),
1384        };
1385        if !is_identifier(original) || !is_identifier(exported) {
1386            return Err(EuvError::Message(format!(
1387                "snippet exports the non-identifier entry '{entry}'"
1388            )));
1389        }
1390        if original == exported || exported == "default" || original == "wasm" {
1391            continue;
1392        }
1393        out.push_str(exported);
1394        out.push_str(" = ");
1395        out.push_str(original);
1396        out.push_str(";\n");
1397    }
1398    Ok(out)
1399}
1400
1401/// Returns `true` when `value` is a bare JavaScript identifier.
1402///
1403/// # Arguments
1404///
1405/// - `&str` - Candidate binding name.
1406///
1407/// # Returns
1408///
1409/// - `bool` - Whether the value contains only supported identifier characters.
1410fn is_identifier(value: &str) -> bool {
1411    !value.is_empty()
1412        && !value.starts_with(|c: char| c.is_ascii_digit())
1413        && value
1414            .chars()
1415            .all(|c: char| c.is_ascii_alphanumeric() || c == '_' || c == '$')
1416}
1417
1418/// Scans a snippet module for every name it exports, so a namespace import
1419/// can be rebuilt as a plain object. `export function`, `export async
1420/// function`, `export class` and `export const|let|var` all count.
1421///
1422/// # Arguments
1423///
1424/// - `&str` - The snippet module source to scan.
1425///
1426/// # Returns
1427///
1428/// - `Vec<String>` - Every exported name, in source order.
1429pub fn extract_exported_names(source: &str) -> Vec<String> {
1430    let mut names: Vec<String> = Vec::new();
1431    for line in source.lines() {
1432        let trimmed: &str = line.trim();
1433        let Some(rest) = trimmed.strip_prefix("export ") else {
1434            continue;
1435        };
1436        let declaration: Option<&str> = EXPORT_DECLARATION_KEYWORDS
1437            .iter()
1438            .find_map(|keyword: &&str| rest.strip_prefix(keyword));
1439        if let Some(name) = declaration.and_then(leading_identifier)
1440            && !names.iter().any(|existing: &String| existing == name)
1441        {
1442            names.push(name.to_string());
1443        }
1444    }
1445    names
1446}
1447
1448/// Returns the leading identifier of `value`, or `None` when it starts with
1449/// something that cannot be one.
1450///
1451/// # Arguments
1452///
1453/// - `&str` - Declaration text starting with the exported name.
1454///
1455/// # Returns
1456///
1457/// - `Option<&str>` - The leading identifier, when present.
1458fn leading_identifier(value: &str) -> Option<&str> {
1459    let end: usize = value
1460        .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$')
1461        .unwrap_or(value.len());
1462    if end == 0 { None } else { Some(&value[..end]) }
1463}
1464
1465/// Resolves a relative specifier to a path inside `pkg_dir`.
1466///
1467/// # Arguments
1468///
1469/// - `&Path` - Directory holding the wasm-pack output.
1470/// - `&str` - Relative specifier taken from the bridge's import clause.
1471///
1472/// # Returns
1473///
1474/// - `Result<PathBuf, EuvError>` - The resolved path, or an error if the
1475///   specifier climbs above `pkg_dir`.
1476async fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result<PathBuf, EuvError> {
1477    let base: &str = spec.strip_prefix("./").unwrap_or(spec);
1478    let mut path: PathBuf = pkg_dir.to_path_buf();
1479    for segment in base.split('/') {
1480        if segment.is_empty() || segment == "." {
1481            continue;
1482        }
1483        if segment == ".." {
1484            return Err(EuvError::Message(String::from(
1485                "Snippet import spec escapes pkg directory",
1486            )));
1487        }
1488        path.push(segment);
1489    }
1490    let root: PathBuf =
1491        canonicalize(pkg_dir)
1492            .await
1493            .map_err(|error: io::Error| EuvError::IoPath {
1494                message: String::from("Failed to resolve the WASM output directory"),
1495                path: pkg_dir.to_path_buf(),
1496                error,
1497            })?;
1498    let resolved: PathBuf =
1499        canonicalize(&path)
1500            .await
1501            .map_err(|error: io::Error| EuvError::IoPath {
1502                message: String::from("Failed to resolve the imported snippet"),
1503                path: path.clone(),
1504                error,
1505            })?;
1506    if !resolved.starts_with(root) {
1507        return Err(EuvError::Message(format!(
1508            "Snippet import '{spec}' resolves outside the WASM output directory",
1509        )));
1510    }
1511    Ok(resolved)
1512}
1513
1514/// Removes the bridge's trailing `export { ... }` clauses, turning each
1515/// exported alias into the assignment that keeps the local function
1516/// reachable under its exported name, and its `export function` lines into
1517/// plain declarations.
1518///
1519/// # Arguments
1520///
1521/// - `&str` - The bridge source, after its `import` lines were removed.
1522///
1523/// # Returns
1524///
1525/// - `String` - The source with its module-level export syntax removed.
1526fn strip_trailing_module_exports(source: &str) -> String {
1527    let mut out: String = String::with_capacity(source.len());
1528    for line in source.lines() {
1529        let trimmed: &str = line.trim();
1530        let Some(rest) = trimmed.strip_prefix("export ") else {
1531            out.push_str(line);
1532            out.push('\n');
1533            continue;
1534        };
1535        match classify_export(rest) {
1536            ExportForm::Aliases(clause) => {
1537                if let Ok(aliases) = render_export_aliases(clause) {
1538                    out.push_str(&aliases);
1539                }
1540            }
1541            ExportForm::Declaration(declaration) => {
1542                out.push_str(declaration);
1543                out.push('\n');
1544            }
1545            ExportForm::Unsupported => {
1546                out.push_str(line);
1547                out.push('\n');
1548            }
1549        }
1550    }
1551    out
1552}
1553
1554/// Encodes a value as a JavaScript string literal, including the
1555/// surrounding quotes, escaping every character that would otherwise end
1556/// the literal, open a new line inside it, or close the enclosing
1557/// `<script>` element early.
1558///
1559/// # Arguments
1560///
1561/// - `&str` - The value to encode.
1562///
1563/// # Returns
1564///
1565/// - `String` - A quoted, escaped JavaScript string.
1566pub(crate) fn encode_js_string(value: &str) -> String {
1567    let mut out: String = String::with_capacity(value.len() + 2);
1568    out.push('"');
1569    for ch in value.chars() {
1570        match ch {
1571            '"' => out.push_str("\\\""),
1572            '\\' => out.push_str("\\\\"),
1573            '\n' => out.push_str("\\n"),
1574            '\r' => out.push_str("\\r"),
1575            '\t' => out.push_str("\\t"),
1576            '\u{08}' => out.push_str("\\b"),
1577            '\u{0C}' => out.push_str("\\f"),
1578            '<' => out.push_str("\\u003c"),
1579            '>' => out.push_str("\\u003e"),
1580            '&' => out.push_str("\\u0026"),
1581            '\u{2028}' => out.push_str("\\u2028"),
1582            '\u{2029}' => out.push_str("\\u2029"),
1583            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1584            c => out.push(c),
1585        }
1586    }
1587    out.push('"');
1588    out
1589}
1590
1591/// Deletes every JavaScript artifact wasm-pack emitted for this build:
1592/// the bridge itself, its `.d.ts` declarations, its source maps and the
1593/// `snippets/` modules that were inlined into the HTML.
1594///
1595/// Runs only after the HTML carrying the inlined bridge has been written,
1596/// and only inside the wasm-pack output directory. The walk classifies each
1597/// entry with `symlink_metadata`, so a directory symlink is never followed:
1598/// a link pointing outside the output, or into a cycle, is left in place
1599/// and named, rather than extending the walk without bound. Every file
1600/// that is deleted is first confirmed to resolve inside the output.
1601///
1602/// A directory wasm-pack itself created is removed only once it is
1603/// genuinely empty — `remove_dir`, never `remove_dir_all` — so a
1604/// non-JavaScript asset an author left inside `snippets/` survives
1605/// together with its directory. Unreadable directories and failed
1606/// enumerations are errors, not silent skips: reporting success while
1607/// JavaScript remains is exactly the failure this guards against.
1608///
1609/// # Arguments
1610///
1611/// - `&Path` - The wasm-pack output directory.
1612///
1613/// # Returns
1614///
1615/// - `Result<Vec<PathBuf>, EuvError>` - The files removed, or an error naming
1616///   what could not be read or deleted.
1617pub(crate) async fn remove_generated_js(pkg_dir: &Path) -> Result<Vec<PathBuf>, EuvError> {
1618    let root: PathBuf = match canonicalize(pkg_dir).await {
1619        Ok(root) => root,
1620        Err(error) if error.kind() == io::ErrorKind::NotFound => {
1621            return Ok(Vec::new());
1622        }
1623        Err(error) => {
1624            return Err(EuvError::IoPath {
1625                message: String::from("Failed to resolve the wasm-pack output directory"),
1626                path: pkg_dir.to_path_buf(),
1627                error,
1628            });
1629        }
1630    };
1631    let mut removed: Vec<PathBuf> = Vec::new();
1632    let mut generated_dirs: Vec<PathBuf> = Vec::new();
1633    let mut pending: Vec<PathBuf> = vec![root.clone()];
1634    while let Some(dir) = pending.pop() {
1635        let mut entries: ReadDir = read_dir(&dir).await.map_err(|error: io::Error| {
1636            EuvError::IoPath {
1637                message: String::from(
1638                    "Failed to list the wasm-pack output directory while removing inlined JavaScript",
1639                ),
1640                path: dir.clone(),
1641                error,
1642            }
1643        })?;
1644        loop {
1645            let entry: DirEntry = match entries.next_entry().await {
1646                Ok(Some(entry)) => entry,
1647                Ok(None) => break,
1648                Err(error) => {
1649                    return Err(EuvError::IoPath {
1650                        message: String::from(
1651                            "Failed to read an entry of the wasm-pack output directory while \
1652                             removing inlined JavaScript",
1653                        ),
1654                        path: dir.clone(),
1655                        error,
1656                    });
1657                }
1658            };
1659            let path: PathBuf = entry.path();
1660            let file_type: FileType =
1661                entry
1662                    .file_type()
1663                    .await
1664                    .map_err(|error: io::Error| EuvError::IoPath {
1665                        message: String::from(
1666                            "Failed to stat an entry of the wasm-pack output directory while \
1667                         removing inlined JavaScript",
1668                        ),
1669                        path: path.clone(),
1670                        error,
1671                    })?;
1672            if file_type.is_symlink() {
1673                // Never followed: a link may leave the package or close a
1674                // cycle, and neither is this walk's to resolve.
1675                log::warn!(
1676                    "Leaving '{}' in place: it is a symlink, and the cleanup never follows one",
1677                    path.display()
1678                );
1679                continue;
1680            }
1681            if file_type.is_dir() {
1682                if path.starts_with(root.join(SNIPPET_DIR_NAME)) {
1683                    generated_dirs.push(path.clone());
1684                }
1685                pending.push(path);
1686                continue;
1687            }
1688            let canonical: PathBuf =
1689                canonicalize(&path)
1690                    .await
1691                    .map_err(|error: io::Error| EuvError::IoPath {
1692                        message: String::from(
1693                            "Failed to resolve an entry of the wasm-pack output directory before \
1694                         deleting it",
1695                        ),
1696                        path: path.clone(),
1697                        error,
1698                    })?;
1699            if !canonical.starts_with(&root) {
1700                log::warn!(
1701                    "Leaving '{}' in place: it resolves outside the wasm-pack output",
1702                    path.display()
1703                );
1704                continue;
1705            }
1706            if !is_generated_js_artifact(&path) {
1707                continue;
1708            }
1709            remove_file(&path)
1710                .await
1711                .map_err(|error: io::Error| EuvError::IoPath {
1712                    message: String::from(
1713                        "Failed to remove a generated JavaScript file after inlining it",
1714                    ),
1715                    path: path.clone(),
1716                    error,
1717                })?;
1718            removed.push(path);
1719        }
1720    }
1721    for dir in generated_dirs.iter().rev() {
1722        match remove_dir(dir).await {
1723            Ok(()) => {}
1724            Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => {}
1725            Err(error) => {
1726                return Err(EuvError::IoPath {
1727                    message: String::from("Failed to remove an emptied snippet directory"),
1728                    path: dir.clone(),
1729                    error,
1730                });
1731            }
1732        }
1733    }
1734    log::debug!(
1735        "Removed {} generated JavaScript file(s) and {} empty generated directory(ies)",
1736        removed.len(),
1737        generated_dirs.len()
1738    );
1739    Ok(removed)
1740}
1741
1742/// Returns `true` for the JavaScript-adjacent files wasm-pack generates:
1743/// `.js`, `.mjs`, `.cjs`, their source maps and their `.d.ts` declarations.
1744///
1745/// # Arguments
1746///
1747/// - `&Path` - Output file path to classify.
1748///
1749/// # Returns
1750///
1751/// - `bool` - Whether the file belongs to the generated JavaScript artifacts.
1752fn is_generated_js_artifact(path: &Path) -> bool {
1753    let name: String = path
1754        .file_name()
1755        .and_then(|name: &ffi::OsStr| name.to_str())
1756        .unwrap_or_default()
1757        .to_ascii_lowercase();
1758    GENERATED_JS_SUFFIXES
1759        .iter()
1760        .any(|suffix: &&str| name.ends_with(suffix))
1761}