Skip to main content

htl_core/
lib.rs

1//! htl: Teal, hidden.
2//!
3//! Embeds the Teal compiler (`tl.lua`) into an mlua state so `.tl` sources can be
4//! type-checked, generated and executed without any external toolchain.
5//!
6//! - [`Htl::check`] / [`Htl::gen`]: type-check and generate Lua from a `.tl` file
7//! - [`Htl::install_searcher`]: strict `require` for `.tl` (type errors abort the require)
8//! - [`Htl::preload`]: register generated Lua (e.g. from `include_tl!`) under a module name
9//! - [`bundle`]: stripped-bytecode bundles produced by `htl build`
10
11pub use mlua;
12
13use anyhow::{Context, Result, anyhow, bail};
14use mlua::chunk::ChunkMode;
15use mlua::{Function, Lua, Table, Value, Variadic};
16use std::path::{Path, PathBuf};
17
18pub mod bundle;
19#[cfg(feature = "dts")]
20pub mod dts;
21#[cfg(feature = "pkg")]
22pub mod pkg;
23pub mod teal;
24pub mod testing;
25
26/// Registry key under which the prelude table is stored (lets `pkg::TealResolver`
27/// reach the compiler from a bare `&Lua`).
28pub(crate) const PRELUDE_REGISTRY_KEY: &str = "htl.prelude";
29
30const TL_SRC: &str = include_str!("../vendor/tl.lua");
31const LINT_SRC: &str = include_str!("lint.lua");
32const FMT_SRC: &str = include_str!("fmt.lua");
33const PRELUDE: &str = include_str!("prelude.lua");
34
35/// Teal version vendored into this crate.
36pub const TEAL_VERSION: &str = "0.24.8";
37
38/// Result of type-checking one `.tl` file.
39#[derive(Debug, Clone, Default)]
40pub struct CheckInfo {
41    /// `file:line:col: message` for syntax and type errors.
42    pub errors: Vec<String>,
43    /// `file:line:col: message` for warnings (non-fatal).
44    pub warnings: Vec<String>,
45    /// Files pulled in via `require` during checking (`.tl` / `.d.tl` / `.lua`).
46    pub deps: Vec<PathBuf>,
47    /// htl lint findings (`nil-index`, `enum-exhaustive`). Advisory unless the caller
48    /// promotes them (`htl check --strict`, `include_tl!`).
49    pub lints: Vec<String>,
50    /// Every `require("<literal>")` in the file and where the checker resolved it.
51    /// Input to [`require_cycles`].
52    pub requires: Vec<RequireSite>,
53}
54
55/// One literal `require` call in a checked file.
56#[derive(Debug, Clone)]
57pub struct RequireSite {
58    pub module: String,
59    /// Resolved file, `None` when the checker could not find it.
60    pub path: Option<PathBuf>,
61    pub line: usize,
62    pub col: usize,
63}
64
65/// Cycles in the require graph of a set of checked files, one message per cycle,
66/// anchored at the first edge's call site. Teal types a circular require as an opaque
67/// `circular_require`, so a cycle shows up elsewhere as "cannot index" errors; naming
68/// the loop is the useful part. Files outside `infos` are treated as leaves.
69pub fn require_cycles(infos: &[(PathBuf, CheckInfo)]) -> Vec<String> {
70    use std::collections::{HashMap, HashSet};
71    let canon = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
72    let mut edges: HashMap<PathBuf, Vec<(PathBuf, &RequireSite)>> = HashMap::new();
73    let mut display: HashMap<PathBuf, PathBuf> = HashMap::new();
74    for (file, ci) in infos {
75        let from = canon(file);
76        display.insert(from.clone(), file.clone());
77        let list = edges.entry(from).or_default();
78        for r in &ci.requires {
79            if let Some(p) = &r.path {
80                list.push((canon(p), r));
81            }
82        }
83    }
84    let nodes: Vec<PathBuf> = {
85        let mut v: Vec<PathBuf> = edges.keys().cloned().collect();
86        v.sort();
87        v
88    };
89    let mut out = Vec::new();
90    let mut reported: HashSet<Vec<PathBuf>> = HashSet::new();
91    let mut state: HashMap<PathBuf, u8> = HashMap::new(); // 1 = on stack, 2 = done
92    let mut stack: Vec<(PathBuf, Option<&RequireSite>)> = Vec::new();
93
94    fn dfs<'a>(
95        node: PathBuf,
96        edges: &HashMap<PathBuf, Vec<(PathBuf, &'a RequireSite)>>,
97        state: &mut HashMap<PathBuf, u8>,
98        stack: &mut Vec<(PathBuf, Option<&'a RequireSite>)>,
99        reported: &mut HashSet<Vec<PathBuf>>,
100        display: &HashMap<PathBuf, PathBuf>,
101        out: &mut Vec<String>,
102    ) {
103        state.insert(node.clone(), 1);
104        if let Some(list) = edges.get(&node) {
105            for (to, site) in list {
106                match state.get(to).copied() {
107                    Some(1) => {
108                        // back edge: cycle = stack from `to` .. node, then back to `to`
109                        let start = stack.iter().position(|(n, _)| n == to).unwrap_or(0);
110                        let mut members: Vec<PathBuf> =
111                            stack[start..].iter().map(|(n, _)| n.clone()).chain(std::iter::once(node.clone())).collect();
112                        members.dedup();
113                        let mut key = members.clone();
114                        key.sort();
115                        if reported.insert(key) {
116                            let name = |p: &PathBuf| {
117                                display
118                                    .get(p)
119                                    .unwrap_or(p)
120                                    .file_name()
121                                    .map(|s| s.to_string_lossy().into_owned())
122                                    .unwrap_or_else(|| p.display().to_string())
123                            };
124                            let chain: Vec<String> = members.iter().map(name).chain(std::iter::once(name(to))).collect();
125                            let first_file = display.get(&members[0]).cloned().unwrap_or_else(|| members[0].clone());
126                            // anchor: the edge leaving the cycle's first member
127                            let anchor = stack.get(start + 1).and_then(|(_, s)| *s).unwrap_or(site);
128                            out.push(format!(
129                                "{}:{}:{}: require cycle: {} (Teal types the back edge as an opaque circular require; \
130                                 break it by moving shared types into a module both sides require) [htl require-cycle]",
131                                first_file.display(),
132                                anchor.line,
133                                anchor.col,
134                                chain.join(" -> ")
135                            ));
136                        }
137                    }
138                    Some(2) => {}
139                    _ => {
140                        stack.push((to.clone(), Some(site)));
141                        dfs(to.clone(), edges, state, stack, reported, display, out);
142                        stack.pop();
143                    }
144                }
145            }
146        }
147        state.insert(node, 2);
148    }
149
150    for n in nodes {
151        if !state.contains_key(&n) {
152            stack.push((n.clone(), None));
153            dfs(n, &edges, &mut state, &mut stack, &mut reported, &display, &mut out);
154            stack.pop();
155        }
156    }
157    out.sort();
158    out
159}
160
161impl CheckInfo {
162    pub fn ok(&self) -> bool {
163        self.errors.is_empty()
164    }
165
166    /// `true` when there are no errors, warnings or lints.
167    pub fn clean(&self) -> bool {
168        self.errors.is_empty() && self.warnings.is_empty() && self.lints.is_empty()
169    }
170}
171
172/// An mlua state with the Teal compiler loaded.
173pub struct Htl {
174    lua: Lua,
175    h: Table,
176}
177
178impl Htl {
179    /// New state. Uses `Lua::unsafe_new` so stripped bytecode bundles can be loaded.
180    pub fn new() -> Result<Self> {
181        // SAFETY: we accept binary chunks only from bundles we produced ourselves.
182        let lua = unsafe { Lua::unsafe_new() };
183        Self::from_lua(lua)
184    }
185
186    /// Attach the Teal compiler to an existing Lua state (the host's own `Lua`).
187    pub fn from_lua(lua: Lua) -> Result<Self> {
188        let tl_loader: Function = lua
189            .load(TL_SRC)
190            .set_name("=tl.lua")
191            .into_function()
192            .context("compiling vendored tl.lua")?;
193        let lint_loader: Function = lua
194            .load(LINT_SRC)
195            .set_name("=htl-lint")
196            .into_function()
197            .context("compiling htl lint.lua")?;
198        let package: Table = lua.globals().get("package")?;
199        let preload: Table = package.get("preload")?;
200        let fmt_loader: Function = lua
201            .load(FMT_SRC)
202            .set_name("=htl-fmt")
203            .into_function()
204            .context("compiling htl fmt.lua")?;
205        preload.set("tl", tl_loader)?;
206        preload.set("htl.lint", lint_loader)?;
207        preload.set("htl.fmt", fmt_loader)?;
208        let h: Table = lua
209            .load(PRELUDE)
210            .set_name("=htl-prelude")
211            .eval()
212            .context("loading htl prelude")?;
213        lua.set_named_registry_value(PRELUDE_REGISTRY_KEY, h.clone())?;
214        Ok(Self { lua, h })
215    }
216
217    pub fn lua(&self) -> &Lua {
218        &self.lua
219    }
220
221    /// Type-check one file.
222    pub fn check(&self, file: &Path) -> Result<CheckInfo> {
223        let f: Function = self.h.get("check")?;
224        let t: Table = f.call(path_str(file))?;
225        read_checkinfo(&t)
226    }
227
228    /// Type-check and generate Lua source. `None` code means errors (see `CheckInfo`).
229    pub fn gen_lua(&self, file: &Path) -> Result<(Option<String>, CheckInfo)> {
230        let f: Function = self.h.get("gen")?;
231        let (code, t): (Option<String>, Table) = f.call(path_str(file))?;
232        Ok((code, read_checkinfo(&t)?))
233    }
234
235    /// Configure lint rules: `"+no-any,-shadow-local"` on top of the defaults.
236    pub fn configure_lints(&self, spec: &str) -> Result<()> {
237        let f: Function = self.h.get("set_lints")?;
238        let (ok, err): (Option<bool>, Option<String>) = f.call(spec)?;
239        if ok.unwrap_or(false) {
240            Ok(())
241        } else {
242            bail!("{}", err.unwrap_or_else(|| "invalid lint spec".into()))
243        }
244    }
245
246    /// Names of all lint rules (enabled or not).
247    pub fn lint_rules(&self) -> Result<Vec<String>> {
248        let f: Function = self.h.get("lint_rules")?;
249        let t: Table = f.call(())?;
250        Ok(t.sequence_values::<String>().collect::<mlua::Result<_>>()?)
251    }
252
253    /// Format a `.tl` file (whitespace-only formatter). Returns the formatted text.
254    pub fn format_file(&self, file: &Path, indent: usize) -> Result<String> {
255        let f: Function = self.h.get("format")?;
256        let (out, err): (Option<String>, Option<String>) = f.call((path_str(file), indent))?;
257        out.ok_or_else(|| anyhow!("{}", err.unwrap_or_else(|| "format failed".into())))
258    }
259
260    /// Drop Lua's default search path (cwd-relative `./?.lua` etc.) so only directories
261    /// passed to [`add_path`](Self::add_path) are consulted by the checker and `require`.
262    pub fn reset_search_path(&self) -> Result<()> {
263        let f: Function = self.h.get("reset_path")?;
264        f.call::<()>(())?;
265        Ok(())
266    }
267
268    /// Search paths implied by where `file` sits in the scaffold layout: its own
269    /// directory, and for a file under `tests/` also the project root and `<root>/src`
270    /// (the test runner's rule, so `htl check tests` sees what `htl test` sees).
271    pub fn add_layout_paths(&self, file: &Path) -> Result<()> {
272        let dir = parent_dir(file);
273        self.add_path(&dir)?;
274        if dir.file_name().is_some_and(|n| n == "tests")
275            && let Some(root) = dir.parent()
276        {
277            self.add_path(root)?;
278            let src = root.join("src");
279            if src.is_dir() {
280                self.add_path(&src)?;
281            }
282        }
283        Ok(())
284    }
285
286    /// Prepend `dir/?.tl;dir/?/init.tl` to `package.path` (Teal resolves requires through it).
287    pub fn add_path(&self, dir: &Path) -> Result<()> {
288        let f: Function = self.h.get("add_path")?;
289        f.call::<()>(path_str(dir))?;
290        Ok(())
291    }
292
293    /// Install the strict `.tl` searcher: `require` of a `.tl` with type errors fails.
294    pub fn install_searcher(&self) -> Result<()> {
295        let f: Function = self.h.get("install_searcher")?;
296        f.call::<()>(())?;
297        Ok(())
298    }
299
300    /// Register generated Lua source under a module name (`package.preload`).
301    pub fn preload(&self, name: &str, lua_src: &str) -> Result<()> {
302        let loader = self
303            .lua
304            .load(lua_src)
305            .set_name(format!("={name}"))
306            .into_function()
307            .with_context(|| format!("compiling preloaded module {name}"))?;
308        self.preload_table()?.set(name, loader)?;
309        Ok(())
310    }
311
312    /// Register stripped bytecode (e.g. from `include_tl_bytes!`) under a module name.
313    pub fn preload_bytes(&self, name: &str, bytecode: &[u8]) -> Result<()> {
314        let loader = self
315            .lua
316            .load(bytecode)
317            .set_name(format!("={name}"))
318            .set_mode(ChunkMode::Binary)
319            .into_function()
320            .with_context(|| format!("loading bytecode for module {name}"))?;
321        self.preload_table()?.set(name, loader)?;
322        Ok(())
323    }
324
325    /// Execute stripped bytecode with `...` = args.
326    pub fn exec_bytes(&self, bytecode: &[u8], chunk_name: &str, args: &[String]) -> Result<()> {
327        let f = self
328            .lua
329            .load(bytecode)
330            .set_name(chunk_name)
331            .set_mode(ChunkMode::Binary)
332            .into_function()?;
333        let va: Variadic<String> = args.iter().cloned().collect();
334        f.call::<()>(va)?;
335        Ok(())
336    }
337
338    /// Register a ready-made value (typically a Rust-built table) as a module.
339    pub fn preload_value(&self, name: &str, value: impl mlua::IntoLua) -> Result<()> {
340        let value = value.into_lua(&self.lua)?;
341        let loader = self
342            .lua
343            .create_function(move |_, ()| Ok(value.clone()))?;
344        self.preload_table()?.set(name, loader)?;
345        Ok(())
346    }
347
348    fn preload_table(&self) -> Result<Table> {
349        let package: Table = self.lua.globals().get("package")?;
350        Ok(package.get("preload")?)
351    }
352
353    /// Set the global `arg` table like the `lua` CLI does.
354    pub fn set_arg(&self, script: &str, args: &[String]) -> Result<()> {
355        let t = self.lua.create_table()?;
356        t.set(0, script)?;
357        for (i, a) in args.iter().enumerate() {
358            t.set(i + 1, a.as_str())?;
359        }
360        self.lua.globals().set("arg", t)?;
361        Ok(())
362    }
363
364    /// Execute Lua source with `...` = args.
365    pub fn exec(&self, lua_src: &str, chunk_name: &str, args: &[String]) -> Result<()> {
366        let f = self
367            .lua
368            .load(lua_src)
369            .set_name(chunk_name)
370            .into_function()?;
371        let va: Variadic<String> = args.iter().cloned().collect();
372        f.call::<()>(va)?;
373        Ok(())
374    }
375
376    /// Check + gen + run a `.tl` script. If the check fails the script is not run and the
377    /// returned `CheckInfo` carries the errors. Runtime errors come back as `Err`.
378    pub fn run_file(&self, file: &Path, args: &[String]) -> Result<CheckInfo> {
379        self.add_path(&parent_dir(file))?;
380        self.install_searcher()?;
381        self.set_arg(&file.to_string_lossy(), args)?;
382        let (code, ci) = self.gen_lua(file)?;
383        let Some(code) = code else { return Ok(ci) };
384        self.exec(&code, &format!("@{}", file.display()), args)?;
385        Ok(ci)
386    }
387
388    /// Compile Lua source to stripped bytecode (Lua 5.4 format of this build).
389    pub fn compile(&self, name: &str, lua_src: &str) -> Result<Vec<u8>> {
390        let f = self
391            .lua
392            .load(lua_src)
393            .set_name(format!("={name}"))
394            .into_function()
395            .with_context(|| format!("compiling generated Lua for {name}"))?;
396        Ok(f.dump(true))
397    }
398
399    /// Install a searcher serving modules from a bundle.
400    pub fn install_bundle(&self, b: &bundle::Bundle) -> Result<()> {
401        let modules = b.modules.clone();
402        let searcher = self.lua.create_function(move |lua, name: String| {
403            match modules.iter().find(|(n, _)| *n == name) {
404                Some((_, bc)) => {
405                    let f = lua
406                        .load(bc.as_slice())
407                        .set_name(format!("={name}"))
408                        .set_mode(ChunkMode::Binary)
409                        .into_function()?;
410                    Ok(Value::Function(f))
411                }
412                None => Ok(Value::String(
413                    lua.create_string(format!("\n\tno bundled module '{name}'"))?,
414                )),
415            }
416        })?;
417        let package: Table = self.lua.globals().get("package")?;
418        let searchers: Table = package.get("searchers")?;
419        searchers.raw_insert(2, searcher)?;
420        Ok(())
421    }
422
423    /// Install the bundle and run its entry module with `...` = args.
424    pub fn run_bundle(&self, b: &bundle::Bundle, args: &[String]) -> Result<()> {
425        let entry_bc = b
426            .modules
427            .iter()
428            .find(|(n, _)| *n == b.entry)
429            .map(|(_, bc)| bc.clone())
430            .ok_or_else(|| anyhow!("entry module '{}' not in bundle", b.entry))?;
431        self.install_bundle(b)?;
432        self.set_arg(&b.entry, args)?;
433        let main: Function = self
434            .lua
435            .load(entry_bc.as_slice())
436            .set_name(format!("={}", b.entry))
437            .set_mode(ChunkMode::Binary)
438            .into_function()?;
439        let va: Variadic<String> = args.iter().cloned().collect();
440        main.call::<()>(va)?;
441        Ok(())
442    }
443}
444
445fn path_str(p: &Path) -> String {
446    p.to_string_lossy().into_owned()
447}
448
449/// A user-facing message for an error that came out of running Lua: the innermost
450/// cause without Lua's `stack traceback:` block. A host function's `Err(e)` surfaces
451/// as `e`'s own text; a Lua `error("msg")` surfaces as `file:line: msg`.
452///
453/// ```text
454/// sgen: content/no-date.md: front matter: 'date' is required
455/// ```
456/// instead of that line followed by `stack traceback: [C]: in method 'pages' ...`.
457pub fn user_message(err: &anyhow::Error) -> String {
458    fn from_mlua(e: &mlua::Error) -> String {
459        match e {
460            mlua::Error::CallbackError { cause, .. } => from_mlua(cause),
461            mlua::Error::ExternalError(ext) => ext.to_string(),
462            mlua::Error::WithContext { cause, .. } => from_mlua(cause),
463            other => strip_traceback(&other.to_string()),
464        }
465    }
466    if let Some(e) = err.downcast_ref::<mlua::Error>() {
467        return from_mlua(e);
468    }
469    strip_traceback(&format!("{err:#}"))
470}
471
472/// Remove a trailing Lua `stack traceback:` section from an error text.
473pub fn strip_traceback(text: &str) -> String {
474    let cut = text.find("\nstack traceback:").unwrap_or(text.len());
475    text[..cut].trim_end().to_string()
476}
477
478/// Write `text` to `path` only if the content differs. Returns `true` when written.
479/// Used by the derive macros to emit `.d.tl` files without churning cargo's fingerprints.
480pub fn write_if_changed(path: &Path, text: &str) -> std::io::Result<bool> {
481    if let Ok(cur) = std::fs::read_to_string(path)
482        && cur == text
483    {
484        return Ok(false);
485    }
486    if let Some(dir) = path.parent() {
487        std::fs::create_dir_all(dir)?;
488    }
489    std::fs::write(path, text)?;
490    Ok(true)
491}
492
493/// Parent directory of a file, `.` when the path has none.
494pub fn parent_dir(file: &Path) -> PathBuf {
495    let dir = file.parent().unwrap_or(Path::new("."));
496    if dir.as_os_str().is_empty() { PathBuf::from(".") } else { dir.to_path_buf() }
497}
498
499fn read_checkinfo(t: &Table) -> Result<CheckInfo> {
500    let seq = |key: &str| -> Result<Vec<String>> {
501        let inner: Table = t.get(key)?;
502        Ok(inner.sequence_values::<String>().collect::<mlua::Result<_>>()?)
503    };
504    let mut requires = Vec::new();
505    if let Ok(list) = t.get::<Table>("requires") {
506        for r in list.sequence_values::<Table>() {
507            let r = r?;
508            requires.push(RequireSite {
509                module: r.get::<String>("name")?,
510                path: r.get::<Option<String>>("path")?.map(PathBuf::from),
511                line: r.get::<Option<usize>>("y")?.unwrap_or(0),
512                col: r.get::<Option<usize>>("x")?.unwrap_or(0),
513            });
514        }
515    }
516    Ok(CheckInfo {
517        errors: seq("errors")?,
518        warnings: seq("warnings")?,
519        deps: seq("deps")?.into_iter().map(PathBuf::from).collect(),
520        lints: seq("lints")?,
521        requires,
522    })
523}
524
525/// `true` for `foo.tl` but not `foo.d.tl`.
526pub fn is_tl_source(p: &Path) -> bool {
527    let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
528    p.is_file() && name.ends_with(".tl") && !name.ends_with(".d.tl")
529}
530
531/// Directories never descended into when collecting sources under a root: build output,
532/// installed packages, VCS and tool state. A root passed explicitly is always walked.
533pub const SKIP_DIRS: &[&str] = &["target", "node_modules", ".mlua-pkgs", ".git"];
534
535/// `true` for a directory entry that source collection should not enter: a name in
536/// [`SKIP_DIRS`], any dot-directory, or the project's mlua-pkg directory (`pkgs_dir`,
537/// which `MLUA_PKG_DIR` can move somewhere unremarkable).
538pub fn is_skipped_dir(path: &Path, extra: &[PathBuf]) -> bool {
539    if !path.is_dir() {
540        return false;
541    }
542    let name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
543    if SKIP_DIRS.contains(&name) || (name.starts_with('.') && name.len() > 1) {
544        return true;
545    }
546    extra.iter().any(|e| same_dir(path, e))
547}
548
549fn same_dir(a: &Path, b: &Path) -> bool {
550    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
551        (Ok(x), Ok(y)) => x == y,
552        _ => a == b,
553    }
554}
555
556/// Extra directories to skip below `root`: the mlua-pkg package dir when `root` is
557/// inside an `mlua-pkg.toml` project (its vendored / cached sources are dependencies,
558/// not the project's own files).
559#[cfg(feature = "pkg")]
560pub fn project_skip_dirs(root: &Path) -> Vec<PathBuf> {
561    match pkg::Project::find(root) {
562        Some(p) => vec![p.pkgs_dir],
563        None => Vec::new(),
564    }
565}
566
567#[cfg(not(feature = "pkg"))]
568pub fn project_skip_dirs(_root: &Path) -> Vec<PathBuf> {
569    Vec::new()
570}
571
572/// Collect `.tl` sources from files and directories (sorted, recursive). Directories in
573/// [`SKIP_DIRS`], dot-directories and the project's package dir are not entered unless
574/// given as a root themselves.
575pub fn collect_tl(paths: &[PathBuf]) -> Result<Vec<PathBuf>> {
576    let mut out = Vec::new();
577    for p in paths {
578        if p.is_dir() {
579            let extra = project_skip_dirs(p);
580            let root = p.clone();
581            let walker = walkdir::WalkDir::new(p)
582                .sort_by_file_name()
583                .into_iter()
584                .filter_entry(move |e| e.path() == root || !is_skipped_dir(e.path(), &extra));
585            for e in walker {
586                let e = e?;
587                if is_tl_source(e.path()) {
588                    out.push(e.path().to_path_buf());
589                }
590            }
591        } else if p.is_file() {
592            out.push(p.clone());
593        } else {
594            bail!("no such file or directory: {}", p.display());
595        }
596    }
597    Ok(out)
598}
599
600/// `root/foo/bar.tl` -> `foo.bar`, `root/foo/init.tl` -> `foo`.
601pub fn module_name(root: &Path, file: &Path) -> Result<String> {
602    let rel = file.strip_prefix(root)?.with_extension("");
603    let mut parts: Vec<String> = rel
604        .components()
605        .map(|c| c.as_os_str().to_string_lossy().into_owned())
606        .collect();
607    if parts.last().map(|s| s == "init").unwrap_or(false) {
608        parts.pop();
609    }
610    if parts.is_empty() {
611        bail!("cannot derive module name for {}", file.display());
612    }
613    Ok(parts.join("."))
614}