Skip to main content

floe/
interop.rs

1//! npm / .d.ts interop module.
2//!
3//! Resolves npm modules by shelling out to `tsc`, parses the type
4//! declarations from `.d.ts` files, and wraps types at the import
5//! boundary so they conform to Floe semantics.
6//!
7//! Boundary conversions:
8//! - `T | null`          -> `Option<T>`
9//! - `T | undefined`     -> `Option<T>`
10//! - `T | null | undefined` -> `Option<T>`
11//! - `any`               -> `unknown`
12
13mod dts;
14mod ts_types;
15pub mod tsgo;
16mod wrapper;
17
18#[cfg(test)]
19mod tests;
20
21use std::collections::HashMap;
22use std::path::{Path, PathBuf};
23use std::process::Command;
24
25use crate::checker::Type;
26
27// Re-export public API
28pub use dts::{DtsExport, parse_dts_exports};
29pub use ts_types::{ObjectField, TsType, ts_type_to_string};
30pub use tsgo::TsgoResolver;
31pub use wrapper::wrap_boundary_type;
32
33// Re-export internal helpers so tests (and sibling submodules) can access via `use super::*`
34#[cfg(test)]
35#[allow(unused_imports)]
36use dts::{
37    parse_const_export, parse_dts_exports_from_str, parse_function_export, parse_interface_export,
38    parse_type_export,
39};
40#[cfg(test)]
41#[allow(unused_imports)]
42use ts_types::{find_matching_paren, parse_param_types, parse_type_str, split_at_top_level};
43
44// ── Module Resolution ───────────────────────────────────────────
45
46/// Result of resolving an npm module.
47#[derive(Debug, Clone)]
48pub struct ResolvedModule {
49    /// Absolute path to the .d.ts file
50    pub dts_path: PathBuf,
51    /// The module specifier as written in the import (e.g. "react")
52    pub specifier: String,
53}
54
55/// Result of looking up exports from a resolved module.
56#[derive(Debug, Clone)]
57pub struct ModuleExports {
58    /// Named exports: name -> wrapped Floe type
59    pub exports: HashMap<String, Type>,
60    /// The module specifier
61    pub specifier: String,
62}
63
64/// Resolves an npm module specifier to its .d.ts file path using tsc.
65///
66/// Runs `tsc --moduleResolution bundler --noEmit --traceResolution` to find
67/// the declaration file for a given module specifier.
68pub fn resolve_module(specifier: &str, project_dir: &Path) -> Result<ResolvedModule, String> {
69    // First try: look for a tsconfig.json and use tsc's resolution
70    let tsconfig = crate::resolve::find_tsconfig_from(project_dir);
71
72    let mut cmd = Command::new("tsc");
73    cmd.current_dir(project_dir);
74
75    if let Some(tsconfig_path) = &tsconfig {
76        cmd.arg("--project").arg(tsconfig_path);
77    }
78
79    cmd.args(["--noEmit", "--traceResolution"]);
80
81    // Create a temp file that imports the module so tsc resolves it
82    let probe_content = format!("import {{}} from \"{specifier}\";");
83    let probe_path = project_dir.join("__floe_probe__.ts");
84
85    if std::fs::write(&probe_path, &probe_content).is_err() {
86        return Err(format!(
87            "failed to create probe file for module '{specifier}'"
88        ));
89    }
90
91    cmd.arg(&probe_path);
92
93    let output = cmd.output().map_err(|e| {
94        let _ = std::fs::remove_file(&probe_path);
95        format!("failed to run tsc: {e}. Is TypeScript installed?")
96    })?;
97
98    let _ = std::fs::remove_file(&probe_path);
99
100    let stderr = String::from_utf8_lossy(&output.stderr);
101    let stdout = String::from_utf8_lossy(&output.stdout);
102    let combined = format!("{stdout}\n{stderr}");
103
104    // Parse tsc's trace resolution output to find the .d.ts path
105    // Look for lines like: "File '.../node_modules/@types/react/index.d.ts' exists"
106    // or "Resolution for module 'react' was found in cache from location..."
107    parse_resolved_path(&combined, specifier)
108}
109
110/// Parses tsc trace output to extract the resolved .d.ts path.
111fn parse_resolved_path(trace: &str, specifier: &str) -> Result<ResolvedModule, String> {
112    // Pattern 1: "======== Module name 'X' was successfully resolved to 'Y'. ========"
113    let success_marker = "was successfully resolved to '";
114    for line in trace.lines() {
115        if line.contains(&format!("Module name '{specifier}'"))
116            && let Some(start) = line.find(success_marker)
117        {
118            let rest = &line[start + success_marker.len()..];
119            if let Some(end) = rest.find("'.") {
120                let dts_path = &rest[..end];
121                return Ok(ResolvedModule {
122                    dts_path: PathBuf::from(dts_path),
123                    specifier: specifier.to_string(),
124                });
125            }
126        }
127    }
128
129    // Pattern 2: look for resolved .d.ts in node_modules
130    // Try common locations directly
131    Err(format!(
132        "could not resolve module '{specifier}'. Make sure the package is installed (npm install)"
133    ))
134}
135
136/// Resolves a module specifier and returns wrapped Floe types for its exports.
137///
138/// This is the main entry point: resolve the module, parse its .d.ts,
139/// and wrap all exported types at the boundary.
140pub fn resolve_and_wrap(specifier: &str, project_dir: &Path) -> Result<ModuleExports, String> {
141    let resolved = resolve_module(specifier, project_dir)?;
142    let dts_exports = parse_dts_exports(&resolved.dts_path)?;
143
144    let mut exports = HashMap::new();
145    for export in dts_exports {
146        let wrapped = wrap_boundary_type(&export.ts_type);
147        exports.insert(export.name, wrapped);
148    }
149
150    Ok(ModuleExports {
151        exports,
152        specifier: specifier.to_string(),
153    })
154}