From 6d47909a34280cda2a739517a11007263c5e8729 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Thu, 11 Jun 2026 18:00:30 -0500 Subject: [PATCH] feat: dependency metadata loading --- crates/core/src/ir.rs | 165 ++++++++++- crates/core/src/lib.rs | 4 +- crates/core/src/loader.rs | 1 + crates/core/src/loader/dependency.rs | 159 +++++++++++ crates/core/src/project.rs | 140 +++++++--- crates/core/src/resolve.rs | 94 ++++++- crates/core/src/types.rs | 264 +++++++++++++++++- ...14_project_compilation_and_dependencies.md | 8 +- 8 files changed, 785 insertions(+), 50 deletions(-) create mode 100644 crates/core/src/loader.rs create mode 100644 crates/core/src/loader/dependency.rs diff --git a/crates/core/src/ir.rs b/crates/core/src/ir.rs index 6139acc..0eb0d61 100644 --- a/crates/core/src/ir.rs +++ b/crates/core/src/ir.rs @@ -2,7 +2,7 @@ pub mod bit_slices; mod closure; mod lowerer; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use crate::{ ast::{self, Declaration as AstDeclaration, LiteralKind}, @@ -10,7 +10,7 @@ use crate::{ naming::{ BackendItem, BackendItemKind, BackendName, CompilerGeneratedIndex, HelperKind, ModuleName, render_backend_name, }, - resolve::SymbolKind, + resolve::{Namespace, ReferenceTarget, SymbolKind}, source::Span, stdlib, types::{Type, TypedModule, TypedProject}, @@ -927,7 +927,11 @@ pub fn lower(module: TypedModule) -> Result { pub fn lower_project(project: TypedProject) -> Result { let mut modules = Vec::new(); - let mut diagnostics = Vec::new(); + let mut diagnostics = unsupported_dependency_member_diagnostics(&project); + + if !diagnostics.is_empty() { + return Err(diagnostics); + } for module in project.modules { match lowerer::lower_with_project_interfaces(module, &project.interfaces) { @@ -943,6 +947,90 @@ pub fn lower_project(project: TypedProject) -> Result { link_modules(modules) } +fn unsupported_dependency_member_diagnostics(project: &TypedProject) -> Diagnostics { + let project_modules = project + .modules + .iter() + .filter_map(|module| module.module_name.as_deref()) + .collect::>(); + let stdlib_modules = stdlib::StdlibRegistry::new() + .modules() + .map(|module| module.name) + .collect::>(); + let dependency_modules = project + .interfaces + .keys() + .map(String::as_str) + .filter(|module| !project_modules.contains(module) && !stdlib_modules.contains(module)) + .collect::>(); + + if dependency_modules.is_empty() { + return Vec::new(); + } + + let mut diagnostics = Vec::new(); + let mut reported = HashSet::new(); + for module in &project.modules { + for reference in &module.resolved.references { + match &reference.target { + ReferenceTarget::Symbol(symbol_id) => { + let symbol = module.resolved.symbols.symbol(*symbol_id); + if !matches!(symbol.namespace, Namespace::Value | Namespace::Constructor) { + continue; + } + let SymbolKind::Imported { module: dependency_module, member } = &symbol.kind else { + continue; + }; + if dependency_modules.contains(dependency_module.as_str()) + && reported.insert((reference.name.span, dependency_module.clone(), member.clone())) + { + diagnostics.push(unsupported_dependency_member_diagnostic( + dependency_module, + member, + reference.name.span, + )); + } + } + ReferenceTarget::QualifiedMember { module: module_symbol, member, .. } => { + let symbol = module.resolved.symbols.symbol(*module_symbol); + let SymbolKind::Import { module: dependency_module } = &symbol.kind else { + continue; + }; + if !dependency_modules.contains(dependency_module.as_str()) { + continue; + } + let Some(interface) = project.interfaces.get(dependency_module) else { + continue; + }; + if !(interface.functions.contains_key(&member.text) + || interface.constructors.contains_key(&member.text)) + { + continue; + } + if reported.insert((member.span, dependency_module.clone(), member.text.clone())) { + diagnostics.push(unsupported_dependency_member_diagnostic( + dependency_module, + &member.text, + member.span, + )); + } + } + } + } + } + diagnostics +} + +fn unsupported_dependency_member_diagnostic(module: &str, member: &str, span: Span) -> Diagnostic { + Diagnostic::new( + DiagnosticCode::LoweringError, + format!( + "dependency member `{module}.{member}` cannot be lowered yet; dependency source compilation is not supported" + ), + ) + .with_label(Label::primary(span, "unsupported dependency member used here")) +} + fn link_modules(modules: Vec) -> Result { let Some(first) = modules.first() else { return Err(vec![Diagnostic::new( @@ -1561,10 +1649,25 @@ fn visibility(public: bool) -> Visibility { #[cfg(test)] mod tests { - use crate::source::{SourceFileId, Span}; + use std::{fs, path::Path}; + + use tempfile::tempdir; + + use crate::{ + project, + source::{SourceFileId, Span}, + types, + }; use super::*; + fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).expect("create parent directory"); + } + fs::write(path, text).expect("write fixture"); + } + #[test] fn reports_generated_name_collisions_with_source_declarations() { let first_span = Span::new(SourceFileId(1), 10, 20); @@ -1597,6 +1700,60 @@ ProjectError: duplicate generated backend name `generated/run` "#); } + #[test] + fn reports_unsupported_dependency_members_before_lowering() { + let dir = tempdir().expect("tempdir"); + write( + &dir.path().join("gleam.toml"), + r#"name = "app" +version = "1.0.0" + +[dependencies] +dep_pkg = { path = "dep_pkg" } +"#, + ); + write( + &dir.path().join("src/app.gleam"), + r#"import dep/foo.{Make, Thing, answer} + +pub fn main() -> Int { + answer() +} + +pub fn value() -> Thing { + Make(1) +} +"#, + ); + write( + &dir.path().join("dep_pkg/gleam.toml"), + "name = \"dep_pkg\"\nversion = \"0.1.0\"\n", + ); + write( + &dir.path().join("dep_pkg/src/dep/foo.gleam"), + r#"pub type Thing { + Make(count: Int) +} + +pub fn answer() -> Int { + 1 +} +"#, + ); + + let project = project::load_project(dir.path()).expect("load project"); + let typed = types::check_project(&project).expect("type check project"); + let diagnostics = lower_project(typed).expect_err("dependency member should be unsupported"); + + assert_eq!(diagnostics.len(), 2); + let messages = diagnostics + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect::>(); + assert!(messages.iter().any(|message| message.contains("`dep/foo.answer`"))); + assert!(messages.iter().any(|message| message.contains("`dep/foo.Make`"))); + } + #[test] fn linked_debug_dump_shows_source_generated_names_and_import_boundaries() { let span = Span::new(SourceFileId(1), 0, 3); diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 553e292..7674c5d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -5,6 +5,7 @@ pub mod ast; pub mod diagnostic; pub mod inference; pub mod ir; +pub mod loader; pub mod naming; pub mod parse; pub mod project; @@ -71,9 +72,8 @@ pub fn compile_source_with_options(source: SourceFile, options: CompileOptions) let resolved = resolve::resolve(ast)?; let typed = types::check(resolved)?; let ir = ir::lower(typed)?; - let wasm = ir.emit_wasm_with_options(options.target.into())?; - Ok(CompileOutput { wasm }) + Ok(CompileOutput { wasm: ir.emit_wasm_with_options(options.target.into())? }) } #[cfg(test)] diff --git a/crates/core/src/loader.rs b/crates/core/src/loader.rs new file mode 100644 index 0000000..c81cea6 --- /dev/null +++ b/crates/core/src/loader.rs @@ -0,0 +1 @@ +pub mod dependency; diff --git a/crates/core/src/loader/dependency.rs b/crates/core/src/loader/dependency.rs new file mode 100644 index 0000000..9176a85 --- /dev/null +++ b/crates/core/src/loader/dependency.rs @@ -0,0 +1,159 @@ +use std::path::{Path, PathBuf}; +use std::{collections::HashMap, fs}; + +use serde::Deserialize; + +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics}; +use crate::project::{Dependency, DependencySource, DependencyToml}; +use crate::source::{SourceFile, SourceFileId}; +use crate::types::ModuleInterface; +use crate::{ast, parse, target, types}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DependencyPackage { + pub name: String, + pub version: Option, + pub root: PathBuf, + pub source: DependencySource, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DependencyInterfaces { + pub packages: Vec, + pub modules: HashMap, +} + +#[derive(Debug, Deserialize)] +struct PackagesToml { + #[serde(default)] + packages: HashMap, +} + +pub fn load_dependency_interfaces( + root: &Path, dependencies: &[(String, DependencyToml, bool)], compile_target: target::CompileTarget, +) -> Result { + let package_versions = match fs::read_to_string(root.join("build").join("packages").join("packages.toml")) { + Ok(text) => toml::from_str::(&text) + .map(|packages| packages.packages) + .unwrap_or_default(), + Err(_) => HashMap::new(), + }; + let mut output = DependencyInterfaces::default(); + let mut diagnostics = Vec::new(); + + for (name, dep, _dev) in dependencies { + match dependency_root(root, name, dep) { + Some(pkg_root) => { + let version = dep.get_dep_ver(name, &package_versions, &pkg_root); + let source = DependencySource::from_toml(dep); + let pkg = DependencyPackage { name: name.clone(), version, root: pkg_root.clone(), source }; + output.packages.push(pkg); + match load_pkg_interfaces(&pkg_root, compile_target) { + Ok(interfaces) => output.modules.extend(interfaces), + Err(mut errs) => diagnostics.append(&mut errs), + } + } + None => continue, + } + } + + if diagnostics.is_empty() { Ok(output) } else { Err(diagnostics) } +} + +pub fn dependency_nodes(packages: &[DependencyPackage], configured: Vec) -> Vec { + configured + .into_iter() + .map(|mut dep| { + if let Some(package) = packages.iter().find(|package| package.name == dep.name) { + dep.version = package.version.clone(); + dep.root = Some(package.root.clone()); + dep.source = package.source; + } + dep + }) + .collect() +} + +fn dependency_root(root: &Path, name: &str, dependency: &DependencyToml) -> Option { + match dependency.path() { + Some(path) => { + let path = PathBuf::from(path); + return Some(if path.is_absolute() { path } else { root.join(path) }); + } + None => { + let hex_root = root.join("build").join("packages").join(name); + hex_root.is_dir().then_some(hex_root) + } + } +} + +fn load_pkg_interfaces( + pkg_root: &Path, compile_target: target::CompileTarget, +) -> Result, Diagnostics> { + let mut paths = Vec::new(); + collect_gleam_files(&pkg_root.join("src"), &mut paths)?; + paths.sort(); + + let mut interfaces = HashMap::new(); + let mut diagnostics = Vec::new(); + for (index, path) in paths.into_iter().enumerate() { + let source_id = SourceFileId(1_000_000 + index as u32); + let module_name = path + .strip_prefix(pkg_root.join("src")) + .unwrap_or(&path) + .with_extension("") + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); + match parse_module(&path, source_id, compile_target) { + Ok(module) => { + interfaces.insert(module_name, types::interface_from_ast(&module)); + } + Err(mut errors) => diagnostics.append(&mut errors), + } + } + + if diagnostics.is_empty() { Ok(interfaces) } else { Err(diagnostics) } +} + +fn parse_module( + path: &Path, source_id: SourceFileId, compile_target: target::CompileTarget, +) -> Result { + let text = fs::read_to_string(path).map_err(|error| { + vec![Diagnostic::new( + DiagnosticCode::ProjectError, + format!("could not read dependency source {}: {error}", path.display()), + )] + })?; + parse::parse(SourceFile::with_path(source_id, path, text)) + .and_then(|cst| ast::build(&cst)) + .and_then(|module| target::select_module(module, compile_target)) +} + +fn collect_gleam_files(dir: &Path, paths: &mut Vec) -> Result<(), Diagnostics> { + if !dir.exists() { + return Ok(()); + } + let read_dir = fs::read_dir(dir).map_err(|error| { + vec![Diagnostic::new( + DiagnosticCode::ProjectError, + format!("could not read dependency directory {}: {error}", dir.display()), + )] + })?; + for entry in read_dir { + let entry = entry.map_err(|error| { + vec![Diagnostic::new( + DiagnosticCode::ProjectError, + format!("could not read dependency directory entry: {error}"), + )] + })?; + let path = entry.path(); + if path.is_dir() { + collect_gleam_files(&path, paths)?; + } else if path.extension().is_some_and(|extension| extension == "gleam") { + paths.push(path); + } + } + Ok(()) +} diff --git a/crates/core/src/project.rs b/crates/core/src/project.rs index 4fcbab6..c68b553 100644 --- a/crates/core/src/project.rs +++ b/crates/core/src/project.rs @@ -1,15 +1,12 @@ -use std::{ - collections::{BTreeMap, HashMap}, - fs, - path::{Path, PathBuf}, -}; +use std::collections::{BTreeMap, HashMap}; +use std::fs; +use std::path::{Path, PathBuf}; use serde::Deserialize; -use crate::{ - diagnostic::{Diagnostic, DiagnosticCode, Diagnostics}, - source::{SourceFile, SourceFileId}, -}; +use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics}; +use crate::source::{SourceFile, SourceFileId}; +use crate::{loader::dependency, target, types}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Project { @@ -19,10 +16,26 @@ pub struct Project { pub sources: Vec, } +impl Project { + pub fn module(&self, name: &str) -> Result<&ModuleInfo, Diagnostics> { + self.graph + .modules + .iter() + .find(|module| module.name == name) + .ok_or_else(|| { + vec![Diagnostic::new( + DiagnosticCode::ProjectError, + format!("missing module `{name}`"), + )] + }) + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct PackageGraph { pub root_package: PackageNode, pub dependencies: Vec, + pub dependency_interfaces: HashMap, pub modules: Vec, } @@ -38,6 +51,26 @@ pub struct Dependency { pub name: String, pub requirement: String, pub dev: bool, + pub source: DependencySource, + pub root: Option, + pub version: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DependencySource { + Hex, + Path, + Git, +} + +impl DependencySource { + pub fn from_toml(dependency: &DependencyToml) -> Self { + match dependency { + DependencyToml::Options { path: Some(_), .. } => Self::Path, + DependencyToml::Options { git: Some(_), .. } => Self::Git, + _ => Self::Hex, + } + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -111,13 +144,42 @@ impl DependencyToml { .unwrap_or_else(|| "*".into()), } } + + pub fn version(&self) -> Option { + match self { + DependencyToml::Version(version) => Some(version.clone()), + DependencyToml::Options { version, .. } => version.clone(), + } + } + + pub fn path(&self) -> Option { + match self { + DependencyToml::Options { path, .. } => path.clone(), + DependencyToml::Version(_) => None, + } + } + + pub fn get_dep_ver(&self, name: &str, pkg_versions: &HashMap, pkg_root: &Path) -> Option { + self.version() + .or_else(|| pkg_versions.get(name).cloned()) + .or_else(|| Self::package_gleam_toml_version(pkg_root)) + } + + fn package_gleam_toml_version(root: &Path) -> Option { + let text = fs::read_to_string(root.join("gleam.toml")).ok()?; + toml::from_str::(&text).ok().map(|config| config.version) + } } pub fn load_project(path: impl AsRef) -> Result { let root = project_root(path.as_ref()); let config = read_config(&root)?; let (sources, modules) = discover_modules(&root)?; - let dependencies = dependencies(&config); + let configured_dependencies = configured_dependencies(&config); + let compile_target = target::project_compile_target(config.target.as_ref()); + let dependency_interfaces = + dependency::load_dependency_interfaces(&root, &configured_dependencies, compile_target)?; + let dependencies = dependency::dependency_nodes(&dependency_interfaces.packages, dependencies(&config)); Ok(Project { graph: PackageGraph { @@ -127,6 +189,7 @@ pub fn load_project(path: impl AsRef) -> Result { root: root.clone(), }, dependencies, + dependency_interfaces: dependency_interfaces.modules, modules, }, root, @@ -146,21 +209,6 @@ pub fn source_file(path: impl AsRef) -> Result { Ok(SourceFile::with_path(SourceFileId(0), path, text)) } -impl Project { - pub fn module(&self, name: &str) -> Result<&ModuleInfo, Diagnostics> { - self.graph - .modules - .iter() - .find(|module| module.name == name) - .ok_or_else(|| { - vec![Diagnostic::new( - DiagnosticCode::ProjectError, - format!("missing module `{name}`"), - )] - }) - } -} - fn project_root(path: &Path) -> PathBuf { if path.is_file() { path.parent().unwrap_or(path).to_path_buf() } else { path.to_path_buf() } } @@ -181,20 +229,40 @@ fn read_config(root: &Path) -> Result { }) } -fn dependencies(config: &GleamToml) -> Vec { - let normal = config.dependencies.iter().map(|(name, dependency)| Dependency { - name: name.clone(), - requirement: dependency.requirement(), - dev: false, - }); - let dev = config.dev_dependencies.iter().map(|(name, dependency)| Dependency { - name: name.clone(), - requirement: dependency.requirement(), - dev: true, - }); +fn configured_dependencies(config: &GleamToml) -> Vec<(String, DependencyToml, bool)> { + let normal = config + .dependencies + .iter() + .map(|(name, dependency)| (name.clone(), dependency.clone(), false)); + let dev = config + .dev_dependencies + .iter() + .map(|(name, dependency)| (name.clone(), dependency.clone(), true)); normal.chain(dev).collect() } +fn dependencies(config: &GleamToml) -> Vec { + let normal = config + .dependencies + .iter() + .map(|(name, dependency)| (name.clone(), dependency.clone(), false)); + let dev = config + .dev_dependencies + .iter() + .map(|(name, dependency)| (name.clone(), dependency.clone(), true)); + normal + .chain(dev) + .map(|(name, dependency, dev)| Dependency { + name, + requirement: dependency.requirement(), + dev, + source: DependencySource::from_toml(&dependency), + root: None, + version: dependency.version(), + }) + .collect() +} + fn discover_modules(root: &Path) -> Result<(Vec, Vec), Diagnostics> { let mut entries = Vec::new(); collect_gleam_files(root, SourceRoot::Src, &mut entries)?; diff --git a/crates/core/src/resolve.rs b/crates/core/src/resolve.rs index 85d3ec3..a05c3b2 100644 --- a/crates/core/src/resolve.rs +++ b/crates/core/src/resolve.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::ast::{self, Declaration, Expression, Pattern, Statement, UnqualifiedImportKind}; use crate::diagnostic::{Diagnostic, DiagnosticCode, Diagnostics, Label}; -use crate::{parse, project::Project, source::Span, stdlib::StdlibRegistry, target}; +use crate::{parse, project::Project, source::Span, stdlib::StdlibRegistry, target, types}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SymbolId(pub u32); @@ -128,6 +128,7 @@ pub fn resolve_project(project: &Project) -> Result HashMap { .collect() } +fn dependency_resolve_interfaces( + interfaces: &HashMap, +) -> HashMap { + interfaces + .iter() + .map(|(module, interface)| { + let mut members = HashMap::new(); + for (name, type_) in &interface.functions { + members.insert( + (Namespace::Value, name.clone()), + ModuleMember { public: true, span: type_span(type_) }, + ); + } + for (name, declaration) in &interface.types { + members.insert( + (Namespace::Type, name.clone()), + ModuleMember { public: true, span: declaration.span }, + ); + } + for (name, constructor) in &interface.constructors { + members.insert( + (Namespace::Constructor, name.clone()), + ModuleMember { public: true, span: constructor.span }, + ); + for field in &constructor.fields { + members.insert( + (Namespace::Field, field.name.clone()), + ModuleMember { public: true, span: constructor.span }, + ); + } + } + (module.clone(), ModuleInterface { members }) + }) + .collect() +} + +fn type_span(_type_: &types::Type) -> Span { + module_span() +} + fn module_span() -> Span { Span { file_id: crate::source::SourceFileId(u32::MAX), start: 0, end: 0 } } @@ -1259,6 +1300,57 @@ fn main(person) { case person { Missing(age: value) -> value } } ); } + #[test] + fn dependency_interface_lookup_respects_public_visibility() { + let dir = tempdir().expect("tempdir"); + write( + &dir.path().join("gleam.toml"), + r#"name = "app" +version = "1.0.0" + +[dependencies] +dep_pkg = { path = "dep_pkg" } +"#, + ); + write( + &dir.path().join("src/app.gleam"), + r#"import dep/foo.{hidden, visible} + +pub fn main() { + #(visible(), hidden()) +} +"#, + ); + let dep = dir.path().join("dep_pkg"); + write(&dep.join("gleam.toml"), "name = \"dep_pkg\"\nversion = \"0.1.0\"\n"); + write( + &dep.join("src/dep/foo.gleam"), + r#"pub fn visible() -> Int { + 1 +} + +fn hidden() -> Int { + 2 +} +"#, + ); + + let project = project::load_project(dir.path()).expect("load project"); + let diagnostics = resolve_project(&project).expect_err("private dependency member should be hidden"); + + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("module `dep/foo` has no member `hidden`")), + "expected hidden member lookup diagnostic, got {diagnostics:#?}" + ); + assert!( + !diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("visible")) + ); + } + #[test] fn filters_project_target_groups_before_interface_resolution() { let dir = tempdir().expect("tempdir"); diff --git a/crates/core/src/types.rs b/crates/core/src/types.rs index 76a2422..0eba605 100644 --- a/crates/core/src/types.rs +++ b/crates/core/src/types.rs @@ -149,6 +149,119 @@ pub struct TypedProject { pub interfaces: HashMap, } +pub fn interface_from_ast(module: &ast::Module) -> ModuleInterface { + let mut interface = ModuleInterface::default(); + collect_interface_declarations(&module.declarations, &mut interface); + interface +} + +fn collect_interface_declarations(declarations: &[Declaration], interface: &mut ModuleInterface) { + for declaration in declarations { + match declaration { + Declaration::Function(function) if function.public => { + if let Some(type_) = function_type_from_annotations(function) { + interface.functions.insert(function.name.text.clone(), type_); + interface.function_labels.insert( + function.name.text.clone(), + function + .parameters + .iter() + .map(|parameter| parameter.label.as_ref().map(|label| label.text.clone())) + .collect(), + ); + } + } + Declaration::Constant(constant) if constant.public => { + if let Some(type_) = constant + .type_annotation + .as_ref() + .and_then(|annotation| Type::from_source(&annotation.source)) + { + interface.functions.insert(constant.name.text.clone(), type_); + } + } + Declaration::ExternalFunction(function) if function.public => { + if let Some(type_) = external_function_type_from_annotations(function) { + interface.functions.insert(function.name.text.clone(), type_); + interface.function_labels.insert( + function.name.text.clone(), + function + .parameters + .iter() + .map(|parameter| parameter.label.as_ref().map(|label| label.text.clone())) + .collect(), + ); + } + } + Declaration::ExternalType(type_) if type_.public => { + interface.types.insert( + type_.name.text.clone(), + TypeDeclaration { + name: type_.name.text.clone(), + parameters: Vec::new(), + opaque: type_.opaque, + constructors: Vec::new(), + span: type_.span, + }, + ); + } + Declaration::TypeDefinition(type_) if type_.public => { + if let Some(type_declaration) = type_definition_from_ast(type_) { + let exported_constructors = !type_declaration.opaque; + if exported_constructors { + for constructor in &type_declaration.constructors { + interface + .constructors + .insert(constructor.name.clone(), constructor.clone()); + } + } + interface.types.insert(type_declaration.name.clone(), type_declaration); + } + } + Declaration::TypeAlias(alias) if alias.public => { + if let Some(alias) = type_alias_from_ast(alias) { + interface.types.insert(alias.name.clone(), alias); + } + } + Declaration::TargetGroup(group) => collect_interface_declarations(&group.declarations, interface), + _ => {} + } + } +} + +fn function_type_from_annotations(function: &ast::Function) -> Option { + let params = function + .parameters + .iter() + .map(|parameter| { + parameter + .type_annotation + .as_ref() + .and_then(|annotation| Type::from_source(&annotation.source)) + }) + .collect::>>()?; + let return_type = function + .return_type + .as_ref() + .and_then(|annotation| Type::from_source(&annotation.source))?; + Some(Type::Function { params, return_type: Box::new(return_type) }) +} + +fn external_function_type_from_annotations(function: &ast::ExternalFunction) -> Option { + let params = function + .parameters + .iter() + .map(|parameter| { + parameter + .type_annotation + .as_ref() + .and_then(|annotation| Type::from_source(&annotation.source)) + }) + .collect::>>()?; + let return_type = Type::from_source(&function.return_type.source)?; + Some(Type::Function { params, return_type: Box::new(return_type) }) +} + pub fn check(module: ResolvedModule) -> Result { TypeChecker::new(module).check() } @@ -174,15 +287,18 @@ pub fn check_project(project: &Project) -> Result { .modules() .map(|module| (module.name.to_string(), module.interface.clone())) .collect::>(); - interfaces.extend(stdlib_interfaces.clone()); + let dependency_interfaces = project.graph.dependency_interfaces.clone(); + let mut external_interfaces = stdlib_interfaces.clone(); + external_interfaces.extend(dependency_interfaces.clone()); + interfaces.extend(external_interfaces.clone()); let external_constructors = resolved .modules .iter() .flat_map(|module| constructors_from_ast(&module.ast)) - .chain(stdlib_interfaces.values().flat_map(interface_constructors)) + .chain(external_interfaces.values().flat_map(interface_constructors)) .collect::>(); - let stdlib_values = stdlib_interfaces + let stdlib_values = external_interfaces .iter() .flat_map(|(module, interface)| qualified_values_from_interface(module, interface)) .collect::>(); @@ -196,6 +312,11 @@ pub fn check_project(project: &Project) -> Result { .modules .iter() .flat_map(|module| function_label_map(&module.ast)) + .chain( + external_interfaces + .iter() + .flat_map(|(module, interface)| qualified_function_labels_from_interface(module, interface)), + ) .collect::>(); let module_order = project_module_order(project, &resolved); @@ -2361,6 +2482,24 @@ fn qualified_values_from_interface(module: &str, interface: &ModuleInterface) -> [ (format!("{module}.{name}"), type_.clone()), (format!("{short}.{name}"), type_.clone()), + (name.clone(), type_.clone()), + ] + }) + .collect() +} + +fn qualified_function_labels_from_interface( + module: &str, interface: &ModuleInterface, +) -> Vec<(String, Vec>)> { + let short = module.rsplit('/').next().unwrap_or(module); + interface + .function_labels + .iter() + .flat_map(|(name, labels)| { + [ + (format!("{module}.{name}"), labels.clone()), + (format!("{short}.{name}"), labels.clone()), + (name.clone(), labels.clone()), ] }) .collect() @@ -2873,6 +3012,125 @@ fn generic(result: Outcome(Int)) { case result { Ok(value) -> value Error(_) -> ); } + #[test] + fn checks_dependency_interfaces_for_values_types_constructors_and_labels() { + let dir = tempdir().expect("tempdir"); + write( + &dir.path().join("gleam.toml"), + r#"name = "app" +version = "1.0.0" + +[dependencies] +dep_pkg = { path = "dep_pkg" } +"#, + ); + write( + &dir.path().join("src/app.gleam"), + r#"import dep/foo.{Make, Thing, labelled} + +pub fn main() -> Int { + labelled(value: Make(1)) +} +"#, + ); + let dep = dir.path().join("dep_pkg"); + write(&dep.join("gleam.toml"), "name = \"dep_pkg\"\nversion = \"0.1.0\"\n"); + write( + &dep.join("src/dep/foo.gleam"), + r#"pub type Thing { + Make(count: Int) +} + +pub fn labelled(value thing: Thing) -> Int { + 1 +} +"#, + ); + + let project = project::load_project(dir.path()).expect("load project"); + let typed = check_project(&project).expect("type check project with dependency interface"); + + assert!(project.graph.dependency_interfaces.contains_key("dep/foo")); + let dependency_interface = typed.interfaces.get("dep/foo").expect("dependency interface"); + assert_eq!( + dependency_interface.function_labels.get("labelled"), + Some(&vec![Some("value".into())]), + ); + assert_eq!( + dependency_interface + .constructors + .get("Make") + .and_then(|constructor| constructor.fields.first()) + .map(|field| field.name.as_str()), + Some("count"), + ); + } + + #[test] + fn checks_dependency_generic_schemes_and_constructor_patterns() { + let dir = tempdir().expect("tempdir"); + write( + &dir.path().join("gleam.toml"), + r#"name = "app" +version = "1.0.0" + +[dependencies] +dep_pkg = { path = "dep_pkg" } +"#, + ); + write( + &dir.path().join("src/app.gleam"), + r#"import dep/box.{Box, Boxed} +import dep/generic + +pub fn pair() -> #(Int, String) { + #(generic.identity(1), generic.identity("one")) +} + +pub fn unbox(box: Boxed(Int)) -> Int { + case box { + Box(value) -> value + } +} +"#, + ); + let dep = dir.path().join("dep_pkg"); + write(&dep.join("gleam.toml"), "name = \"dep_pkg\"\nversion = \"0.1.0\"\n"); + write( + &dep.join("src/dep/generic.gleam"), + r#"pub fn identity(value: a) -> a { + value +} +"#, + ); + write( + &dep.join("src/dep/box.gleam"), + r#"pub type Boxed(a) { + Box(value: a) +} +"#, + ); + + let project = project::load_project(dir.path()).expect("load project"); + let typed = check_project(&project).expect("type check project with dependency generics"); + + let app = typed + .modules + .iter() + .find(|module| module.module_name.as_deref() == Some("app")) + .expect("app module"); + let pair = app + .functions + .iter() + .find(|function| function.name.text == "pair") + .expect("pair type"); + assert_eq!( + pair.type_, + Type::Function { params: vec![], return_type: Box::new(Type::Tuple(vec![Type::Int, Type::String])) } + ); + assert!(typed.interfaces["dep/box"].constructors.contains_key("Box")); + } + #[test] fn checks_imported_constructor_patterns_in_projects() { let dir = tempdir().expect("tempdir"); diff --git a/docs/internal/tasks/14_project_compilation_and_dependencies.md b/docs/internal/tasks/14_project_compilation_and_dependencies.md index 816db78..c76246e 100644 --- a/docs/internal/tasks/14_project_compilation_and_dependencies.md +++ b/docs/internal/tasks/14_project_compilation_and_dependencies.md @@ -77,11 +77,11 @@ The goal here is to make linked project names deterministic and collision-free. ### Dependency interfaces -- [ ] Load enough dependency metadata for project compile inputs. -- [ ] Load dependency module interfaces for values, types, constructors, and +- [x] Load enough dependency metadata for project compile inputs. +- [x] Load dependency module interfaces for values, types, constructors, and labels used by project compilation. -- [ ] Report unsupported dependency members before lowering. -- [ ] Add tests for dependency interface lookup, visibility, generic schemes, +- [x] Report unsupported dependency members before lowering. +- [x] Add tests for dependency interface lookup, visibility, generic schemes, constructors, and unsupported members. ### Dependency source loading -- 2.51.2