Skip to main content

geam_core/frontend/
project.rs

1use super::program::{
2    ParsedModule, compile_parsed_host_package_program, compile_parsed_package_program, parse_module,
3};
4use super::{FrontendError, HostedTypedProgram, ModuleSource, TypedProgram};
5use crate::host::{HostProfile, HostProviderSet};
6use camino::{Utf8Path, Utf8PathBuf};
7use ecow::EcoString;
8use gleam_compiler_core::build::Target;
9use gleam_compiler_core::config::PackageConfig;
10use gleam_compiler_core::manifest::{Manifest, ManifestPackage, ManifestPackageSource};
11use gleam_compiler_core::type_::PRELUDE_MODULE_NAME;
12use gleam_compiler_core::warning::WarningEmitter;
13use std::collections::{BTreeMap, BTreeSet};
14use std::ffi::OsString;
15use std::fs;
16use thiserror::Error;
17
18const CONFIG_FILE: &str = "gleam.toml";
19const MANIFEST_FILE: &str = "manifest.toml";
20
21#[derive(Debug, Error)]
22pub enum ProjectError {
23    #[error("failed to read Gleam package config {path}")]
24    ConfigIo {
25        path: Utf8PathBuf,
26        #[source]
27        error: std::io::Error,
28    },
29
30    #[error("failed to read Gleam package manifest {path}")]
31    ManifestIo {
32        path: Utf8PathBuf,
33        #[source]
34        error: std::io::Error,
35    },
36
37    #[error("failed to read Gleam source {path}")]
38    SourceIo {
39        path: Utf8PathBuf,
40        #[source]
41        error: std::io::Error,
42    },
43
44    #[error("invalid Gleam package config {path}: {reason}")]
45    InvalidConfig { path: Utf8PathBuf, reason: String },
46
47    #[error("invalid Gleam package manifest {path}: {reason}")]
48    InvalidManifest { path: Utf8PathBuf, reason: String },
49
50    #[error("package {package} is incompatible with the embedded Gleam compiler: {error}")]
51    IncompatibleCompilerVersion {
52        package: EcoString,
53        error: Box<gleam_compiler_core::Error>,
54    },
55
56    #[error("package {required_by} requires {package}, but it is absent from manifest.toml")]
57    MissingManifestPackage {
58        required_by: EcoString,
59        package: EcoString,
60    },
61
62    #[error(
63        "resolved package {package} is not downloaded at {path}; run `gleam deps download` first"
64    )]
65    MissingDownloadedPackage {
66        package: EcoString,
67        path: Utf8PathBuf,
68    },
69
70    #[error(
71        "manifest package {expected} resolves to a package config named {actual} at {config_path}"
72    )]
73    PackageNameMismatch {
74        expected: EcoString,
75        actual: EcoString,
76        config_path: Utf8PathBuf,
77    },
78
79    #[error(transparent)]
80    Frontend(#[from] FrontendError),
81}
82
83pub fn compile_typed_project(
84    project_root: impl Into<Utf8PathBuf>,
85    root_module: impl Into<EcoString>,
86) -> Result<TypedProgram, ProjectError> {
87    let project = load_project(project_root.into(), root_module.into())?;
88    compile_parsed_package_program(
89        project.root_package,
90        project.root_module,
91        project.modules,
92        WarningEmitter::null(),
93    )
94    .map_err(ProjectError::from)
95}
96
97/// Compiles the selected import closure of an already resolved Gleam project
98/// with explicit Rust host modules and source providers.
99///
100/// This loader is read-only. It does not invoke Gleam CLI, download packages,
101/// or modify project files.
102pub fn compile_typed_host_project<Profile: HostProfile>(
103    project_root: impl Into<Utf8PathBuf>,
104    root_module: impl Into<EcoString>,
105    hosts: HostProviderSet<Profile>,
106) -> Result<HostedTypedProgram<Profile>, ProjectError> {
107    let project = load_project(project_root.into(), root_module.into())?;
108    let selected_source_modules = project
109        .modules
110        .iter()
111        .map(|module| (module.package.clone(), module.module.name.clone()))
112        .collect::<BTreeSet<_>>();
113    let hosts = hosts.select_source_providers(&selected_source_modules);
114    compile_parsed_host_package_program(
115        project.root_package,
116        project.root_module,
117        project.modules,
118        hosts,
119        WarningEmitter::null(),
120    )
121    .map_err(ProjectError::from)
122}
123
124struct ParsedProject {
125    root_package: EcoString,
126    root_module: EcoString,
127    modules: Vec<ParsedModule>,
128}
129
130fn load_project(
131    project_root: Utf8PathBuf,
132    root_module: EcoString,
133) -> Result<ParsedProject, ProjectError> {
134    let root_config = read_config(&project_root.join(CONFIG_FILE))?;
135    let manifest = read_manifest(&project_root.join(MANIFEST_FILE))?;
136    let manifest_packages = manifest_packages(&project_root, &manifest)?;
137    let packages = load_packages(&project_root, root_config, &manifest_packages)?;
138    let catalog = source_catalog(&packages)?;
139    let modules = select_import_closure(&packages.root, &root_module, &catalog)?;
140
141    Ok(ParsedProject {
142        root_package: packages.root.name,
143        root_module,
144        modules,
145    })
146}
147
148fn read_config(path: &Utf8Path) -> Result<PackageConfig, ProjectError> {
149    let source = fs::read_to_string(path).map_err(|error| ProjectError::ConfigIo {
150        path: path.to_path_buf(),
151        error,
152    })?;
153    let config =
154        toml::from_str::<PackageConfig>(&source).map_err(|error| ProjectError::InvalidConfig {
155            path: path.to_path_buf(),
156            reason: error.to_string(),
157        })?;
158    config.check_gleam_compatibility().map_err(|error| {
159        ProjectError::IncompatibleCompilerVersion {
160            package: config.name.clone(),
161            error: Box::new(error),
162        }
163    })?;
164    Ok(config)
165}
166
167fn read_manifest(path: &Utf8Path) -> Result<Manifest, ProjectError> {
168    let source = fs::read_to_string(path).map_err(|error| ProjectError::ManifestIo {
169        path: path.to_path_buf(),
170        error,
171    })?;
172    toml::from_str(&source).map_err(|error| ProjectError::InvalidManifest {
173        path: path.to_path_buf(),
174        reason: error.to_string(),
175    })
176}
177
178fn manifest_packages<'manifest>(
179    project_root: &Utf8Path,
180    manifest: &'manifest Manifest,
181) -> Result<BTreeMap<EcoString, &'manifest ManifestPackage>, ProjectError> {
182    let mut packages = BTreeMap::new();
183    for package in &manifest.packages {
184        if packages.insert(package.name.clone(), package).is_some() {
185            return Err(ProjectError::InvalidManifest {
186                path: project_root.join(MANIFEST_FILE),
187                reason: format!("package {} is listed more than once", package.name),
188            });
189        }
190    }
191    Ok(packages)
192}
193
194struct LoadedPackage {
195    name: EcoString,
196    root: Utf8PathBuf,
197    direct_dependencies: Box<[EcoString]>,
198}
199
200struct LoadedProject {
201    root: LoadedPackage,
202    dependencies: Vec<LoadedPackage>,
203}
204
205fn load_packages(
206    project_root: &Utf8Path,
207    root_config: PackageConfig,
208    manifest_packages: &BTreeMap<EcoString, &ManifestPackage>,
209) -> Result<LoadedProject, ProjectError> {
210    let root_name = root_config.name.clone();
211    let root_dependencies = dependency_names(&root_config);
212    let root = LoadedPackage {
213        name: root_name.clone(),
214        root: project_root.to_path_buf(),
215        direct_dependencies: root_dependencies.clone(),
216    };
217    let mut dependencies = Vec::new();
218    let mut loaded = BTreeSet::from([root_name.clone()]);
219    let mut pending = root_dependencies
220        .iter()
221        .cloned()
222        .map(|package| (root_name.clone(), package))
223        .collect::<BTreeSet<_>>();
224
225    while let Some((required_by, package_name)) = pending.pop_first() {
226        if loaded.contains(&package_name) {
227            continue;
228        }
229        let package = manifest_packages
230            .get(&package_name)
231            .copied()
232            .ok_or_else(|| ProjectError::MissingManifestPackage {
233                required_by,
234                package: package_name.clone(),
235            })?;
236        loaded.insert(package_name.clone());
237        if !package.build_tools.iter().any(|tool| tool == "gleam") {
238            continue;
239        }
240
241        let package_root = package_root(project_root, package);
242        if !package_root.is_dir() && !package.is_local() {
243            return Err(ProjectError::MissingDownloadedPackage {
244                package: package_name,
245                path: package_root,
246            });
247        }
248        let config_path = package_root.join(CONFIG_FILE);
249        let config = read_config(&config_path)?;
250        if config.name != package.name {
251            return Err(ProjectError::PackageNameMismatch {
252                expected: package.name.clone(),
253                actual: config.name,
254                config_path,
255            });
256        }
257        let direct_dependencies = dependency_names(&config);
258        for dependency in &direct_dependencies {
259            pending.insert((package.name.clone(), dependency.clone()));
260        }
261        dependencies.push(LoadedPackage {
262            name: package.name.clone(),
263            root: package_root,
264            direct_dependencies,
265        });
266    }
267
268    Ok(LoadedProject { root, dependencies })
269}
270
271fn dependency_names(config: &PackageConfig) -> Box<[EcoString]> {
272    config
273        .dependencies
274        .keys()
275        .cloned()
276        .collect::<BTreeSet<_>>()
277        .into_iter()
278        .collect()
279}
280
281fn package_root(project_root: &Utf8Path, package: &ManifestPackage) -> Utf8PathBuf {
282    match &package.source {
283        ManifestPackageSource::Hex { .. } | ManifestPackageSource::Git { .. } => project_root
284            .join("build")
285            .join("packages")
286            .join(package.name.as_str()),
287        ManifestPackageSource::Local { path } if path.is_absolute() => path.clone(),
288        ManifestPackageSource::Local { path } => project_root.join(path),
289    }
290}
291
292#[derive(Clone)]
293struct CatalogModule {
294    package: EcoString,
295    direct_dependencies: Box<[EcoString]>,
296    module: EcoString,
297    path: Utf8PathBuf,
298}
299
300fn source_catalog(
301    project: &LoadedProject,
302) -> Result<BTreeMap<EcoString, Vec<CatalogModule>>, ProjectError> {
303    let mut catalog = BTreeMap::<EcoString, Vec<CatalogModule>>::new();
304    for package in std::iter::once(&project.root).chain(&project.dependencies) {
305        let source_root = package.root.join("src");
306        for (module, path) in source_paths(&source_root)? {
307            catalog
308                .entry(module.clone())
309                .or_default()
310                .push(CatalogModule {
311                    package: package.name.clone(),
312                    direct_dependencies: package.direct_dependencies.clone(),
313                    module,
314                    path,
315                });
316        }
317    }
318    Ok(catalog)
319}
320
321fn source_paths(directory: &Utf8Path) -> Result<Vec<(EcoString, Utf8PathBuf)>, ProjectError> {
322    source_paths_from(&FileSystemSourceDirectory, directory, Utf8Path::new(""))
323}
324
325trait SourceDirectory {
326    fn entries(&self, directory: &Utf8Path)
327    -> Result<Vec<std::io::Result<OsString>>, ProjectError>;
328}
329
330struct FileSystemSourceDirectory;
331
332impl SourceDirectory for FileSystemSourceDirectory {
333    fn entries(
334        &self,
335        directory: &Utf8Path,
336    ) -> Result<Vec<std::io::Result<OsString>>, ProjectError> {
337        fs::read_dir(directory)
338            .map(|entries| {
339                entries
340                    .map(|entry| entry.map(|entry| entry.file_name()))
341                    .collect()
342            })
343            .map_err(|error| ProjectError::SourceIo {
344                path: directory.to_path_buf(),
345                error,
346            })
347    }
348}
349
350fn source_paths_from(
351    source_directory: &dyn SourceDirectory,
352    directory: &Utf8Path,
353    relative_directory: &Utf8Path,
354) -> Result<Vec<(EcoString, Utf8PathBuf)>, ProjectError> {
355    let mut paths = Vec::new();
356    for entry in source_directory.entries(directory)? {
357        let file_name = source_file_name(
358            directory,
359            entry.map_err(|error| ProjectError::SourceIo {
360                path: directory.to_path_buf(),
361                error,
362            })?,
363        )?;
364        let path = directory.join(&file_name);
365        let relative = relative_directory.join(file_name);
366        if path.is_dir() {
367            paths.extend(source_paths_from(source_directory, &path, &relative)?);
368        } else if path.extension() == Some("gleam") {
369            let module = relative
370                .with_extension("")
371                .as_str()
372                .replace('\\', "/")
373                .into();
374            paths.push((module, path));
375        }
376    }
377    paths.sort_by(|left, right| left.1.cmp(&right.1));
378    Ok(paths)
379}
380
381fn source_file_name(directory: &Utf8Path, file_name: OsString) -> Result<String, ProjectError> {
382    file_name
383        .into_string()
384        .map_err(|file_name| ProjectError::SourceIo {
385            path: directory.to_path_buf(),
386            error: std::io::Error::new(
387                std::io::ErrorKind::InvalidData,
388                format!("non-UTF-8 source path: {}", file_name.to_string_lossy()),
389            ),
390        })
391}
392
393fn select_import_closure(
394    root_package: &LoadedPackage,
395    root_module: &EcoString,
396    catalog: &BTreeMap<EcoString, Vec<CatalogModule>>,
397) -> Result<Vec<ParsedModule>, ProjectError> {
398    let root = catalog
399        .get(root_module)
400        .and_then(|modules| {
401            modules
402                .iter()
403                .find(|module| module.package == root_package.name)
404        })
405        .cloned()
406        .ok_or_else(|| {
407            ProjectError::Frontend(FrontendError::MissingRootModule {
408                package: root_package.name.clone(),
409                module: root_module.clone(),
410            })
411        })?;
412    let warnings = WarningEmitter::null();
413    let mut pending = BTreeMap::from([((root.package.clone(), root.module.clone()), root)]);
414    let mut selected = BTreeSet::new();
415    let mut parsed_modules = Vec::new();
416
417    while let Some((identity, module)) = pending.pop_first() {
418        if !selected.insert(identity) {
419            continue;
420        }
421        let source = fs::read_to_string(&module.path).map_err(|error| ProjectError::SourceIo {
422            path: module.path.clone(),
423            error,
424        })?;
425        let parsed = parse_module(
426            module.package,
427            module.direct_dependencies,
428            ModuleSource::new(module.module, module.path, source),
429            &warnings,
430        )?;
431        for (dependency, _) in parsed.module.dependencies(Target::Erlang) {
432            if dependency == PRELUDE_MODULE_NAME {
433                continue;
434            }
435            if let Some(modules) = catalog.get(&dependency) {
436                for module in modules {
437                    pending.insert(
438                        (module.package.clone(), module.module.clone()),
439                        module.clone(),
440                    );
441                }
442            }
443        }
444        parsed_modules.push(parsed);
445    }
446
447    Ok(parsed_modules)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::{
453        ProjectError, SourceDirectory, compile_typed_host_project, compile_typed_project,
454        source_paths_from,
455    };
456    use crate::host::{HostModule, HostProviderModule, HostProviderSet, StatelessHostProfile};
457    use crate::planner::UnsupportedFunctionReason;
458    use crate::{HostedExecution, PlanError, Value, plan_host_program, plan_program};
459    use camino::{Utf8Path, Utf8PathBuf};
460    use num_bigint::BigInt;
461    use std::fs;
462    use tempfile::{TempDir, tempdir};
463
464    struct FailedSourceDirectoryEntry;
465
466    impl SourceDirectory for FailedSourceDirectoryEntry {
467        fn entries(
468            &self,
469            _directory: &Utf8Path,
470        ) -> Result<Vec<std::io::Result<std::ffi::OsString>>, ProjectError> {
471            Ok(vec![Err(std::io::Error::new(
472                std::io::ErrorKind::PermissionDenied,
473                "entry unavailable",
474            ))])
475        }
476    }
477
478    #[cfg(unix)]
479    struct NonUtf8SourceDirectoryEntry;
480
481    #[cfg(unix)]
482    impl SourceDirectory for NonUtf8SourceDirectoryEntry {
483        fn entries(
484            &self,
485            _directory: &Utf8Path,
486        ) -> Result<Vec<std::io::Result<std::ffi::OsString>>, ProjectError> {
487            use std::os::unix::ffi::OsStringExt;
488
489            Ok(vec![Ok(std::ffi::OsString::from_vec(
490                b"invalid-\xff.gleam".to_vec(),
491            ))])
492        }
493    }
494
495    #[test]
496    fn loads_selected_hex_git_and_local_package_modules() {
497        let project = tempdir().expect("temporary project should be created");
498        let root = project_root(&project);
499        write_file(
500            &root,
501            "gleam.toml",
502            r#"
503name = "application"
504version = "1.0.0"
505
506[dependencies]
507hex_dep = ">= 1.0.0 and < 2.0.0"
508git_dep = ">= 1.0.0 and < 2.0.0"
509"#,
510        );
511        write_file(
512            &root,
513            "manifest.toml",
514            r#"
515packages = [
516  { name = "hex_dep", version = "1.0.0", build_tools = ["gleam"], requirements = ["local_dep"], source = "hex", outer_checksum = "00" },
517  { name = "git_dep", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://example.com/git_dep", commit = "0123456789abcdef" },
518  { name = "local_dep", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/local_dep" },
519]
520
521[requirements]
522"#,
523        );
524        write_file(
525            &root,
526            "src/main.gleam",
527            r#"
528import gleam
529import hex/nested
530
531pub fn main() {
532  nested.answer()
533}
534"#,
535        );
536        write_file(
537            &root,
538            "build/packages/hex_dep/gleam.toml",
539            r#"
540name = "hex_dep"
541version = "1.0.0"
542
543[dependencies]
544local_dep = { path = "../../../packages/local_dep" }
545"#,
546        );
547        write_file(
548            &root,
549            "build/packages/hex_dep/src/hex/nested.gleam",
550            r#"
551import local/value
552
553pub fn answer() {
554  value.answer()
555}
556"#,
557        );
558        write_file(
559            &root,
560            "build/packages/git_dep/gleam.toml",
561            r#"
562name = "git_dep"
563version = "1.0.0"
564
565[dependencies]
566local_dep = { path = "../../../packages/local_dep" }
567"#,
568        );
569        write_file(
570            &root,
571            "build/packages/git_dep/src/git/unselected.gleam",
572            "pub fn broken(",
573        );
574        write_file(
575            &root,
576            "packages/local_dep/gleam.toml",
577            r#"
578name = "local_dep"
579version = "1.0.0"
580"#,
581        );
582        write_file(
583            &root,
584            "packages/local_dep/src/local/value.gleam",
585            "pub fn answer() { 42 }",
586        );
587        write_file(&root, "src/README.md", "not a Gleam module");
588
589        let program =
590            compile_typed_project(root, "main").expect("resolved package project should compile");
591
592        assert_eq!(program.root_package(), "application");
593        assert_eq!(program.root_module(), "main");
594        assert_eq!(
595            program
596                .modules()
597                .map(|module| (module.type_info.package.as_str(), module.name.as_str()))
598                .collect::<Vec<_>>(),
599            [
600                ("local_dep", "local/value"),
601                ("hex_dep", "hex/nested"),
602                ("application", "main"),
603            ],
604        );
605    }
606
607    #[test]
608    fn loads_hosted_hex_git_and_local_modules_through_the_shared_project_owner() {
609        let project = tempdir().expect("temporary project should be created");
610        let root = project_root(&project);
611        write_file(
612            &root,
613            "gleam.toml",
614            r#"
615name = "application"
616version = "1.0.0"
617
618[dependencies]
619hex_dep = ">= 1.0.0 and < 2.0.0"
620git_dep = ">= 1.0.0 and < 2.0.0"
621"#,
622        );
623        write_file(
624            &root,
625            "manifest.toml",
626            r#"
627packages = [
628  { name = "hex_dep", version = "1.0.0", build_tools = ["gleam"], requirements = ["local_dep"], source = "hex", outer_checksum = "00" },
629  { name = "git_dep", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "git", repo = "https://example.com/git_dep", commit = "0123456789abcdef" },
630  { name = "local_dep", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/local_dep" },
631]
632
633[requirements]
634"#,
635        );
636        write_file(
637            &root,
638            "src/main.gleam",
639            r#"
640import git/value as git_value
641import hex/nested
642
643pub fn main() {
644  nested.answer() + git_value.answer()
645}
646"#,
647        );
648        write_file(
649            &root,
650            "build/packages/hex_dep/gleam.toml",
651            r#"
652name = "hex_dep"
653version = "1.0.0"
654
655[dependencies]
656local_dep = { path = "../../../packages/local_dep" }
657"#,
658        );
659        write_file(
660            &root,
661            "build/packages/hex_dep/src/hex/nested.gleam",
662            r#"
663import local/value
664
665pub fn answer() {
666  value.answer()
667}
668"#,
669        );
670        write_file(
671            &root,
672            "build/packages/git_dep/gleam.toml",
673            "name = \"git_dep\"\nversion = \"1.0.0\"\n",
674        );
675        write_file(
676            &root,
677            "build/packages/git_dep/src/git/value.gleam",
678            "pub fn answer() { 2 }",
679        );
680        write_file(
681            &root,
682            "packages/local_dep/gleam.toml",
683            "name = \"local_dep\"\nversion = \"1.0.0\"\n",
684        );
685        write_file(
686            &root,
687            "packages/local_dep/src/local/value.gleam",
688            r#"
689@external(erlang, "host", "answer")
690pub fn answer() -> Int
691"#,
692        );
693
694        let plain = compile_typed_project(root.clone(), "main")
695            .expect("plain project should share the selected source closure");
696        let provider = HostProviderModule::<StatelessHostProfile>::new("local_dep", "local/value")
697            .expect("provider module should be valid")
698            .with_function("answer", || BigInt::from(40))
699            .expect("provider function should be valid");
700        let hosted = compile_typed_host_project(
701            root.clone(),
702            "main",
703            HostProviderSet::with_providers(Vec::<HostModule>::new(), [provider])
704                .expect("provider module should be unique"),
705        )
706        .expect("hosted project should compile through the shared loader");
707        let plan = plan_host_program(hosted).expect("hosted project should plan");
708
709        let expected_modules = [
710            ("git_dep", "git/value"),
711            ("local_dep", "local/value"),
712            ("hex_dep", "hex/nested"),
713            ("application", "main"),
714        ];
715        assert_eq!(
716            plain
717                .modules()
718                .map(|module| (module.type_info.package.as_str(), module.name.as_str()))
719                .collect::<Vec<_>>(),
720            expected_modules,
721        );
722        assert_eq!(
723            plan.modules()
724                .iter()
725                .map(|module| (module.package().as_str(), module.module().as_str()))
726                .collect::<Vec<_>>(),
727            expected_modules,
728        );
729        assert_eq!(
730            plan.modules()
731                .iter()
732                .map(|module| {
733                    module
734                        .source_context()
735                        .expect("filesystem modules should preserve source context")
736                        .path()
737                        .to_path_buf()
738                })
739                .collect::<Vec<_>>(),
740            [
741                root.join("build/packages/git_dep/src/git/value.gleam"),
742                root.join("packages/local_dep/src/local/value.gleam"),
743                root.join("build/packages/hex_dep/src/hex/nested.gleam"),
744                root.join("src/main.gleam"),
745            ],
746        );
747
748        let execution = HostedExecution::try_from_module_plan(plan)
749            .expect("hosted project execution should seal");
750        assert_eq!(
751            execution.run_main(&mut (), &mut Vec::new()),
752            Ok(Value::Int(42.into())),
753        );
754    }
755
756    #[test]
757    fn selects_only_provider_modules_in_the_resolved_source_closure() {
758        let project = tempdir().expect("temporary project should be created");
759        let root = project_root(&project);
760        write_file(
761            &root,
762            "gleam.toml",
763            "name = \"application\"\nversion = \"1.0.0\"\n",
764        );
765        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
766        write_file(
767            &root,
768            "src/main.gleam",
769            "import used\npub fn main() { used.value() }",
770        );
771        write_file(
772            &root,
773            "src/used.gleam",
774            r#"
775@external(erlang, "host", "value")
776pub fn value() -> Int
777"#,
778        );
779        write_file(
780            &root,
781            "src/unused.gleam",
782            r#"
783@external(erlang, "host", "value")
784pub fn value() -> Int
785"#,
786        );
787        let unused = HostProviderModule::<StatelessHostProfile>::new("application", "unused")
788            .expect("unused provider should be valid")
789            .with_function("value", BigInt::default)
790            .expect("unused provider function should be valid");
791        let selected = HostProviderModule::<StatelessHostProfile>::new("application", "used")
792            .expect("selected provider should be valid")
793            .with_function("value", || BigInt::from(42))
794            .expect("selected provider function should be valid");
795        let typed = compile_typed_host_project(
796            root,
797            "main",
798            HostProviderSet::with_providers(Vec::<HostModule>::new(), [unused, selected])
799                .expect("provider modules should be unique"),
800        )
801        .expect("unselected source providers should not enter the hosted program");
802        let plan = plan_host_program(typed).expect("selected provider should plan");
803        let execution = HostedExecution::try_from_module_plan(plan)
804            .expect("selected provider should seal for execution");
805
806        assert_eq!(
807            execution.run_main(&mut (), &mut Vec::new()),
808            Ok(Value::Int(42.into())),
809        );
810    }
811
812    #[test]
813    fn keeps_missing_project_providers_at_the_hosted_planner_boundary() {
814        let project = tempdir().expect("temporary project should be created");
815        let root = project_root(&project);
816        write_file(
817            &root,
818            "gleam.toml",
819            r#"
820name = "application"
821version = "1.0.0"
822
823[dependencies]
824library = { path = "packages/library" }
825"#,
826        );
827        write_file(
828            &root,
829            "manifest.toml",
830            r#"
831packages = [
832  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/library" },
833]
834
835[requirements]
836"#,
837        );
838        write_file(
839            &root,
840            "src/main.gleam",
841            "import support\npub fn main() { support.value() }",
842        );
843        write_file(
844            &root,
845            "packages/library/gleam.toml",
846            "name = \"library\"\nversion = \"1.0.0\"\n",
847        );
848        write_file(
849            &root,
850            "packages/library/src/support.gleam",
851            r#"
852@external(erlang, "host", "value")
853pub fn value() -> Int
854"#,
855        );
856
857        let typed = compile_typed_host_project(
858            root,
859            "main",
860            HostProviderSet::new(Vec::<HostModule>::new())
861                .expect("empty host modules should be valid"),
862        )
863        .expect("provider selection should remain a planning boundary");
864
865        assert_eq!(
866            plan_host_program(typed).err(),
867            Some(PlanError::MissingHostProvider {
868                package: "library".into(),
869                module: "support".into(),
870                function: "value".into(),
871            }),
872        );
873    }
874
875    #[test]
876    fn preserves_hosted_frontend_errors_in_project_errors() {
877        let project = tempdir().expect("temporary project should be created");
878        let root = project_root(&project);
879        write_file(
880            &root,
881            "gleam.toml",
882            "name = \"application\"\nversion = \"1.0.0\"\n",
883        );
884        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
885        write_file(&root, "src/main.gleam", "pub fn main() { 1 }");
886        let host = HostModule::new("host_support", "main").expect("host module should be valid");
887
888        let error = compile_typed_host_project(
889            root.clone(),
890            "main",
891            HostProviderSet::new([host]).expect("host module should be unique"),
892        )
893        .err()
894        .expect("source and host modules should still collide");
895
896        assert_eq!(
897            format!("{error:?}"),
898            format!(
899                "Frontend(SourceHostModuleCollision {{ module: \"main\", source_package: \"application\", source_path: {:?}, host_package: \"host_support\" }})",
900                root.join("src/main.gleam"),
901            ),
902        );
903    }
904
905    #[test]
906    fn excludes_root_dev_dependencies_from_the_resolved_program() {
907        let project = tempdir().expect("temporary project should be created");
908        let root = project_root(&project);
909        write_file(
910            &root,
911            "gleam.toml",
912            r#"
913name = "application"
914version = "1.0.0"
915
916[dev-dependencies]
917dev_only = ">= 1.0.0 and < 2.0.0"
918"#,
919        );
920        write_file(
921            &root,
922            "manifest.toml",
923            r#"
924packages = [
925  { name = "dev_only", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "hex", outer_checksum = "00" },
926]
927
928[requirements]
929"#,
930        );
931        write_file(&root, "src/main.gleam", "pub fn main() { 1 }");
932
933        let program =
934            compile_typed_project(root, "main").expect("development dependency should be ignored");
935
936        assert_eq!(
937            program
938                .modules()
939                .map(|module| module.name.as_str())
940                .collect::<Vec<_>>(),
941            ["main"],
942        );
943    }
944
945    #[test]
946    fn skips_non_gleam_resolved_packages() {
947        let project = tempdir().expect("temporary project should be created");
948        let root = project_root(&project);
949        write_file(
950            &root,
951            "gleam.toml",
952            r#"
953name = "application"
954version = "1.0.0"
955
956[dependencies]
957build_only = ">= 1.0.0 and < 2.0.0"
958"#,
959        );
960        write_file(
961            &root,
962            "manifest.toml",
963            r#"
964packages = [
965  { name = "build_only", version = "1.0.0", build_tools = ["rebar3"], requirements = [], source = "hex", outer_checksum = "00" },
966]
967
968[requirements]
969"#,
970        );
971        write_file(&root, "src/main.gleam", "pub fn main() { 1 }");
972
973        let program =
974            compile_typed_project(root, "main").expect("non-Gleam package should be skipped");
975
976        assert_eq!(
977            program
978                .modules()
979                .map(|module| module.name.as_str())
980                .collect::<Vec<_>>(),
981            ["main"],
982        );
983    }
984
985    #[test]
986    fn ignores_unselected_modules_but_plans_every_selected_body() {
987        let project = tempdir().expect("temporary project should be created");
988        let root = project_root(&project);
989        write_file(
990            &root,
991            "gleam.toml",
992            r#"
993name = "application"
994version = "1.0.0"
995
996[dependencies]
997library = ">= 1.0.0 and < 2.0.0"
998"#,
999        );
1000        write_file(
1001            &root,
1002            "manifest.toml",
1003            r#"
1004packages = [
1005  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "hex", outer_checksum = "00" },
1006]
1007
1008[requirements]
1009"#,
1010        );
1011        write_file(
1012            &root,
1013            "src/main.gleam",
1014            "import selected\npub fn main() { selected.value() }",
1015        );
1016        write_file(
1017            &root,
1018            "build/packages/library/gleam.toml",
1019            "name = \"library\"\nversion = \"1.0.0\"\n",
1020        );
1021        write_file(
1022            &root,
1023            "build/packages/library/src/selected.gleam",
1024            r#"
1025pub fn value() {
1026  1
1027}
1028
1029fn unsupported_unused_body() {
1030  <<1:native>>
1031}
1032"#,
1033        );
1034        write_file(
1035            &root,
1036            "build/packages/library/src/unselected.gleam",
1037            "pub fn broken(",
1038        );
1039
1040        let program =
1041            compile_typed_project(root, "main").expect("selected source closure should compile");
1042        assert_eq!(
1043            program
1044                .modules()
1045                .map(|module| module.name.as_str())
1046                .collect::<Vec<_>>(),
1047            ["selected", "main"],
1048        );
1049        assert_eq!(
1050            plan_program(program),
1051            Err(PlanError::UnsupportedBitArraySegment {
1052                reason: crate::planner::UnsupportedBitArraySegmentReason::NativeEndianness,
1053            }),
1054        );
1055    }
1056
1057    #[test]
1058    fn preserves_dependency_source_paths_in_module_plans() {
1059        let project = tempdir().expect("temporary project should be created");
1060        let root = project_root(&project);
1061        write_file(
1062            &root,
1063            "gleam.toml",
1064            r#"
1065name = "application"
1066version = "1.0.0"
1067
1068[dependencies]
1069library = { path = "packages/library" }
1070"#,
1071        );
1072        write_file(
1073            &root,
1074            "manifest.toml",
1075            r#"
1076packages = [
1077  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/library" },
1078]
1079
1080[requirements]
1081"#,
1082        );
1083        write_file(
1084            &root,
1085            "src/main.gleam",
1086            "import support\npub fn main() { support.value() }",
1087        );
1088        write_file(
1089            &root,
1090            "packages/library/gleam.toml",
1091            "name = \"library\"\nversion = \"1.0.0\"\n",
1092        );
1093        write_file(
1094            &root,
1095            "packages/library/src/support.gleam",
1096            "pub fn value() { 42 }",
1097        );
1098
1099        let plan = plan_program(
1100            compile_typed_project(root.clone(), "main")
1101                .expect("dependency source project should compile"),
1102        )
1103        .expect("dependency source project should plan");
1104
1105        assert_eq!(
1106            plan.modules()
1107                .iter()
1108                .map(|module| {
1109                    (
1110                        module.package().as_str(),
1111                        module
1112                            .source_context()
1113                            .expect("filesystem module should preserve source context")
1114                            .path()
1115                            .as_str(),
1116                    )
1117                })
1118                .collect::<Vec<_>>(),
1119            [
1120                (
1121                    "library",
1122                    root.join("packages/library/src/support.gleam").as_str(),
1123                ),
1124                ("application", root.join("src/main.gleam").as_str()),
1125            ],
1126        );
1127    }
1128
1129    #[test]
1130    fn keeps_selected_external_functions_at_the_existing_planner_boundary() {
1131        let project = tempdir().expect("temporary project should be created");
1132        let root = project_root(&project);
1133        write_file(
1134            &root,
1135            "gleam.toml",
1136            r#"
1137name = "application"
1138version = "1.0.0"
1139
1140[dependencies]
1141library = { path = "packages/library" }
1142"#,
1143        );
1144        write_file(
1145            &root,
1146            "manifest.toml",
1147            r#"
1148packages = [
1149  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/library" },
1150]
1151
1152[requirements]
1153"#,
1154        );
1155        write_file(
1156            &root,
1157            "src/main.gleam",
1158            "import support\npub fn main() { support.value() }",
1159        );
1160        write_file(
1161            &root,
1162            "packages/library/gleam.toml",
1163            "name = \"library\"\nversion = \"1.0.0\"\n",
1164        );
1165        write_file(
1166            &root,
1167            "packages/library/src/support.gleam",
1168            r#"
1169@external(erlang, "support", "value")
1170pub fn value() -> Int
1171"#,
1172        );
1173
1174        let program =
1175            compile_typed_project(root, "main").expect("external source should type-check");
1176
1177        assert_eq!(
1178            plan_program(program),
1179            Err(PlanError::UnsupportedFunction {
1180                name: "value".into(),
1181                reason: UnsupportedFunctionReason::External,
1182            }),
1183        );
1184    }
1185
1186    #[test]
1187    fn resolves_absolute_local_package_paths() {
1188        let project = tempdir().expect("temporary project should be created");
1189        let dependency = tempdir().expect("temporary dependency should be created");
1190        let root = project_root(&project);
1191        let dependency_root = project_root(&dependency);
1192        write_file(
1193            &root,
1194            "gleam.toml",
1195            r#"
1196name = "application"
1197version = "1.0.0"
1198
1199[dependencies]
1200library = { path = "../library" }
1201"#,
1202        );
1203        write_file(
1204            &root,
1205            "manifest.toml",
1206            &format!(
1207                r#"
1208packages = [
1209  {{ name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "{dependency_root}" }},
1210]
1211
1212[requirements]
1213"#,
1214            ),
1215        );
1216        write_file(
1217            &root,
1218            "src/main.gleam",
1219            "import support\npub fn main() { support.value() }",
1220        );
1221        write_file(
1222            &dependency_root,
1223            "gleam.toml",
1224            "name = \"library\"\nversion = \"1.0.0\"\n",
1225        );
1226        write_file(
1227            &dependency_root,
1228            "src/support.gleam",
1229            "pub fn value() { 42 }",
1230        );
1231
1232        let program =
1233            compile_typed_project(root, "main").expect("absolute local package should compile");
1234
1235        assert_eq!(
1236            program
1237                .modules()
1238                .map(|module| (module.type_info.package.as_str(), module.name.as_str()))
1239                .collect::<Vec<_>>(),
1240            [("library", "support"), ("application", "main")],
1241        );
1242    }
1243
1244    #[test]
1245    fn rejects_missing_package_config_for_plain_and_hosted_projects() {
1246        let project = tempdir().expect("temporary project should be created");
1247        let root = project_root(&project);
1248
1249        let plain_error = compile_typed_project(root.clone(), "main")
1250            .expect_err("missing package config should fail");
1251        let hosted_error = compile_typed_host_project(
1252            root.clone(),
1253            "main",
1254            HostProviderSet::new(Vec::<HostModule>::new())
1255                .expect("empty host modules should be valid"),
1256        )
1257        .err()
1258        .expect("missing package config should fail before hosted compilation");
1259        let expected = format!(
1260            "failed to read Gleam package config {}",
1261            root.join("gleam.toml"),
1262        );
1263
1264        assert_eq!(plain_error.to_string(), expected);
1265        assert_eq!(hosted_error.to_string(), expected);
1266    }
1267
1268    #[test]
1269    fn rejects_invalid_package_config() {
1270        let project = tempdir().expect("temporary project should be created");
1271        let root = project_root(&project);
1272        write_file(&root, "gleam.toml", "name = 1");
1273
1274        let error = compile_typed_project(root.clone(), "main")
1275            .expect_err("invalid package config should fail");
1276
1277        assert_eq!(
1278            error.to_string(),
1279            format!(
1280                "invalid Gleam package config {}: TOML parse error at line 1, column 8\n  |\n1 | name = 1\n  |        ^\ninvalid type: integer `1`, expected a package name\n",
1281                root.join("gleam.toml"),
1282            ),
1283        );
1284    }
1285
1286    #[test]
1287    fn rejects_incompatible_embedded_compiler_versions() {
1288        let project = tempdir().expect("temporary project should be created");
1289        let root = project_root(&project);
1290        write_file(
1291            &root,
1292            "gleam.toml",
1293            r#"
1294name = "application"
1295version = "1.0.0"
1296gleam = ">= 99.0.0"
1297"#,
1298        );
1299
1300        let error = compile_typed_project(root, "main")
1301            .expect_err("incompatible Gleam compiler requirement should fail");
1302
1303        assert_eq!(
1304            error.to_string(),
1305            "package application is incompatible with the embedded Gleam compiler: The package application requires a Gleam version satisfying >=99.0.0 and you are using v1.18.1",
1306        );
1307    }
1308
1309    #[test]
1310    fn rejects_missing_manifests() {
1311        let project = tempdir().expect("temporary project should be created");
1312        let root = project_root(&project);
1313        write_file(
1314            &root,
1315            "gleam.toml",
1316            "name = \"application\"\nversion = \"1.0.0\"\n",
1317        );
1318
1319        let error =
1320            compile_typed_project(root.clone(), "main").expect_err("missing manifest should fail");
1321
1322        assert_eq!(
1323            error.to_string(),
1324            format!(
1325                "failed to read Gleam package manifest {}",
1326                root.join("manifest.toml"),
1327            ),
1328        );
1329    }
1330
1331    #[test]
1332    fn rejects_invalid_manifests() {
1333        let project = tempdir().expect("temporary project should be created");
1334        let root = project_root(&project);
1335        write_file(
1336            &root,
1337            "gleam.toml",
1338            "name = \"application\"\nversion = \"1.0.0\"\n",
1339        );
1340        write_file(&root, "manifest.toml", "packages = 1");
1341
1342        let error =
1343            compile_typed_project(root.clone(), "main").expect_err("invalid manifest should fail");
1344
1345        assert_eq!(
1346            error.to_string(),
1347            format!(
1348                "invalid Gleam package manifest {}: TOML parse error at line 1, column 12\n  |\n1 | packages = 1\n  |            ^\ninvalid type: integer `1`, expected a sequence\n",
1349                root.join("manifest.toml"),
1350            ),
1351        );
1352    }
1353
1354    #[test]
1355    fn rejects_duplicate_manifest_packages() {
1356        let project = tempdir().expect("temporary project should be created");
1357        let root = project_root(&project);
1358        write_file(
1359            &root,
1360            "gleam.toml",
1361            "name = \"application\"\nversion = \"1.0.0\"\n",
1362        );
1363        write_file(
1364            &root,
1365            "manifest.toml",
1366            r#"
1367packages = [
1368  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "hex", outer_checksum = "00" },
1369  { name = "library", version = "1.0.1", build_tools = ["gleam"], requirements = [], source = "hex", outer_checksum = "00" },
1370]
1371
1372[requirements]
1373"#,
1374        );
1375
1376        let error = compile_typed_project(root.clone(), "main")
1377            .expect_err("duplicate manifest package should fail");
1378
1379        assert_eq!(
1380            error.to_string(),
1381            format!(
1382                "invalid Gleam package manifest {}: package library is listed more than once",
1383                root.join("manifest.toml"),
1384            ),
1385        );
1386    }
1387
1388    #[test]
1389    fn rejects_dependencies_absent_from_the_manifest() {
1390        let project = tempdir().expect("temporary project should be created");
1391        let root = project_root(&project);
1392        write_file(
1393            &root,
1394            "gleam.toml",
1395            r#"
1396name = "application"
1397version = "1.0.0"
1398
1399[dependencies]
1400library = ">= 1.0.0 and < 2.0.0"
1401"#,
1402        );
1403        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
1404
1405        let error = compile_typed_project(root, "main")
1406            .expect_err("dependency absent from manifest should fail");
1407
1408        assert_eq!(
1409            error.to_string(),
1410            "package application requires library, but it is absent from manifest.toml",
1411        );
1412    }
1413
1414    #[test]
1415    fn rejects_undownloaded_resolved_packages() {
1416        let project = tempdir().expect("temporary project should be created");
1417        let root = project_root(&project);
1418        write_file(
1419            &root,
1420            "gleam.toml",
1421            r#"
1422name = "application"
1423version = "1.0.0"
1424
1425[dependencies]
1426library = ">= 1.0.0 and < 2.0.0"
1427"#,
1428        );
1429        write_file(
1430            &root,
1431            "manifest.toml",
1432            r#"
1433packages = [
1434  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "hex", outer_checksum = "00" },
1435]
1436
1437[requirements]
1438"#,
1439        );
1440
1441        let error = compile_typed_project(root.clone(), "main")
1442            .expect_err("undownloaded package should fail");
1443
1444        assert_eq!(
1445            error.to_string(),
1446            format!(
1447                "resolved package library is not downloaded at {}; run `gleam deps download` first",
1448                root.join("build/packages/library"),
1449            ),
1450        );
1451    }
1452
1453    #[test]
1454    fn rejects_missing_dependency_configs() {
1455        let project = tempdir().expect("temporary project should be created");
1456        let root = project_root(&project);
1457        write_file(
1458            &root,
1459            "gleam.toml",
1460            r#"
1461name = "application"
1462version = "1.0.0"
1463
1464[dependencies]
1465library = { path = "packages/library" }
1466"#,
1467        );
1468        write_file(
1469            &root,
1470            "manifest.toml",
1471            r#"
1472packages = [
1473  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/library" },
1474]
1475
1476[requirements]
1477"#,
1478        );
1479
1480        let error = compile_typed_project(root.clone(), "main")
1481            .expect_err("missing dependency config should fail");
1482
1483        assert_eq!(
1484            error.to_string(),
1485            format!(
1486                "failed to read Gleam package config {}",
1487                root.join("packages/library/gleam.toml"),
1488            ),
1489        );
1490    }
1491
1492    #[test]
1493    fn rejects_dependency_package_name_mismatches() {
1494        let project = tempdir().expect("temporary project should be created");
1495        let root = project_root(&project);
1496        write_file(
1497            &root,
1498            "gleam.toml",
1499            r#"
1500name = "application"
1501version = "1.0.0"
1502
1503[dependencies]
1504library = ">= 1.0.0 and < 2.0.0"
1505"#,
1506        );
1507        write_file(
1508            &root,
1509            "manifest.toml",
1510            r#"
1511packages = [
1512  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "hex", outer_checksum = "00" },
1513]
1514
1515[requirements]
1516"#,
1517        );
1518        write_file(
1519            &root,
1520            "build/packages/library/gleam.toml",
1521            "name = \"different\"\nversion = \"1.0.0\"\n",
1522        );
1523
1524        let error = compile_typed_project(root.clone(), "main")
1525            .expect_err("dependency package name mismatch should fail");
1526
1527        assert_eq!(
1528            error.to_string(),
1529            format!(
1530                "manifest package library resolves to a package config named different at {}",
1531                root.join("build/packages/library/gleam.toml"),
1532            ),
1533        );
1534    }
1535
1536    #[test]
1537    fn rejects_missing_source_directories() {
1538        let project = tempdir().expect("temporary project should be created");
1539        let root = project_root(&project);
1540        write_file(
1541            &root,
1542            "gleam.toml",
1543            "name = \"application\"\nversion = \"1.0.0\"\n",
1544        );
1545        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
1546
1547        let error =
1548            compile_typed_project(root.clone(), "main").expect_err("missing source should fail");
1549
1550        assert_eq!(
1551            error.to_string(),
1552            format!("failed to read Gleam source {}", root.join("src")),
1553        );
1554    }
1555
1556    #[test]
1557    fn preserves_frontend_errors_for_missing_root_modules() {
1558        let project = tempdir().expect("temporary project should be created");
1559        let root = project_root(&project);
1560        write_file(
1561            &root,
1562            "gleam.toml",
1563            "name = \"application\"\nversion = \"1.0.0\"\n",
1564        );
1565        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
1566        write_file(&root, "src/other.gleam", "pub fn value() { 1 }");
1567
1568        let error =
1569            compile_typed_project(root, "main").expect_err("missing root module should fail");
1570
1571        assert_eq!(
1572            error.to_string(),
1573            "root module main was not supplied by package application",
1574        );
1575    }
1576
1577    #[test]
1578    fn preserves_selected_dependency_parse_paths() {
1579        let project = tempdir().expect("temporary project should be created");
1580        let root = project_root(&project);
1581        write_file(
1582            &root,
1583            "gleam.toml",
1584            r#"
1585name = "application"
1586version = "1.0.0"
1587
1588[dependencies]
1589library = { path = "packages/library" }
1590"#,
1591        );
1592        write_file(
1593            &root,
1594            "manifest.toml",
1595            r#"
1596packages = [
1597  { name = "library", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/library" },
1598]
1599
1600[requirements]
1601"#,
1602        );
1603        write_file(
1604            &root,
1605            "src/main.gleam",
1606            "import support\npub fn main() { 1 }",
1607        );
1608        write_file(
1609            &root,
1610            "packages/library/gleam.toml",
1611            "name = \"library\"\nversion = \"1.0.0\"\n",
1612        );
1613        write_file(
1614            &root,
1615            "packages/library/src/support.gleam",
1616            "pub fn broken(",
1617        );
1618
1619        let error = compile_typed_project(root.clone(), "main")
1620            .expect_err("selected dependency parse failure should be preserved");
1621
1622        assert_eq!(
1623            error.to_string(),
1624            format!(
1625                "failed to parse Gleam module {}",
1626                root.join("packages/library/src/support.gleam"),
1627            ),
1628        );
1629    }
1630
1631    #[test]
1632    fn preserves_selected_module_import_cycles() {
1633        let project = tempdir().expect("temporary project should be created");
1634        let root = project_root(&project);
1635        write_file(
1636            &root,
1637            "gleam.toml",
1638            "name = \"application\"\nversion = \"1.0.0\"\n",
1639        );
1640        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
1641        write_file(
1642            &root,
1643            "src/main.gleam",
1644            "import support\npub fn main() { support.value() }",
1645        );
1646        write_file(
1647            &root,
1648            "src/support.gleam",
1649            "import main\npub fn value() { main.main() }",
1650        );
1651
1652        let error =
1653            compile_typed_project(root, "main").expect_err("module import cycle should fail");
1654
1655        assert_eq!(
1656            format!("{error:?}"),
1657            "Frontend(ImportCycle { modules: [\"main\", \"support\", \"main\"] })",
1658        );
1659    }
1660
1661    #[test]
1662    fn rejects_selected_duplicate_module_owners() {
1663        let project = tempdir().expect("temporary project should be created");
1664        let root = project_root(&project);
1665        write_file(
1666            &root,
1667            "gleam.toml",
1668            r#"
1669name = "application"
1670version = "1.0.0"
1671
1672[dependencies]
1673first = { path = "packages/first" }
1674second = { path = "packages/second" }
1675"#,
1676        );
1677        write_file(
1678            &root,
1679            "manifest.toml",
1680            r#"
1681packages = [
1682  { name = "first", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/first" },
1683  { name = "second", version = "1.0.0", build_tools = ["gleam"], requirements = [], source = "local", path = "packages/second" },
1684]
1685
1686[requirements]
1687"#,
1688        );
1689        write_file(
1690            &root,
1691            "src/main.gleam",
1692            "import shared\npub fn main() { shared.value() }",
1693        );
1694        write_file(
1695            &root,
1696            "packages/first/gleam.toml",
1697            "name = \"first\"\nversion = \"1.0.0\"\n",
1698        );
1699        write_file(
1700            &root,
1701            "packages/first/src/shared.gleam",
1702            "pub fn value() { 1 }",
1703        );
1704        write_file(
1705            &root,
1706            "packages/second/gleam.toml",
1707            "name = \"second\"\nversion = \"1.0.0\"\n",
1708        );
1709        write_file(
1710            &root,
1711            "packages/second/src/shared.gleam",
1712            "pub fn value() { 2 }",
1713        );
1714
1715        let error = compile_typed_project(root.clone(), "main")
1716            .expect_err("duplicate selected module owners should fail");
1717
1718        assert_eq!(
1719            format!("{error:?}"),
1720            format!(
1721                "Frontend(DuplicateModule {{ module: \"shared\", first_package: \"first\", first_path: \"{}\", second_package: \"second\", second_path: \"{}\" }})",
1722                root.join("packages/first/src/shared.gleam"),
1723                root.join("packages/second/src/shared.gleam"),
1724            ),
1725        );
1726    }
1727
1728    #[test]
1729    fn leaves_unknown_imports_to_gleam_analysis() {
1730        let project = tempdir().expect("temporary project should be created");
1731        let root = project_root(&project);
1732        write_file(
1733            &root,
1734            "gleam.toml",
1735            "name = \"application\"\nversion = \"1.0.0\"\n",
1736        );
1737        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
1738        write_file(
1739            &root,
1740            "src/main.gleam",
1741            "import unknown\npub fn main() { 1 }",
1742        );
1743
1744        let error =
1745            compile_typed_project(root, "main").expect_err("unknown import should fail analysis");
1746
1747        assert_eq!(error.to_string(), "failed to analyse Gleam module");
1748    }
1749
1750    #[cfg(unix)]
1751    #[test]
1752    fn rejects_non_utf8_source_paths() {
1753        let error = source_paths_from(
1754            &NonUtf8SourceDirectoryEntry,
1755            Utf8Path::new("src"),
1756            Utf8Path::new(""),
1757        )
1758        .expect_err("non-UTF-8 path should fail");
1759
1760        assert_eq!(error.to_string(), "failed to read Gleam source src");
1761    }
1762
1763    #[test]
1764    fn rejects_failed_source_directory_entries() {
1765        let error = source_paths_from(
1766            &FailedSourceDirectoryEntry,
1767            Utf8Path::new("src"),
1768            Utf8Path::new(""),
1769        )
1770        .expect_err("failed directory entry should fail");
1771
1772        assert_eq!(error.to_string(), "failed to read Gleam source src");
1773    }
1774
1775    #[cfg(unix)]
1776    #[test]
1777    fn rejects_unreadable_selected_source_files() {
1778        use std::os::unix::fs::PermissionsExt;
1779
1780        let project = tempdir().expect("temporary project should be created");
1781        let root = project_root(&project);
1782        write_file(
1783            &root,
1784            "gleam.toml",
1785            "name = \"application\"\nversion = \"1.0.0\"\n",
1786        );
1787        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
1788        write_file(&root, "src/main.gleam", "pub fn main() { 1 }");
1789        let source_path = root.join("src/main.gleam");
1790        let mut permissions = fs::metadata(&source_path)
1791            .expect("source metadata should be readable")
1792            .permissions();
1793        permissions.set_mode(0o000);
1794        fs::set_permissions(&source_path, permissions)
1795            .expect("source permissions should be changed");
1796
1797        let error = compile_typed_project(root, "main")
1798            .expect_err("unreadable selected source should fail");
1799
1800        let mut permissions = fs::metadata(&source_path)
1801            .expect("source metadata should remain readable")
1802            .permissions();
1803        permissions.set_mode(0o600);
1804        fs::set_permissions(&source_path, permissions)
1805            .expect("source permissions should be restored");
1806        assert_eq!(
1807            error.to_string(),
1808            format!("failed to read Gleam source {source_path}"),
1809        );
1810    }
1811
1812    #[cfg(unix)]
1813    #[test]
1814    fn rejects_unreadable_nested_source_directories() {
1815        use std::os::unix::fs::PermissionsExt;
1816
1817        let project = tempdir().expect("temporary project should be created");
1818        let root = project_root(&project);
1819        write_file(
1820            &root,
1821            "gleam.toml",
1822            "name = \"application\"\nversion = \"1.0.0\"\n",
1823        );
1824        write_file(&root, "manifest.toml", "packages = []\n\n[requirements]\n");
1825        write_file(&root, "src/main.gleam", "pub fn main() { 1 }");
1826        write_file(&root, "src/private/hidden.gleam", "pub fn value() { 1 }");
1827        let private_path = root.join("src/private");
1828        let mut permissions = fs::metadata(&private_path)
1829            .expect("source directory metadata should be readable")
1830            .permissions();
1831        permissions.set_mode(0o000);
1832        fs::set_permissions(&private_path, permissions)
1833            .expect("source directory permissions should be changed");
1834
1835        let error = compile_typed_project(root, "main")
1836            .expect_err("unreadable nested source directory should fail");
1837
1838        let mut permissions = fs::metadata(&private_path)
1839            .expect("source directory metadata should remain readable")
1840            .permissions();
1841        permissions.set_mode(0o700);
1842        fs::set_permissions(&private_path, permissions)
1843            .expect("source directory permissions should be restored");
1844        assert_eq!(
1845            error.to_string(),
1846            format!("failed to read Gleam source {private_path}"),
1847        );
1848    }
1849
1850    fn project_root(project: &TempDir) -> Utf8PathBuf {
1851        Utf8PathBuf::from_path_buf(project.path().to_path_buf())
1852            .expect("temporary project path should be UTF-8")
1853    }
1854
1855    fn write_file(root: &Utf8Path, relative: &str, source: &str) {
1856        let path = root.join(relative);
1857        fs::create_dir_all(path.parent().expect("fixture file should have a parent"))
1858            .expect("fixture directory should be created");
1859        fs::write(path, source).expect("fixture file should be written");
1860    }
1861}