Skip to main content

bynk_emit/emitter/
runtime_files.rs

1//! The runtime as per-module files (ADR 0615, #2426).
2//!
3//! A project emits `__bynk_runtime/<module>.{ts,js}` for the runtime modules its
4//! emitted code reaches, and no others. Generated code imports each helper from
5//! the module that defines it; this module is where a list of runtime names
6//! becomes one import per defining module, and where the reached set is closed
7//! over the modules' own imports.
8//!
9//! The directory name starts with `_`, which a Bynk identifier cannot (#1653),
10//! and a user file mirrors its source path, so no user path can collide with it.
11
12use std::collections::BTreeSet;
13use std::path::PathBuf;
14
15use bynk_ts::TsDecl;
16
17use super::runtime_modules::{RUNTIME_EXPORTS, RUNTIME_MODULES};
18
19/// The directory (at the root of the output) the runtime modules are emitted to.
20pub const RUNTIME_DIR: &str = "__bynk_runtime";
21
22/// The module that defines the exported runtime name `name`.
23fn module_of(name: &str) -> Option<&'static str> {
24    RUNTIME_EXPORTS
25        .binary_search_by_key(&name, |(n, _)| n)
26        .ok()
27        .map(|i| RUNTIME_EXPORTS[i].1)
28}
29
30/// Split a runtime specifier (`./runtime.js`, `../../runtime.ts`) into the
31/// directory prefix and the extension, or `None` if it is not one.
32fn split_specifier(from: &str) -> Option<(&str, &str)> {
33    let (prefix, file) = from.rsplit_once('/')?;
34    let ext = file.strip_prefix("runtime.")?;
35    matches!(ext, "js" | "ts").then_some((&from[..=prefix.len()], ext))
36}
37
38/// One import declaration per runtime module defining a name in `names`.
39///
40/// `from` is the project-relative runtime specifier the producer already
41/// computes (`runtime_import_for`); each name keeps its `type ` prefix and any
42/// `as` rename. A group whose names are all types is emitted as
43/// `import type`, so a type-only edge never survives type-stripping as a value
44/// import. Modules come out in runtime dependency order, which keeps the output
45/// deterministic.
46pub(crate) fn split_runtime_import(type_only: bool, names: &[String], from: &str) -> Vec<TsDecl> {
47    try_split_runtime_import(type_only, names, from)
48        .unwrap_or_else(|| panic!("an import from {from} names a non-runtime export: {names:?}"))
49}
50
51/// [`split_runtime_import`] that answers `None` for a specifier that is not a
52/// runtime one or a name the runtime does not export, so hand-written input
53/// (a user's binding) is left for `tsc` to report instead of crashing.
54fn try_split_runtime_import(type_only: bool, names: &[String], from: &str) -> Option<Vec<TsDecl>> {
55    let (prefix, ext) = split_specifier(from)?;
56    let mut groups: Vec<(&'static str, Vec<String>)> = RUNTIME_MODULES
57        .iter()
58        .map(|(m, _)| (*m, Vec::new()))
59        .collect();
60    for name in names {
61        let bare = name.strip_prefix("type ").unwrap_or(name);
62        let imported = bare.split(" as ").next().unwrap_or(bare);
63        let module = module_of(imported)?;
64        let group = groups
65            .iter_mut()
66            .find(|(m, _)| *m == module)
67            .expect("module is in the table");
68        group.1.push(name.clone());
69    }
70    Some(
71        groups
72            .into_iter()
73            .filter(|(_, names)| !names.is_empty())
74            .map(|(module, names)| {
75                let all_types = names.iter().all(|n| n.starts_with("type "));
76                let (type_only, names) = if all_types && !type_only {
77                    (
78                        true,
79                        names
80                            .iter()
81                            .map(|n| n.trim_start_matches("type ").to_string())
82                            .collect(),
83                    )
84                } else {
85                    (type_only, names)
86                };
87                TsDecl::Import {
88                    type_only,
89                    names,
90                    from: format!("{prefix}{RUNTIME_DIR}/{module}.{ext}"),
91                }
92            })
93            .collect(),
94    )
95}
96
97/// The `import … from "…";` statements of hand-written TypeScript as
98/// `(first line, last line, statement text)` ranges, a multi-line braced import
99/// joined into one. Only statements that start a line (after indentation).
100fn import_statements(lines: &[&str]) -> Vec<(usize, usize, String)> {
101    let mut out = Vec::new();
102    let mut i = 0;
103    while i < lines.len() {
104        let t = lines[i].trim_start();
105        if t.starts_with("import ") || t.starts_with("import{") {
106            let mut j = i;
107            let mut text = t.trim_end().to_string();
108            // A braced list may span lines; stop at the `}` line, never run past
109            // a statement that is not closed within a few hundred lines.
110            while text.contains('{') && !text.contains('}') && j + 1 < lines.len() && j - i < 500 {
111                j += 1;
112                text.push(' ');
113                text.push_str(lines[j].trim());
114            }
115            out.push((i, j, text));
116            i = j + 1;
117        } else {
118            i += 1;
119        }
120    }
121    out
122}
123
124/// Rewrite the `import … from "<…>/runtime.js"` statements of hand-written
125/// TypeScript (adapter bindings, first-party or the user's) to per-module
126/// imports. Multi-line, unterminated, indented and trailing-comment forms are
127/// handled. A statement that is not a braced named import of runtime exports (a
128/// namespace import, a typo'd name) is left as written, for `tsc` to report.
129pub fn rewrite_runtime_imports(source: &str) -> String {
130    let crlf = source.contains("\r\n");
131    let nl = if crlf { "\r\n" } else { "\n" };
132    let lines: Vec<&str> = source
133        .split('\n')
134        .map(|l| l.trim_end_matches('\r'))
135        .collect();
136    let mut out: Vec<String> = Vec::with_capacity(lines.len());
137    let mut i = 0;
138    for (first, last, text) in import_statements(&lines) {
139        out.extend(lines[i..first].iter().map(|l| l.to_string()));
140        match rewrite_import_statement(&text) {
141            Some(rewritten) => {
142                let indent = &lines[first][..lines[first].len() - lines[first].trim_start().len()];
143                out.extend(rewritten.into_iter().map(|l| format!("{indent}{l}")));
144            }
145            None => out.extend(lines[first..=last].iter().map(|l| l.to_string())),
146        }
147        i = last + 1;
148    }
149    out.extend(lines[i..].iter().map(|l| l.to_string()));
150    out.join(nl)
151}
152
153fn rewrite_import_statement(stmt: &str) -> Option<Vec<String>> {
154    let rest = stmt.strip_prefix("import")?.trim_start();
155    let (type_only, rest) = match rest.strip_prefix("type ") {
156        Some(r) => (true, r.trim_start()),
157        None => (false, rest),
158    };
159    let rest = rest.strip_prefix('{')?;
160    let (list, rest) = rest.split_once('}')?;
161    let rest = rest.trim_start().strip_prefix("from")?.trim_start();
162    let quote = rest.chars().next().filter(|c| matches!(c, '"' | '\''))?;
163    let rest = &rest[1..];
164    let (spec, tail) = rest.split_once(quote)?;
165    split_specifier(spec)?;
166    // Whatever follows the specifier may be `;`, nothing, or a comment.
167    let tail = tail.trim();
168    let comment = tail.strip_prefix(';').unwrap_or(tail).trim();
169    if !(comment.is_empty() || comment.starts_with("//") || comment.starts_with("/*")) {
170        return None;
171    }
172    let names: Vec<String> = list
173        .split(',')
174        .map(|n| n.trim().to_string())
175        .filter(|n| !n.is_empty())
176        .collect();
177    let decls = try_split_runtime_import(type_only, &names, spec)?;
178    let mut lines: Vec<String> = decls
179        .iter()
180        .map(|d| match d {
181            TsDecl::Import {
182                type_only,
183                names,
184                from,
185            } => format!(
186                "import {}{{ {} }} from {quote}{from}{quote};",
187                if *type_only { "type " } else { "" },
188                names.join(", ")
189            ),
190            _ => unreachable!("split_runtime_import yields named imports"),
191        })
192        .collect();
193    if let (false, Some(last)) = (comment.is_empty(), lines.last_mut()) {
194        last.push(' ');
195        last.push_str(comment);
196    }
197    Some(lines)
198}
199
200/// The runtime module named by an import specifier, with its extension.
201fn reached_module(specifier: &str) -> Option<(&str, &str)> {
202    let rest = specifier.rsplit_once(&format!("{RUNTIME_DIR}/"))?.1;
203    let (module, ext) = rest.rsplit_once('.')?;
204    Some((module, ext))
205}
206
207/// Every runtime module an emitted file's imports name, found in the
208/// specifiers it imports and in the `import … from` lines of its verbatim text.
209pub(crate) fn reached_in(program: &bynk_ts::TsProgram, out: &mut BTreeSet<(String, String)>) {
210    let mut note = |spec: &str| {
211        if let Some((m, e)) = reached_module(spec) {
212            out.insert((m.to_string(), e.to_string()));
213        }
214    };
215    for spec in program.import_specifiers() {
216        note(spec);
217    }
218    for (_, text) in program.verbatim_content() {
219        let lines: Vec<&str> = text.lines().collect();
220        for (_, _, stmt) in import_statements(&lines) {
221            if let Some((_, tail)) = stmt.rsplit_once(" from ") {
222                let spec = tail.trim().trim_start_matches(['"', '\'']);
223                if let Some(end) = spec.find(['"', '\'']) {
224                    note(&spec[..end]);
225                }
226            }
227        }
228    }
229}
230
231/// The files for `reached` (module, extension) pairs closed over the runtime
232/// modules' own imports. Type-only edges count: `tsc` over the emitted `.ts`
233/// needs the files, and one rule keeps the output deterministic.
234pub(crate) fn closure_files(reached: &BTreeSet<(String, String)>) -> Vec<(PathBuf, String)> {
235    let mut files = Vec::new();
236    for ext in reached
237        .iter()
238        .map(|(_, e)| e.as_str())
239        .collect::<BTreeSet<_>>()
240    {
241        let mut want: Vec<&str> = reached
242            .iter()
243            .filter(|(_, e)| e == ext)
244            .map(|(m, _)| m.as_str())
245            .collect();
246        let mut done: BTreeSet<&str> = BTreeSet::new();
247        while let Some(m) = want.pop() {
248            if !done.insert(m) {
249                continue;
250            }
251            // A name that is not a runtime module (a hand-written binding's typo)
252            // emits nothing; `tsc` reports the missing file.
253            let Some((_, src)) = RUNTIME_MODULES.iter().find(|(n, _)| *n == m) else {
254                done.remove(m);
255                continue;
256            };
257            for line in src.lines().filter(|l| l.starts_with("import ")) {
258                if let Some(dep) = line
259                    .rsplit_once("\"./")
260                    .and_then(|(_, t)| t.strip_suffix(".ts\";"))
261                {
262                    want.push(dep);
263                }
264            }
265        }
266        for m in done {
267            files.push((module_path(m), module_source(m, ext)));
268        }
269    }
270    files
271}
272
273/// Always a `.ts` file: specifiers say `.js` (resolved by `tsc` / stripped by
274/// `--emit js`) or `.ts` (the debug build), but the document is TypeScript.
275fn module_path(module: &str) -> PathBuf {
276    PathBuf::from(format!("{RUNTIME_DIR}/{module}.ts"))
277}
278
279/// A module's source with its sibling imports spelled for the target extension.
280fn module_source(module: &str, ext: &str) -> String {
281    let (_, src) = RUNTIME_MODULES
282        .iter()
283        .find(|(n, _)| *n == module)
284        .expect("module is in the table");
285    if ext == "ts" {
286        return src.to_string();
287    }
288    src.lines()
289        .map(|l| {
290            if l.starts_with("import ") {
291                l.replace(".ts\";", &format!(".{ext}\";"))
292            } else {
293                l.to_string()
294            }
295        })
296        .collect::<Vec<_>>()
297        .join("\n")
298        + "\n"
299}
300
301/// Every runtime module as `(path, source)`, for tests that stage a runtime by
302/// hand beside emitted code. Extension `js` spells sibling imports as `.js`.
303pub fn emit_all_runtime_modules() -> Vec<(PathBuf, String)> {
304    RUNTIME_MODULES
305        .iter()
306        .map(|(m, _)| (module_path(m), module_source(m, "js")))
307        .collect()
308}
309
310/// [`split_runtime_import`] as top-level statements, for producers building a
311/// `TsProgram`.
312pub(crate) fn runtime_import_stmts(
313    type_only: bool,
314    names: &[String],
315    from: &str,
316) -> Vec<bynk_ts::TsStmt> {
317    split_runtime_import(type_only, names, from)
318        .into_iter()
319        .map(|d| bynk_ts::TsStmt::decl(d, None))
320        .collect()
321}