Skip to main content

specta_typescript/
exporter.rs

1use std::{
2    borrow::Cow,
3    collections::{BTreeMap, BTreeSet, HashMap, HashSet},
4    fmt,
5    ops::Deref,
6    panic::Location,
7    path::{Path, PathBuf},
8    sync::Arc,
9};
10
11use specta::{
12    TypeCollection,
13    datatype::{DataType, NamedDataType, Reference},
14};
15use specta_serde::SerdeMode;
16
17use crate::{Branded, Error, primitives, references};
18
19/// Allows you to configure how Specta's Typescript exporter will deal with BigInt types ([i64], [i128] etc).
20///
21/// WARNING: None of these settings affect how your data is actually ser/deserialized.
22/// It's up to you to adjust your ser/deserialize settings.
23#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
24pub enum BigIntExportBehavior {
25    /// Export BigInt as a Typescript `string`
26    ///
27    /// Doing this in serde is [pretty simple](https://github.com/serde-rs/json/issues/329#issuecomment-305608405).
28    String,
29    /// Export BigInt as a Typescript `number`.
30    ///
31    /// WARNING: `JSON.parse` in JS will truncate your number resulting in data loss so ensure your deserializer supports large numbers.
32    Number,
33    /// Export BigInt as a Typescript `BigInt`.
34    ///
35    /// You must ensure you deserializer is able to support this.
36    BigInt,
37    /// Abort the export with an error.
38    ///
39    /// This is the default behavior because without integration from your serializer and deserializer we can't guarantee data loss won't occur.
40    #[default]
41    Fail,
42}
43
44/// Allows configuring the format of the final types file
45#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum Layout {
47    /// Produce a Typescript namespace for each Rust module
48    Namespaces,
49    /// Produce a dedicated file for each Rust module
50    Files,
51    /// Include the full module path in the types name but keep a flat structure.
52    ModulePrefixedName,
53    /// Flatten all of the types into a single file of types.
54    /// This mode doesn't support having multiple types with the same name.
55    #[default]
56    FlatFile,
57}
58
59impl fmt::Display for Layout {
60    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
61        write!(f, "{self:?}")
62    }
63}
64
65#[derive(Clone)]
66#[allow(clippy::type_complexity)]
67struct RuntimeFn(Arc<dyn Fn(FrameworkExporter) -> Result<Cow<'static, str>, Error> + Send + Sync>);
68
69impl fmt::Debug for RuntimeFn {
70    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
71        write!(f, "RuntimeFn({:p})", self.0)
72    }
73}
74
75#[derive(Clone)]
76#[allow(clippy::type_complexity)]
77pub struct BrandedTypeImpl(
78    pub(crate)  Arc<
79        dyn for<'a> Fn(BrandedTypeExporter<'a>, &Branded) -> Result<Cow<'static, str>, Error>
80            + Send
81            + Sync,
82    >,
83);
84
85impl fmt::Debug for BrandedTypeImpl {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        write!(f, "BrandedTypeImpl({:p})", self.0)
88    }
89}
90
91/// Typescript language exporter.
92#[derive(Debug, Clone)]
93#[non_exhaustive]
94pub struct Exporter {
95    /// Custom header prepended to exported files.
96    pub header: Cow<'static, str>,
97    framework_runtime: Option<RuntimeFn>,
98    pub(crate) branded_type_impl: Option<BrandedTypeImpl>,
99    framework_prelude: Cow<'static, str>,
100    /// Strategy for exporting Rust bigint-compatible primitives.
101    pub bigint: BigIntExportBehavior,
102    /// Output layout mode for generated TypeScript.
103    pub layout: Layout,
104    /// Optional Serde compatibility processing mode.
105    pub serde: Option<SerdeMode>,
106    pub(crate) jsdoc: bool,
107}
108
109impl Exporter {
110    // You should get this from either a [Typescript] or [JSDoc], not construct it directly.
111    pub(crate) fn default() -> Exporter {
112        Exporter {
113            header: Cow::Borrowed(""),
114            framework_runtime: None,
115            branded_type_impl: None,
116            framework_prelude: Cow::Borrowed(
117                "// This file has been generated by Specta. Do not edit this file manually.",
118            ),
119            bigint: Default::default(),
120            layout: Default::default(),
121            serde: Some(SerdeMode::Both),
122            jsdoc: false,
123        }
124    }
125
126    /// Provide a prelude which is added to the start of all exported files.
127    pub fn framework_prelude(mut self, prelude: impl Into<Cow<'static, str>>) -> Self {
128        self.framework_prelude = prelude.into();
129        self
130    }
131
132    /// Add some custom Typescript or Javascript code that is exported as part of the bindings.
133    /// It's appending to the types file for single-file layouts or put in a root `index.{ts/js}` for multi-file.
134    ///
135    /// The closure is wrapped in [`specta::datatype::collect()`] to capture any referenced types.
136    /// Ensure you call `T::reference()` within the closure if you want an import to be created.
137    pub fn framework_runtime(
138        mut self,
139        builder: impl Fn(FrameworkExporter) -> Result<Cow<'static, str>, Error> + Send + Sync + 'static,
140    ) -> Self {
141        self.framework_runtime = Some(RuntimeFn(Arc::new(builder)));
142        self
143    }
144
145    /// Configure how `specta_typescript::branded!` types are rendered with exporter context.
146    ///
147    /// This callback receives both the branded payload and a [`BrandedTypeExporter`], allowing
148    /// you to call [`BrandedTypeExporter::inline`] / [`BrandedTypeExporter::reference`] while
149    /// preserving the current export configuration.
150    ///
151    /// # Examples
152    ///
153    /// `ts-brand` style:
154    /// ```rust
155    /// # use std::borrow::Cow;
156    /// # use specta_typescript::{Branded, Error, Typescript};
157    /// let exporter = Typescript::default().branded_type_impl(|ctx, branded| {
158    ///     let datatype = ctx.inline(branded.ty())?;
159    ///
160    ///     Ok(Cow::Owned(format!(
161    ///         "import(\"ts-brand\").Brand<{}, \"{}\">",
162    ///         datatype,
163    ///         branded.brand()
164    ///     )))
165    /// });
166    /// # let _ = exporter;
167    /// ```
168    ///
169    /// Effect style:
170    /// ```rust
171    /// # use std::borrow::Cow;
172    /// # use specta_typescript::{Branded, Error, Typescript};
173    /// let exporter = Typescript::default().branded_type_impl(|ctx, branded| {
174    ///     let datatype = ctx.inline(branded.ty())?;
175    ///
176    ///     Ok(Cow::Owned(format!(
177    ///         "{} & import(\"effect\").Brand.Brand<\"{}\">",
178    ///         datatype,
179    ///         branded.brand()
180    ///     )))
181    /// });
182    /// # let _ = exporter;
183    /// ```
184    pub fn branded_type_impl(
185        mut self,
186        builder: impl for<'a> Fn(BrandedTypeExporter<'a>, &Branded) -> Result<Cow<'static, str>, Error>
187        + Send
188        + Sync
189        + 'static,
190    ) -> Self {
191        self.branded_type_impl = Some(BrandedTypeImpl(Arc::new(builder)));
192        self
193    }
194
195    /// Configure a header for the file.
196    ///
197    /// This is perfect for configuring lint ignore rules or other file-level comments.
198    pub fn header(mut self, header: impl Into<Cow<'static, str>>) -> Self {
199        self.header = header.into();
200        self
201    }
202
203    /// Configure the BigInt handling behaviour
204    pub fn bigint(mut self, bigint: BigIntExportBehavior) -> Self {
205        self.bigint = bigint;
206        self
207    }
208
209    /// Configure the bindings layout
210    pub fn layout(mut self, layout: Layout) -> Self {
211        self.layout = layout;
212        self
213    }
214
215    /// Configure the exporter to use specta-serde with the specified mode
216    pub fn with_serde(mut self, mode: SerdeMode) -> Self {
217        self.serde = Some(mode);
218        self
219    }
220
221    /// Configure the exporter to export the types for `#[derive(serde::Serialize)]`
222    pub fn with_serde_serialize(self) -> Self {
223        self.with_serde(SerdeMode::Serialize)
224    }
225
226    /// Configure the exporter to export the types for `#[derive(serde::Deserialize)]`
227    pub fn with_serde_deserialize(self) -> Self {
228        self.with_serde(SerdeMode::Deserialize)
229    }
230
231    /// Export the files into a single string.
232    ///
233    /// Note: This returns an error if the format is `Format::Files`.
234    pub fn export(&self, types: &TypeCollection) -> Result<String, Error> {
235        let types = if let Some(mode) = self.serde {
236            let mut types = types.clone(); // TODO: Can we avoid this?
237            specta_serde::apply(&mut types, mode)?;
238            Cow::Owned(types)
239        } else {
240            Cow::Borrowed(types)
241        };
242
243        if let Layout::Files = self.layout {
244            return Err(Error::unable_to_export(self.layout));
245        }
246        if let Layout::Namespaces = self.layout
247            && self.jsdoc
248        {
249            return Err(Error::unable_to_export(self.layout));
250        }
251
252        let mut out = render_file_header(self)?;
253
254        let mut has_manually_exported_user_types = false;
255        let mut runtime = Ok(Cow::default());
256        if let Some(framework_runtime) = &self.framework_runtime {
257            runtime = (framework_runtime.0)(FrameworkExporter {
258                exporter: self,
259                has_manually_exported_user_types: &mut has_manually_exported_user_types,
260                files_root_types: "",
261                types: &types,
262            });
263        }
264        let runtime = runtime?;
265
266        // Framework runtime
267        if !runtime.is_empty() {
268            out += "\n";
269        }
270        out += &runtime;
271        if !runtime.is_empty() {
272            out += "\n";
273        }
274
275        // User types (if not included in framework runtime)
276        if !has_manually_exported_user_types {
277            render_types(&mut out, self, &types, "")?;
278        }
279
280        Ok(out)
281    }
282
283    /// Export the types to a specific file/folder.
284    ///
285    /// When configured when `format` is `Format::Files`, you must provide a directory path.
286    /// Otherwise, you must provide the path of a single file.
287    ///
288    pub fn export_to(&self, path: impl AsRef<Path>, types: &TypeCollection) -> Result<(), Error> {
289        let path = path.as_ref();
290
291        if self.layout != Layout::Files {
292            let result = self.export(types)?;
293            if let Some(parent) = path.parent() {
294                std::fs::create_dir_all(parent)?;
295            };
296            std::fs::write(path, result)?;
297            return Ok(());
298        }
299
300        let types = if let Some(mode) = self.serde {
301            let mut types = types.clone(); // TODO: Can we avoid this?
302            specta_serde::apply(&mut types, mode)?;
303            Cow::Owned(types)
304        } else {
305            Cow::Borrowed(types)
306        };
307
308        fn export(
309            exporter: &Exporter,
310            types: &TypeCollection,
311            module: &mut Module,
312            s: &mut String,
313            path: &Path,
314            files: &mut HashMap<PathBuf, String>,
315        ) -> Result<bool, Error> {
316            module.types.sort_by(|a, b| {
317                a.name()
318                    .cmp(b.name())
319                    .then(a.module_path().cmp(b.module_path()))
320                    .then(a.location().cmp(&b.location()))
321            });
322            let (rendered_types_result, referenced_types) =
323                references::with_module_path(module.module_path.as_ref(), || {
324                    references::collect_references(|| {
325                        let mut rendered = String::new();
326                        let exports = render_flat_types(
327                            &mut rendered,
328                            exporter,
329                            types,
330                            module.types.iter().copied(),
331                            "",
332                        )?;
333                        Ok::<_, Error>((rendered, exports))
334                    })
335                });
336            let (rendered_types, exports) = rendered_types_result?;
337
338            let import_paths = referenced_types
339                .into_iter()
340                .filter_map(|r| {
341                    r.get(types)
342                        .map(|ndt| ndt.module_path().as_ref().to_string())
343                })
344                .filter(|module_path| module_path != module.module_path.as_ref())
345                .collect::<BTreeSet<_>>();
346            if !import_paths.is_empty() {
347                s.push('\n');
348                s.push_str(&module_import_block(
349                    exporter,
350                    module.module_path.as_ref(),
351                    &import_paths,
352                ));
353            }
354
355            if !import_paths.is_empty() && !rendered_types.is_empty() {
356                s.push('\n');
357            }
358
359            s.push_str(&rendered_types);
360
361            for (name, module) in &mut module.children {
362                // This doesn't account for `NamedDataType::requires_reference`
363                // but we keep it for performance.
364                if module.types.is_empty() && module.children.is_empty() {
365                    continue;
366                }
367
368                let mut path = path.join(name);
369                let mut out = render_file_header(exporter)?;
370
371                let has_types = export(exporter, types, module, &mut out, &path, files)?;
372                if has_types {
373                    path.set_extension(if exporter.jsdoc { "js" } else { "ts" });
374                    files.insert(path, out);
375                }
376            }
377
378            Ok(!exports.is_empty())
379        }
380
381        let mut files = HashMap::new();
382        let mut runtime_path = path.join("index");
383        runtime_path.set_extension(if self.jsdoc { "js" } else { "ts" });
384
385        let mut root_types = String::new();
386        export(
387            self,
388            &types,
389            &mut build_module_graph(&types),
390            &mut root_types,
391            path,
392            &mut files,
393        )?;
394
395        {
396            let mut has_manually_exported_user_types = false;
397            let mut runtime = Cow::default();
398            let mut runtime_references = HashSet::new();
399            if let Some(framework_runtime) = &self.framework_runtime {
400                let (runtime_result, referenced_types) = references::with_module_path("", || {
401                    references::collect_references(|| {
402                        (framework_runtime.0)(FrameworkExporter {
403                            exporter: self,
404                            has_manually_exported_user_types: &mut has_manually_exported_user_types,
405                            files_root_types: &root_types,
406                            types: &types,
407                        })
408                    })
409                });
410                runtime = runtime_result?;
411                runtime_references = referenced_types;
412            }
413
414            let should_export_user_types =
415                !has_manually_exported_user_types && !root_types.is_empty();
416
417            if !runtime.is_empty() || should_export_user_types {
418                files.insert(runtime_path, {
419                    let mut out = render_file_header(self)?;
420                    let mut body = String::new();
421
422                    // Framework runtime
423                    if !runtime.is_empty() {
424                        body.push_str(&runtime);
425                    }
426
427                    // User types (if not included in framework runtime)
428                    if should_export_user_types {
429                        if !body.is_empty() {
430                            body.push('\n');
431                        }
432
433                        body.push_str(&root_types);
434                    }
435
436                    let import_paths = runtime_references
437                        .into_iter()
438                        .filter_map(|r| {
439                            r.get(&types)
440                                .map(|ndt| ndt.module_path().as_ref().to_string())
441                        })
442                        .filter(|module_path| !module_path.is_empty())
443                        .collect::<BTreeSet<_>>();
444
445                    let import_paths = import_paths
446                        .into_iter()
447                        .filter(|module_path| {
448                            !body.contains(&module_import_statement(self, "", module_path))
449                        })
450                        .collect::<BTreeSet<_>>();
451
452                    if !import_paths.is_empty() {
453                        out.push('\n');
454                        out.push_str(&module_import_block(self, "", &import_paths));
455                    }
456
457                    if !body.is_empty() {
458                        out.push('\n');
459                        if !import_paths.is_empty() {
460                            out.push('\n');
461                        }
462                        out.push_str(&body);
463                    }
464
465                    out
466                });
467            }
468        }
469
470        match path.metadata() {
471            Ok(meta) if !meta.is_dir() => std::fs::remove_file(path).or_else(|source| {
472                if source.kind() == std::io::ErrorKind::NotFound {
473                    Ok(())
474                } else {
475                    Err(Error::remove_file(path.to_path_buf(), source))
476                }
477            })?,
478            Ok(_) => {}
479            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
480            Err(source) => {
481                return Err(Error::metadata(path.to_path_buf(), source));
482            }
483        }
484
485        for (path, content) in &files {
486            path.parent().map(std::fs::create_dir_all).transpose()?;
487            std::fs::write(path, content)?;
488        }
489
490        cleanup_stale_files(path, &files)?;
491
492        Ok(())
493    }
494}
495
496impl AsRef<Exporter> for Exporter {
497    fn as_ref(&self) -> &Exporter {
498        self
499    }
500}
501
502impl AsMut<Exporter> for Exporter {
503    fn as_mut(&mut self) -> &mut Exporter {
504        self
505    }
506}
507
508/// Reference to Typescript language exporter for branded type callbacks.
509pub struct BrandedTypeExporter<'a> {
510    pub(crate) exporter: &'a Exporter,
511    /// Collected types currently being exported.
512    pub types: &'a TypeCollection,
513}
514
515impl fmt::Debug for BrandedTypeExporter<'_> {
516    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
517        self.exporter.fmt(f)
518    }
519}
520
521impl AsRef<Exporter> for BrandedTypeExporter<'_> {
522    fn as_ref(&self) -> &Exporter {
523        self
524    }
525}
526
527impl Deref for BrandedTypeExporter<'_> {
528    type Target = Exporter;
529
530    fn deref(&self) -> &Self::Target {
531        self.exporter
532    }
533}
534
535impl BrandedTypeExporter<'_> {
536    /// [primitives::inline]
537    pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
538        primitives::inline(self, self.types, dt)
539    }
540
541    /// [primitives::reference]
542    pub fn reference(&self, r: &Reference) -> Result<String, Error> {
543        primitives::reference(self, self.types, r)
544    }
545}
546
547/// Reference to Typescript language exporter for framework
548pub struct FrameworkExporter<'a> {
549    exporter: &'a Exporter,
550    has_manually_exported_user_types: &'a mut bool,
551    // For `Layout::Files` we need to inject the value
552    files_root_types: &'a str,
553    /// Collected types currently being exported.
554    pub types: &'a TypeCollection,
555}
556
557impl fmt::Debug for FrameworkExporter<'_> {
558    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
559        self.exporter.fmt(f)
560    }
561}
562
563impl AsRef<Exporter> for FrameworkExporter<'_> {
564    fn as_ref(&self) -> &Exporter {
565        self
566    }
567}
568
569impl Deref for FrameworkExporter<'_> {
570    type Target = Exporter;
571
572    fn deref(&self) -> &Self::Target {
573        self.exporter
574    }
575}
576
577impl FrameworkExporter<'_> {
578    /// Render the types within the [TypeCollection].
579    ///
580    /// This will only work if used within [Self::framework_runtime] function.
581    /// It allows frameworks to intersperse their user types into their runtime code.
582    pub fn render_types(&mut self) -> Result<Cow<'static, str>, Error> {
583        let mut s = String::new();
584        render_types(&mut s, self.exporter, self.types, self.files_root_types)?;
585        *self.has_manually_exported_user_types = true;
586        Ok(Cow::Owned(s))
587    }
588
589    /// [primitives::inline]
590    pub fn inline(&self, dt: &DataType) -> Result<String, Error> {
591        primitives::inline(self, self.types, dt)
592    }
593
594    /// [primitives::reference]
595    pub fn reference(&self, r: &Reference) -> Result<String, Error> {
596        primitives::reference(self, self.types, r)
597    }
598
599    /// [primitives::export]
600    pub fn export<'a>(
601        &self,
602        ndts: impl Iterator<Item = &'a NamedDataType>,
603        indent: &'a str,
604    ) -> Result<String, Error> {
605        primitives::export(self, self.types, ndts, indent)
606    }
607}
608
609struct Module<'a> {
610    types: Vec<&'a NamedDataType>,
611    children: BTreeMap<&'a str, Module<'a>>,
612    module_path: Cow<'static, str>,
613}
614
615fn build_module_graph(types: &TypeCollection) -> Module<'_> {
616    types.into_unsorted_iter().fold(
617        Module {
618            types: Default::default(),
619            children: Default::default(),
620            module_path: Default::default(),
621        },
622        |mut ns, ndt| {
623            let path = ndt.module_path();
624
625            if path.is_empty() {
626                ns.types.push(ndt);
627            } else {
628                let mut current = &mut ns;
629                let mut current_path = String::new();
630                for segment in path.split("::") {
631                    if !current_path.is_empty() {
632                        current_path.push_str("::");
633                    }
634                    current_path.push_str(segment);
635
636                    current = current.children.entry(segment).or_insert_with(|| Module {
637                        types: Default::default(),
638                        children: Default::default(),
639                        module_path: current_path.clone().into(),
640                    });
641                }
642
643                current.types.push(ndt);
644            }
645
646            ns
647        },
648    )
649}
650
651fn render_file_header(exporter: &Exporter) -> Result<String, Error> {
652    let mut out = exporter.header.to_string();
653    if !exporter.header.is_empty() {
654        out += "\n";
655    }
656
657    out += &exporter.framework_prelude;
658    if !exporter.framework_prelude.is_empty() {
659        out += "\n";
660    }
661
662    Ok(out)
663}
664
665fn render_types(
666    s: &mut String,
667    exporter: &Exporter,
668    types: &TypeCollection,
669    files_user_types: &str,
670) -> Result<(), Error> {
671    match exporter.layout {
672        Layout::Namespaces => {
673            fn has_renderable_content(module: &Module<'_>, types: &TypeCollection) -> bool {
674                module.types.iter().any(|ndt| ndt.requires_reference(types))
675                    || module
676                        .children
677                        .values()
678                        .any(|child| has_renderable_content(child, types))
679            }
680
681            fn export<'a>(
682                exporter: &Exporter,
683                types: &TypeCollection,
684                s: &mut String,
685                module: impl ExactSizeIterator<Item = (&'a &'a str, &'a mut Module<'a>)>,
686                depth: usize,
687            ) -> Result<(), Error> {
688                let namespace_indent = "\t".repeat(depth);
689                let content_indent = "\t".repeat(depth + 1);
690
691                for (name, module) in module {
692                    if !has_renderable_content(module, types) {
693                        continue;
694                    }
695
696                    s.push('\n');
697                    s.push_str(&namespace_indent);
698                    if depth != 0 && *name != "$specta$" {
699                        s.push_str("export ");
700                    }
701                    s.push_str("namespace ");
702                    s.push_str(name);
703                    s.push_str(" {\n");
704
705                    // Types
706                    module.types.sort_by(|a, b| {
707                        a.name()
708                            .cmp(b.name())
709                            .then(a.module_path().cmp(b.module_path()))
710                            .then(a.location().cmp(&b.location()))
711                    });
712                    render_flat_types(
713                        s,
714                        exporter,
715                        types,
716                        module.types.iter().copied(),
717                        &content_indent,
718                    )?;
719
720                    // Namespaces
721                    export(exporter, types, s, module.children.iter_mut(), depth + 1)?;
722
723                    s.push_str(&namespace_indent);
724                    s.push_str("}\n");
725                }
726
727                Ok(())
728            }
729
730            let mut module = build_module_graph(types);
731
732            let reexports = {
733                let mut reexports = String::new();
734                for name in module
735                    .children
736                    .iter()
737                    .filter_map(|(name, module)| {
738                        has_renderable_content(module, types).then_some(*name)
739                    })
740                    .chain(
741                        module
742                            .types
743                            .iter()
744                            .filter(|ndt| ndt.requires_reference(types))
745                            .map(|ndt| ndt.name().as_ref()),
746                    )
747                {
748                    reexports.push_str("export import ");
749                    reexports.push_str(name);
750                    reexports.push_str(" = $s$.");
751                    reexports.push_str(name);
752                    reexports.push_str(";\n");
753                }
754                reexports
755            };
756
757            export(exporter, types, s, [(&"$s$", &mut module)].into_iter(), 0)?;
758            s.push_str(&reexports);
759        }
760        Layout::ModulePrefixedName | Layout::FlatFile => {
761            render_flat_types(s, exporter, types, types.into_sorted_iter(), "")?;
762        }
763        // The types will get their own files
764        // So we keep the user types empty for easy downstream detection.
765        Layout::Files => {
766            if !files_user_types.is_empty() {
767                s.push_str(files_user_types);
768            }
769        }
770    }
771
772    Ok(())
773}
774
775// Implementation of `Layout::ModulePrefixedName | Layout::FlatFile`,
776// but is used by `Layout::Namespace` and `Layout::Files`
777fn render_flat_types<'a>(
778    s: &mut String,
779    exporter: &Exporter,
780    types: &TypeCollection,
781    ndts: impl ExactSizeIterator<Item = &'a NamedDataType>,
782    indent: &str,
783) -> Result<HashMap<String, Location<'static>>, Error> {
784    let mut exports = HashMap::with_capacity(ndts.len());
785
786    let ndts = ndts
787        .filter(|ndt| ndt.requires_reference(types))
788        .map(|ndt| {
789            let export_name = exported_type_name(exporter, ndt);
790            if let Some(other) = exports.insert(export_name.to_string(), ndt.location()) {
791                return Err(Error::duplicate_type_name(
792                    export_name,
793                    ndt.location(),
794                    other,
795                ));
796            }
797
798            Ok(ndt)
799        })
800        .collect::<Result<Vec<_>, _>>()?;
801
802    primitives::export_internal(s, exporter, types, ndts.into_iter(), indent)?;
803
804    Ok(exports)
805}
806
807/// Collect all TypeScript/JavaScript files in a directory recursively
808fn collect_existing_files(root: &Path) -> Result<HashSet<PathBuf>, Error> {
809    if !root.exists() {
810        return Ok(HashSet::new());
811    }
812
813    let mut files = HashSet::new();
814    let entries =
815        std::fs::read_dir(root).map_err(|source| Error::read_dir(root.to_path_buf(), source))?;
816    for entry in entries {
817        let entry = entry.map_err(|source| Error::read_dir(root.to_path_buf(), source))?;
818        let path = entry.path();
819        let file_type = entry
820            .file_type()
821            .map_err(|source| Error::metadata(path.clone(), source))?;
822
823        if file_type.is_symlink() {
824            continue;
825        }
826
827        if file_type.is_dir() {
828            files.extend(collect_existing_files(&path)?);
829        } else if matches!(path.extension().and_then(|e| e.to_str()), Some("ts" | "js")) {
830            files.insert(path);
831        }
832    }
833
834    Ok(files)
835}
836
837/// Remove empty directories recursively, stopping at the root
838fn remove_empty_dirs(path: &Path, root: &Path) -> Result<(), Error> {
839    let entries =
840        std::fs::read_dir(path).map_err(|source| Error::read_dir(path.to_path_buf(), source))?;
841    for entry in entries {
842        let entry = entry.map_err(|source| Error::read_dir(path.to_path_buf(), source))?;
843        let entry_path = entry.path();
844        let file_type = entry
845            .file_type()
846            .map_err(|source| Error::metadata(entry_path.clone(), source))?;
847        if file_type.is_symlink() {
848            continue;
849        }
850        if file_type.is_dir() {
851            remove_empty_dirs(&entry_path, root)?;
852        }
853    }
854
855    let is_empty = path
856        .read_dir()
857        .map_err(|source| Error::read_dir(path.to_path_buf(), source))?
858        .next()
859        .is_none();
860
861    if path != root && is_empty {
862        match std::fs::remove_dir(path) {
863            Ok(()) => {}
864            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
865            Err(source) => {
866                return Err(Error::remove_dir(path.to_path_buf(), source));
867            }
868        }
869    }
870    Ok(())
871}
872
873/// Delete stale files and clean up empty directories
874fn cleanup_stale_files(root: &Path, current_files: &HashMap<PathBuf, String>) -> Result<(), Error> {
875    collect_existing_files(root)?
876        .into_iter()
877        .filter(|path| !current_files.contains_key(path))
878        .try_for_each(|path| {
879            std::fs::remove_file(&path).or_else(|source| {
880                if source.kind() == std::io::ErrorKind::NotFound {
881                    Ok(())
882                } else {
883                    Err(Error::remove_file(path.clone(), source))
884                }
885            })
886        })?;
887
888    remove_empty_dirs(root, root)?;
889
890    Ok(())
891}
892
893fn exported_type_name(exporter: &Exporter, ndt: &NamedDataType) -> Cow<'static, str> {
894    match exporter.layout {
895        Layout::ModulePrefixedName => {
896            let mut s = ndt.module_path().split("::").collect::<Vec<_>>().join("_");
897            s.push('_');
898            s.push_str(ndt.name());
899            Cow::Owned(s)
900        }
901        _ => ndt.name().clone(),
902    }
903}
904
905pub(crate) fn module_alias(module_path: &str) -> String {
906    if module_path.is_empty() {
907        "$root".to_string()
908    } else {
909        module_path.split("::").collect::<Vec<_>>().join("$")
910    }
911}
912
913fn module_import_statement(
914    exporter: &Exporter,
915    from_module_path: &str,
916    to_module_path: &str,
917) -> String {
918    let import_keyword = if exporter.jsdoc {
919        "import"
920    } else {
921        "import type"
922    };
923
924    format!(
925        "{} * as {} from \"{}\";",
926        import_keyword,
927        module_alias(to_module_path),
928        module_import_path(from_module_path, to_module_path)
929    )
930}
931
932fn module_import_block(
933    exporter: &Exporter,
934    from_module_path: &str,
935    import_paths: &BTreeSet<String>,
936) -> String {
937    if exporter.jsdoc {
938        let mut out = String::from("/**\n");
939
940        for module_path in import_paths {
941            out.push_str(" * @typedef {import(\"");
942            out.push_str(&module_import_path(from_module_path, module_path));
943            out.push_str("\")} ");
944            out.push_str(&module_alias(module_path));
945            out.push('\n');
946        }
947
948        out.push_str(" */");
949        out
950    } else {
951        import_paths
952            .iter()
953            .map(|module_path| module_import_statement(exporter, from_module_path, module_path))
954            .collect::<Vec<_>>()
955            .join("\n")
956    }
957}
958
959fn module_import_path(from_module_path: &str, to_module_path: &str) -> String {
960    fn module_file_segments(module_path: &str) -> Vec<&str> {
961        if module_path.is_empty() {
962            vec!["index"]
963        } else {
964            module_path.split("::").collect()
965        }
966    }
967
968    let from_file_segments = module_file_segments(from_module_path);
969    let from_dir_segments = &from_file_segments[..from_file_segments.len() - 1];
970    let to_file_segments = module_file_segments(to_module_path);
971
972    let shared = from_dir_segments
973        .iter()
974        .zip(to_file_segments.iter())
975        .take_while(|(a, b)| a == b)
976        .count();
977
978    let mut relative_parts = Vec::new();
979    relative_parts.extend(std::iter::repeat_n(
980        "..",
981        from_dir_segments.len().saturating_sub(shared),
982    ));
983    relative_parts.extend(to_file_segments.iter().skip(shared).copied());
984
985    if relative_parts
986        .first()
987        .is_none_or(|v| *v != "." && *v != "..")
988    {
989        relative_parts.insert(0, ".");
990    }
991
992    relative_parts.join("/")
993}