Skip to main content

flowjs_rs/
export.rs

1//! File export logic for Flow type declarations.
2
3use crate::{Config, Flow, TypeVisitor};
4use std::any::TypeId;
5use std::collections::{HashMap, HashSet};
6use std::path::{Path, PathBuf};
7use std::sync::{Mutex, OnceLock};
8
9/// Per-file mutex to serialize concurrent exports to the same output path.
10///
11/// When `cargo test` runs, multiple `#[test]` functions execute in parallel
12/// threads. Without synchronization the read-check-write sequence in
13/// [`export_to`] is susceptible to TOCTOU races: two threads can both read
14/// the file, both find the marker missing, and both overwrite — the second
15/// write silently drops the first thread's declaration.
16///
17/// We keep one `Mutex<()>` per canonical output path so that exports to
18/// *different* files still proceed in parallel.
19fn file_lock(path: &Path) -> &'static Mutex<()> {
20    static LOCKS: OnceLock<Mutex<HashMap<PathBuf, &'static Mutex<()>>>> = OnceLock::new();
21    let map_mutex = LOCKS.get_or_init(|| Mutex::new(HashMap::new()));
22    let mut map = map_mutex.lock().unwrap_or_else(|e| e.into_inner());
23    // Canonicalize when possible so that "./bindings/Foo" and "bindings/Foo"
24    // resolve to the same lock. Fall back to the raw path if the file (or its
25    // parent) doesn't exist yet.
26    let key = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
27    *map.entry(key).or_insert_with(|| Box::leak(Box::new(Mutex::new(()))))
28}
29
30/// Export error.
31#[derive(Debug, thiserror::Error)]
32pub enum ExportError {
33    #[error("type `{0}` cannot be exported")]
34    CannotBeExported(&'static str),
35    #[error(transparent)]
36    Io(#[from] std::io::Error),
37    #[error(transparent)]
38    Fmt(#[from] std::fmt::Error),
39}
40
41/// Write a Flow type declaration to a file.
42pub fn export_to<T: Flow + 'static + ?Sized>(cfg: &Config, path: &Path) -> Result<(), ExportError> {
43    if let Some(parent) = path.parent() {
44        std::fs::create_dir_all(parent)?;
45    }
46
47    let _guard = file_lock(path).lock().unwrap_or_else(|e| e.into_inner());
48
49    let type_name = T::ident(cfg);
50
51    if path.exists() {
52        let existing = std::fs::read_to_string(path)?;
53        let type_marker = format!("type {type_name}");
54        let enum_marker = format!("enum {type_name}");
55
56        if existing.contains(&type_marker) || existing.contains(&enum_marker) {
57            return Ok(());
58        }
59
60        // Same file — append only the declaration, skip header and imports
61        let decl_line = format_decl::<T>(cfg);
62        let mut merged = existing;
63        if !merged.ends_with('\n') {
64            merged.push('\n');
65        }
66        merged.push('\n');
67        if let Some(docs) = T::docs() {
68            merged.push_str(&format_docs(&docs));
69        }
70        merged.push_str(&decl_line);
71        merged.push('\n');
72        std::fs::write(path, merged)?;
73    } else {
74        let content = export_to_string::<T>(cfg)?;
75        std::fs::write(path, content)?;
76    }
77
78    Ok(())
79}
80
81/// Export `T` together with all of its dependencies.
82pub fn export_all_into<T: Flow + ?Sized + 'static>(cfg: &Config) -> Result<(), ExportError> {
83    let mut seen = HashSet::new();
84    export_recursive::<T>(cfg, &mut seen)
85}
86
87struct Visit<'a> {
88    cfg: &'a Config,
89    seen: &'a mut HashSet<TypeId>,
90    error: Option<ExportError>,
91}
92
93impl TypeVisitor for Visit<'_> {
94    fn visit<T: Flow + 'static + ?Sized>(&mut self) {
95        if self.error.is_some() || <T as crate::Flow>::output_path().is_none() {
96            return;
97        }
98        self.error = export_recursive::<T>(self.cfg, self.seen).err();
99    }
100}
101
102fn export_recursive<T: Flow + ?Sized + 'static>(
103    cfg: &Config,
104    seen: &mut HashSet<TypeId>,
105) -> Result<(), ExportError> {
106    if !seen.insert(TypeId::of::<T>()) {
107        return Ok(());
108    }
109
110    // Export dependencies first so they exist before the parent
111    let mut visitor = Visit {
112        cfg,
113        seen,
114        error: None,
115    };
116    <T as crate::Flow>::visit_dependencies(&mut visitor);
117
118    if let Some(e) = visitor.error {
119        return Err(e);
120    }
121
122    let base = <T as crate::Flow>::output_path()
123        .ok_or(ExportError::CannotBeExported(std::any::type_name::<T>()))?;
124    let relative = cfg.resolve_output_path(&base);
125    let path = cfg.out_dir().join(relative);
126    export_to::<T>(cfg, &path)
127}
128
129/// Render the full file content for type `T` as a string.
130///
131/// Includes the `// @flow` header, import statements for dependencies that
132/// export to different files, and the type declaration.
133pub fn export_to_string<T: Flow + ?Sized + 'static>(cfg: &Config) -> Result<String, ExportError> {
134    let mut content = String::from("// @flow\n// Generated by flowjs-rs. Do not edit.\n\n");
135
136    let self_id = TypeId::of::<T>();
137    let self_base = <T as crate::Flow>::output_path();
138    let self_path = self_base.as_ref().map(|b| cfg.resolve_output_path(b));
139    let deps = T::dependencies(cfg);
140    let mut seen_imports = HashSet::new();
141
142    for dep in &deps {
143        if dep.type_id == self_id || !seen_imports.insert(dep.type_id) {
144            continue;
145        }
146
147        // Resolve the dependency's output path with the configured extension
148        let dep_path = cfg.resolve_output_path(&dep.output_path);
149
150        // Skip import if dependency exports to the same file
151        if let Some(ref self_p) = self_path {
152            if &dep_path == self_p {
153                continue;
154            }
155        }
156
157        let import_path = dep_path.to_str().unwrap_or(&dep.flow_name);
158        content.push_str(&format!(
159            "import type {{ {} }} from './{import_path}';\n",
160            dep.flow_name
161        ));
162    }
163    if !seen_imports.is_empty() {
164        content.push('\n');
165    }
166
167    if let Some(docs) = T::docs() {
168        content.push_str(&format_docs(&docs));
169    }
170
171    content.push_str(&format_decl::<T>(cfg));
172    content.push('\n');
173
174    Ok(content)
175}
176
177/// Format the declaration line with `export` prefix.
178fn format_decl<T: Flow + ?Sized>(cfg: &Config) -> String {
179    let decl = T::decl(cfg);
180    if decl.starts_with("declare export") {
181        decl
182    } else {
183        format!("export {decl}")
184    }
185}
186
187/// Format doc comments as JSDoc.
188fn format_docs(docs: &str) -> String {
189    let mut out = String::from("/**\n");
190    for line in docs.lines() {
191        out.push_str(&format!(" * {line}\n"));
192    }
193    out.push_str(" */\n");
194    out
195}