char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somext_build: support generating typedefs from typescript implf90c0ce

main
12.2 KiB315 linesraw
1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result, bail};
5use oxc::allocator::Allocator;
6use oxc::ast::ast::{Declaration, IdentifierReference, ImportOrExportKind, Statement};
7use oxc::ast::builder::AstBuilder;
8use oxc::ast_visit::Visit;
9use oxc::codegen::{Codegen, CodegenOptions};
10use oxc::diagnostics::Diagnostics;
11use oxc::isolated_declarations::{IsolatedDeclarations, IsolatedDeclarationsOptions};
12use oxc::parser::Parser;
13use oxc::semantic::SemanticBuilder;
14use oxc::span::{SPAN, SourceType};
15use oxc::transformer::{TransformOptions, Transformer};
16
17/// Compile one TypeScript source from a Cargo build script.
18///
19/// Paths are relative to the extension crate. Outputs mirror the source path
20/// relative to `src/` in `OUT_DIR`: `src/main.ts` produces `main.js` and, when a
21/// public module is named, `main.d.ts`, while `src/public/posix.ts` produces
22/// `public/posix.js` and `public/posix.d.ts`. Imports are preserved, not resolved
23/// or bundled. Public declarations support self-contained exported functions,
24/// variables, interfaces, and type aliases, plus the two shapes a facade needs
25/// beyond that: private interfaces and type aliases referenced by exported
26/// signatures (kept in the ambient module, not exported), and type-only imports
27/// and re-exports naming a public `avril:` module (preserved verbatim).
28///
29/// This does not type-check the source; validate erasable TypeScript separately.
30pub fn build(source: impl AsRef<Path>, public_module: Option<&str>) -> Result<()> {
31    let path = source.as_ref();
32    println!("cargo:rerun-if-changed=build.rs");
33    println!("cargo:rerun-if-changed={}", path.display());
34    let output = output_path(
35        &PathBuf::from(std::env::var_os("OUT_DIR").context("Missing OUT_DIR")?),
36        path,
37    )?;
38    let source =
39        std::fs::read_to_string(path).with_context(|| format!("Reading {}", path.display()))?;
40    let compiled = compile(path, &source, public_module)
41        .with_context(|| format!("Compiling {}", path.display()))?;
42    if let Some(parent) = output.parent() {
43        std::fs::create_dir_all(parent)?;
44    }
45    std::fs::write(output.with_extension("js"), compiled.javascript)?;
46    let declaration_path = output.with_extension("d.ts");
47    if let Some(declaration) = compiled.declaration {
48        std::fs::write(declaration_path, declaration)?;
49    } else {
50        // OUT_DIR persists when a formerly public entry becomes internal.
51        match std::fs::remove_file(declaration_path) {
52            Ok(()) => {}
53            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
54            Err(error) => return Err(error.into()),
55        }
56    }
57    Ok(())
58}
59
60/// A facade may only name another public module; anything else would leak a
61/// private module graph into the emitted ambient declaration.
62fn is_public_module(source: &str) -> bool {
63    source.starts_with("avril:")
64}
65
66/// Outputs mirror the source path relative to `src/`, so one crate can own
67/// several public modules without colliding.
68fn output_path(out_dir: &Path, source: &Path) -> Result<PathBuf> {
69    let relative = source
70        .strip_prefix("src")
71        .context("Source must live under src/")?;
72    Ok(out_dir.join(relative))
73}
74
75/// Identifiers a declaration mentions. Used to keep only the private types the
76/// public surface actually reaches into the emitted ambient module.
77#[derive(Default)]
78struct References<'a>(HashSet<&'a str>);
79
80impl<'a> Visit<'a> for References<'a> {
81    fn visit_identifier_reference(&mut self, reference: &IdentifierReference<'a>) {
82        self.0.insert(reference.name.as_str());
83    }
84}
85
86fn references<'a>(statement: &Statement<'a>) -> HashSet<&'a str> {
87    let mut references = References::default();
88    references.visit_statement(statement);
89    references.0
90}
91
92fn private_name<'a>(statement: &Statement<'a>) -> Option<&'a str> {
93    match statement {
94        Statement::TSInterfaceDeclaration(interface) => Some(interface.id.name.as_str()),
95        Statement::TSTypeAliasDeclaration(alias) => Some(alias.id.name.as_str()),
96        _ => None,
97    }
98}
99
100fn imported_name<'a>(specifier: &'a oxc::ast::ast::ImportDeclarationSpecifier<'a>) -> &'a str {
101    use oxc::ast::ast::ImportDeclarationSpecifier::{
102        ImportDefaultSpecifier, ImportNamespaceSpecifier, ImportSpecifier,
103    };
104    match specifier {
105        ImportSpecifier(specifier) => specifier.local.name.as_str(),
106        ImportDefaultSpecifier(specifier) => specifier.local.name.as_str(),
107        ImportNamespaceSpecifier(specifier) => specifier.local.name.as_str(),
108    }
109}
110
111struct Compiled {
112    javascript: String,
113    declaration: Option<String>,
114}
115
116fn diagnostics(diagnostics: Diagnostics, source: &str) -> Result<()> {
117    if !diagnostics.is_empty() {
118        bail!(
119            "{}",
120            diagnostics
121                .into_iter()
122                .map(|diagnostic| diagnostic.render_with_source_code(source))
123                .collect::<Vec<_>>()
124                .join("\n")
125        );
126    }
127    Ok(())
128}
129
130fn compile(path: &Path, source: &str, public_module: Option<&str>) -> Result<Compiled> {
131    let allocator = Allocator::default();
132    let parsed = Parser::new(&allocator, source, SourceType::ts()).parse();
133    diagnostics(parsed.diagnostics, source)?;
134    let mut program = parsed.program;
135    let declaration = if let Some(module) = public_module {
136        let mut declaration =
137            IsolatedDeclarations::new(&allocator, IsolatedDeclarationsOptions::default())
138                .build(&program);
139        diagnostics(declaration.diagnostics, source)?;
140
141        // A public entry must carry its own types; private module graphs are
142        // not bundled. Strip `declare` from the exports the surface is written
143        // with, and the empty `export {}` marker oxc may emit; it is re-added
144        // canonically below only when a private type survives.
145        let mut index = 0;
146        while index < declaration.program.body.len() {
147            let strip = match &mut declaration.program.body[index] {
148                Statement::ExportDeclaration(export) => {
149                    match &mut export.declaration {
150                        Declaration::FunctionDeclaration(function) => function.declare = false,
151                        Declaration::VariableDeclaration(variable) => variable.declare = false,
152                        Declaration::TSInterfaceDeclaration(interface) => interface.declare = false,
153                        Declaration::TSTypeAliasDeclaration(alias) => alias.declare = false,
154                        _ => bail!(
155                            "Unsupported public declaration for {module:?}: expected an exported function, variable, interface, or type alias"
156                        ),
157                    };
158                    false
159                }
160                Statement::TSInterfaceDeclaration(interface) => {
161                    interface.declare = false;
162                    false
163                }
164                Statement::TSTypeAliasDeclaration(alias) => {
165                    alias.declare = false;
166                    false
167                }
168                Statement::ImportDeclaration(_) | Statement::ExportFromDeclaration(_) => false,
169                Statement::ExportNamedDeclaration(export) => {
170                    if !export.specifiers.is_empty() {
171                        bail!(
172                            "Unsupported public declaration for {module:?}: expected an exported function, variable, interface, or type alias"
173                        );
174                    }
175                    true
176                }
177                Statement::ExportDefaultDeclaration(_) => bail!(
178                    "Unsupported public declaration for {module:?}: a public surface exports named declarations"
179                ),
180                // An ambient module can only describe types. Non-exported
181                // values and classes describe nothing a guest can reach, so
182                // they never belong in it.
183                _ => true,
184            };
185            if strip {
186                declaration.program.body.remove(index);
187            } else {
188                index += 1;
189            }
190        }
191
192        // The ambient module carries the surface, not the implementation: drop
193        // private declarations the exported ones never reach, then the imports
194        // nothing retained names any more.
195        let mut reachable: HashSet<&str> = declaration
196            .program
197            .body
198            .iter()
199            .filter(|statement| matches!(statement, Statement::ExportDeclaration(_)))
200            .flat_map(references)
201            .collect();
202        let mut privates: Vec<(&str, HashSet<&str>)> = declaration
203            .program
204            .body
205            .iter()
206            .filter_map(|statement| {
207                private_name(statement).map(|name| (name, references(statement)))
208            })
209            .collect();
210        loop {
211            let mut grew = false;
212            for (name, refs) in &privates {
213                if reachable.contains(name) {
214                    for reference in refs {
215                        grew |= reachable.insert(reference);
216                    }
217                }
218            }
219            if !grew {
220                break;
221            }
222        }
223        declaration.program.body.retain(|statement| {
224            private_name(statement).is_none_or(|name| reachable.contains(name))
225        });
226
227        let used: HashSet<&str> = declaration
228            .program
229            .body
230            .iter()
231            .flat_map(references)
232            .collect();
233        declaration
234            .program
235            .body
236            .retain(|statement| match statement {
237                Statement::ImportDeclaration(import) => import
238                    .specifiers
239                    .iter()
240                    .flatten()
241                    .any(|specifier| used.contains(imported_name(specifier))),
242                _ => true,
243            });
244        privates.clear();
245
246        // What remains must still be expressible by guests: type-only
247        // references that name a public module, never a private one.
248        for statement in &declaration.program.body {
249            let (source, kind) = match statement {
250                Statement::ImportDeclaration(import) => {
251                    (import.source.value.as_str(), import.import_kind)
252                }
253                Statement::ExportFromDeclaration(export) => {
254                    (export.source.value.as_str(), export.export_kind)
255                }
256                _ => continue,
257            };
258            if kind != ImportOrExportKind::Type || !is_public_module(source) {
259                bail!(
260                    "Unsupported public declaration for {module:?}: reference to {source:?} must be type-only and name a public `avril:` module"
261                );
262            }
263        }
264
265        let kept_private = declaration
266            .program
267            .body
268            .iter()
269            .any(|statement| private_name(statement).is_some());
270        if kept_private {
271            let ast_builder = AstBuilder::new(&allocator);
272            declaration
273                .program
274                .body
275                .push(Statement::new_export_named_declaration(
276                    SPAN,
277                    [],
278                    ImportOrExportKind::Value,
279                    &ast_builder,
280                ));
281        }
282
283        let public = Codegen::new()
284            .with_options(CodegenOptions {
285                initial_indent: 1,
286                ..Default::default()
287            })
288            .build(&declaration.program)
289            .code;
290        let name = serde_json::to_string(module)?;
291        Some(format!(
292            "// Generated from {path:?}.\ndeclare module {name} {{\n{public}}}\n"
293        ))
294    } else {
295        None
296    };
297
298    let semantic = SemanticBuilder::new_compiler().build(&program);
299    diagnostics(semantic.diagnostics, source)?;
300    // Keep native JS syntax and runtime imports; only explicit type imports are erased.
301    let mut options = TransformOptions::default();
302    options.typescript.only_remove_type_imports = true;
303    let transformed = Transformer::new(&allocator, path, &options)
304        .build_with_scoping(semantic.semantic.into_scoping(), &mut program);
305    diagnostics(transformed.diagnostics, source)?;
306    Ok(Compiled {
307        javascript: Codegen::new().build(&program).code,
308        declaration,
309    })
310}
311
312pub mod testing;
313
314#[cfg(test)]
315mod tests;