diff --git a/mlf-cli/src/config.rs b/mlf-cli/src/config.rs index 8dd7047..8b3f8e5 100644 --- a/mlf-cli/src/config.rs +++ b/mlf-cli/src/config.rs @@ -29,7 +29,7 @@ pub struct MlfConfig { #[derive(Debug, Serialize, Deserialize)] pub struct SourceConfig { - #[serde(default = "default_source_directory")] + #[serde(default = "default_source_directory", skip_serializing_if = "is_default_source_directory")] pub directory: String, } @@ -45,6 +45,10 @@ fn default_source_directory() -> String { "./lexicons".to_string() } +fn is_default_source_directory(s: &str) -> bool { + s == default_source_directory() +} + #[derive(Debug, Serialize, Deserialize)] pub struct OutputConfig { pub r#type: String, @@ -56,10 +60,10 @@ pub struct DependenciesConfig { #[serde(default)] pub dependencies: Vec, - #[serde(default = "default_allow_transitive_deps")] + #[serde(default = "default_allow_transitive_deps", skip_serializing_if = "is_default_allow_transitive_deps")] pub allow_transitive_deps: bool, - #[serde(default = "default_optimize_transitive_fetches")] + #[serde(default = "default_optimize_transitive_fetches", skip_serializing_if = "is_default_optimize_transitive_fetches")] pub optimize_transitive_fetches: bool, } @@ -71,6 +75,14 @@ fn default_optimize_transitive_fetches() -> bool { false } +fn is_default_allow_transitive_deps(b: &bool) -> bool { + *b == default_allow_transitive_deps() +} + +fn is_default_optimize_transitive_fetches(b: &bool) -> bool { + *b == default_optimize_transitive_fetches() +} + impl Default for DependenciesConfig { fn default() -> Self { Self { diff --git a/mlf-cli/src/fetch.rs b/mlf-cli/src/fetch.rs index 2ee0a36..ce0ffb7 100644 --- a/mlf-cli/src/fetch.rs +++ b/mlf-cli/src/fetch.rs @@ -68,8 +68,29 @@ pub fn run_fetch(nsid: Option, save: bool, update: bool, locked: bool) - match nsid { Some(namespace) => { - // Fetch single namespace - fetch_lexicon(&namespace, &project_root)?; + // Fetch single namespace with transitive dependencies + let lockfile_path = project_root.join("mlf-lock.toml"); + let mut lockfile = LockFile::load(&lockfile_path).unwrap_or_else(|_| LockFile::new()); + + // Load config to check if transitive deps are enabled + let config_path = project_root.join("mlf.toml"); + let config = MlfConfig::load(&config_path).map_err(FetchError::NoProjectRoot)?; + + fetch_lexicon_with_lock(&namespace, &project_root, &mut lockfile)?; + + // Handle transitive dependencies if enabled + if config.dependencies.allow_transitive_deps { + println!("\n→ Checking for transitive dependencies..."); + fetch_transitive_dependencies( + &project_root, + &mut lockfile, + config.dependencies.optimize_transitive_fetches + )?; + } + + // Save lockfile + lockfile.save(&lockfile_path).map_err(FetchError::NoProjectRoot)?; + println!("\n→ Updated mlf-lock.toml"); // Save to mlf.toml if --save flag is provided if save { @@ -182,145 +203,153 @@ fn fetch_all_dependencies(project_root: &std::path::Path, update: bool, locked: } } - // If transitive dependencies are enabled, iteratively fetch missing deps + // If transitive dependencies are enabled, fetch them if allow_transitive { - let mut iteration = 0; - let max_iterations = 10; // Prevent infinite loops + fetch_transitive_dependencies(&project_root, &mut lockfile, config.dependencies.optimize_transitive_fetches)?; + } - loop { - iteration += 1; - if iteration > max_iterations { - eprintln!("\nWarning: Reached maximum iteration limit for transitive dependencies"); - break; - } + // Save the lockfile + lockfile.save(&lockfile_path).map_err(FetchError::NoProjectRoot)?; + println!("\n→ Updated mlf-lock.toml"); - // Collect unresolved references - let unresolved = match collect_unresolved_references(project_root) { - Ok(refs) => refs, - Err(e) => { - eprintln!("\nWarning: Failed to analyze dependencies: {}", e); - break; - } - }; + if !errors.is_empty() { + eprintln!( + "\n{} dependency(ies) fetched successfully, {} error(s):", + success_count, + errors.len() + ); + for (dep, error) in &errors { + eprintln!(" {} - {}", dep, error); + } + return Err(FetchError::HttpError(format!( + "Failed to fetch {} dependencies", + errors.len() + ))); + } - // Filter out NSIDs we've already fetched or tried to fetch - let new_deps: HashSet = unresolved - .into_iter() - .filter(|nsid| !fetched_nsids.contains(nsid)) - .collect(); + println!("\n✓ Successfully fetched all {} dependencies", success_count); + Ok(()) +} - if new_deps.is_empty() { +/// Fetch transitive dependencies by iteratively resolving unresolved references +fn fetch_transitive_dependencies( + project_root: &std::path::Path, + lockfile: &mut LockFile, + optimize_fetches: bool +) -> Result<(), FetchError> { + let mut fetched_nsids = HashSet::new(); + // Track NSIDs from lockfile as already fetched + for nsid in lockfile.lexicons.keys() { + fetched_nsids.insert(nsid.clone()); + } + + let mut iteration = 0; + const MAX_ITERATIONS: usize = 10; + + loop { + iteration += 1; + if iteration > MAX_ITERATIONS { + eprintln!("\nWarning: Reached maximum iteration limit for transitive dependencies"); + break; + } + + // Collect unresolved references + let unresolved = match collect_unresolved_references(project_root) { + Ok(refs) => refs, + Err(e) => { + eprintln!("\nWarning: Failed to analyze dependencies: {}", e); break; } + }; - // Determine whether to optimize transitive fetches - let should_optimize = config.dependencies.optimize_transitive_fetches; + // Filter out NSIDs we've already fetched or tried to fetch + let new_deps: HashSet = unresolved + .into_iter() + .filter(|nsid| !fetched_nsids.contains(nsid)) + .collect(); - if should_optimize { - // Optimize the fetch patterns to reduce number of fetches - let optimized_patterns = optimize_fetch_patterns(&new_deps); + if new_deps.is_empty() { + break; + } - println!("\n→ Found {} unresolved reference(s), fetching {} optimized pattern(s)...", - new_deps.len(), optimized_patterns.len()); + if optimize_fetches { + // Optimize the fetch patterns to reduce number of fetches + let optimized_patterns = optimize_fetch_patterns(&new_deps); - // Track which patterns are wildcards and their constituent NSIDs - let mut wildcard_failures: Vec<(String, Vec)> = Vec::new(); + println!("\n→ Found {} unresolved reference(s), fetching {} optimized pattern(s)...", + new_deps.len(), optimized_patterns.len()); - for pattern in optimized_patterns { - let is_wildcard = pattern.ends_with(".*"); - println!("\nFetching transitive dependency: {}", pattern); - fetched_nsids.insert(pattern.clone()); + // Track which patterns are wildcards and their constituent NSIDs + let mut wildcard_failures: Vec<(String, Vec)> = Vec::new(); - match fetch_lexicon_with_lock(&pattern, project_root, &mut lockfile) { - Ok(()) => { - success_count += 1; - } - Err(e) => { - eprintln!(" Warning: Failed to fetch {}: {}", pattern, e); - - // If this was a wildcard that failed, collect the individual NSIDs for retry - if is_wildcard { - let pattern_prefix = pattern.strip_suffix(".*").unwrap(); - let matching_nsids: Vec = new_deps.iter() - .filter(|nsid| nsid.starts_with(pattern_prefix)) - .cloned() - .collect(); - - if !matching_nsids.is_empty() { - wildcard_failures.push((pattern.clone(), matching_nsids)); - } + for pattern in optimized_patterns { + let is_wildcard = pattern.ends_with(".*"); + println!("\nFetching transitive dependency: {}", pattern); + fetched_nsids.insert(pattern.clone()); + + match fetch_lexicon_with_lock(&pattern, project_root, lockfile) { + Ok(()) => {} + Err(e) => { + eprintln!(" Warning: Failed to fetch {}: {}", pattern, e); + + // If this was a wildcard that failed, collect the individual NSIDs for retry + if is_wildcard { + let pattern_prefix = pattern.strip_suffix(".*").unwrap(); + let matching_nsids: Vec = new_deps.iter() + .filter(|nsid| nsid.starts_with(pattern_prefix)) + .cloned() + .collect(); + + if !matching_nsids.is_empty() { + wildcard_failures.push((pattern.clone(), matching_nsids)); } } } } + } + + // Retry failed wildcards with individual NSIDs + if !wildcard_failures.is_empty() { + println!("\n→ Retrying failed wildcard patterns with individual NSIDs..."); - // Retry failed wildcards with individual NSIDs - if !wildcard_failures.is_empty() { - println!("\n→ Retrying failed wildcard patterns with individual NSIDs..."); - - for (failed_pattern, nsids) in wildcard_failures { - println!(" Retrying {} NSIDs from failed pattern: {}", nsids.len(), failed_pattern); - - for nsid in nsids { - if !fetched_nsids.contains(&nsid) { - println!(" Fetching: {}", nsid); - fetched_nsids.insert(nsid.clone()); - - match fetch_lexicon_with_lock(&nsid, project_root, &mut lockfile) { - Ok(()) => { - success_count += 1; - } - Err(e) => { - eprintln!(" Warning: Failed to fetch {}: {}", nsid, e); - } + for (failed_pattern, nsids) in wildcard_failures { + println!(" Retrying {} NSIDs from failed pattern: {}", nsids.len(), failed_pattern); + + for nsid in nsids { + if !fetched_nsids.contains(&nsid) { + println!(" Fetching: {}", nsid); + fetched_nsids.insert(nsid.clone()); + + match fetch_lexicon_with_lock(&nsid, project_root, lockfile) { + Ok(()) => {} + Err(e) => { + eprintln!(" Warning: Failed to fetch {}: {}", nsid, e); } } } } } - } else { - // Fetch individually without optimization (safer, more predictable) - println!("\n→ Found {} unresolved reference(s), fetching individually...", - new_deps.len()); - - for nsid in &new_deps { - println!("\nFetching transitive dependency: {}", nsid); - fetched_nsids.insert(nsid.clone()); - - match fetch_lexicon_with_lock(nsid, project_root, &mut lockfile) { - Ok(()) => { - success_count += 1; - } - Err(e) => { - // Don't fail the entire fetch for transitive deps - eprintln!(" Warning: Failed to fetch {}: {}", nsid, e); - } + } + } else { + // Fetch individually without optimization (safer, more predictable) + println!("\n→ Found {} unresolved reference(s), fetching individually...", + new_deps.len()); + + for nsid in &new_deps { + println!("\nFetching transitive dependency: {}", nsid); + fetched_nsids.insert(nsid.clone()); + + match fetch_lexicon_with_lock(nsid, project_root, lockfile) { + Ok(()) => {} + Err(e) => { + // Don't fail the entire fetch for transitive deps + eprintln!(" Warning: Failed to fetch {}: {}", nsid, e); } } } } } - // Save the lockfile - lockfile.save(&lockfile_path).map_err(FetchError::NoProjectRoot)?; - println!("\n→ Updated mlf-lock.toml"); - - if !errors.is_empty() { - eprintln!( - "\n{} dependency(ies) fetched successfully, {} error(s):", - success_count, - errors.len() - ); - for (dep, error) in &errors { - eprintln!(" {} - {}", dep, error); - } - return Err(FetchError::HttpError(format!( - "Failed to fetch {} dependencies", - errors.len() - ))); - } - - println!("\n✓ Successfully fetched all {} dependencies", success_count); Ok(()) } @@ -874,8 +903,12 @@ fn collect_unresolved_references(project_root: &std::path::Path) -> Result, annotations: Vec) -> Result { let start = self.expect(LexToken::Def)?; self.expect(LexToken::Type)?; - let name = self.parse_ident()?; + let name = self.parse_ident()?; // Backticked keywords are already converted to Ident by lexer self.expect(LexToken::Equals)?; let ty = self.parse_type()?; let end = self.expect(LexToken::Semicolon)?; diff --git a/mlf-lang/src/workspace.rs b/mlf-lang/src/workspace.rs index 9881ec6..96851b7 100644 --- a/mlf-lang/src/workspace.rs +++ b/mlf-lang/src/workspace.rs @@ -25,6 +25,9 @@ struct SymbolTable { struct ImportTable { mappings: BTreeMap, used_imports: BTreeSet, + // Maps namespace alias to full namespace path + // e.g., "example" -> "com.example" + namespace_aliases: BTreeMap, } #[derive(Debug, Clone, PartialEq)] @@ -168,7 +171,7 @@ impl Workspace { return Err(errors); } - // Check for unused imports + // Check for unused imports (warnings only, don't fail) if let Err(mut unused_import_errors) = self.check_unused_imports() { errors.append(&mut unused_import_errors); } @@ -177,10 +180,15 @@ impl Workspace { errors.append(&mut typecheck_errors); } - if errors.is_empty() { + // Filter out warnings (UnusedImport) from blocking errors + let blocking_errors: Vec = errors.errors.into_iter() + .filter(|e| !matches!(e, ValidationError::UnusedImport { .. })) + .collect(); + + if blocking_errors.is_empty() { Ok(()) } else { - Err(errors) + Err(ValidationErrors { errors: blocking_errors }) } } @@ -875,7 +883,11 @@ impl Workspace { } }; - if !self.modules.contains_key(&target_namespace) { + // Check if the target namespace exists or if there are modules with that prefix + let namespace_exists = self.modules.contains_key(&target_namespace); + let has_children = self.modules.keys().any(|ns| ns.starts_with(&alloc::format!("{}.", target_namespace))); + + if !namespace_exists && !has_children { errors.push(ValidationError::UndefinedReference { name: target_namespace.clone(), span: use_stmt.path.span, @@ -884,23 +896,62 @@ impl Workspace { return Err(errors); } + let namespace_alias_to_add: Option<(String, String)> = match &use_stmt.imports { + UseImports::All => { + // Check for implicit main resolution + // If namespace suffix matches a type name, import only that type + // Otherwise, this is a namespace alias for path shortening + let namespace_suffix = target_namespace.split('.').last().unwrap_or(&target_namespace); + + if let Some(target_module) = self.modules.get(&target_namespace) { + // Module exists - check if there's a type matching the namespace suffix + if target_module.symbols.types.contains_key(namespace_suffix) { + // Implicit main resolution: no namespace alias, just type import + None + } else { + // No type matching namespace suffix - this is a namespace alias + // Create alias: namespace_suffix -> target_namespace + // e.g., "use com.example;" creates alias "example" -> "com.example" + Some((namespace_suffix.to_string(), target_namespace.clone())) + } + } else { + // Module doesn't exist but has children - create namespace alias + // e.g., "use com.example;" with only "com.example.defs" module + Some((namespace_suffix.to_string(), target_namespace.clone())) + } + } + UseImports::Items(_) => { + // Items imports don't create namespace aliases + None + } + }; + let imports_to_add: Vec<(String, ImportedSymbol)> = match &use_stmt.imports { UseImports::All => { - // Import all types from the namespace - let target_module = self.modules.get(&target_namespace).unwrap(); - target_module.symbols.types.keys() - .map(|type_name| { + // Check for implicit main resolution + // If namespace suffix matches a type name, import only that type + let namespace_suffix = target_namespace.split('.').last().unwrap_or(&target_namespace); + + if let Some(target_module) = self.modules.get(&target_namespace) { + // Check if there's a type matching the namespace suffix + if target_module.symbols.types.contains_key(namespace_suffix) { + // Implicit main resolution: import only the type matching the namespace suffix let imported = ImportedSymbol { original_path: use_stmt.path.segments.iter() .map(|s| s.name.clone()) - .chain(core::iter::once(type_name.clone())) .collect(), - local_name: type_name.clone(), + local_name: namespace_suffix.to_string(), span: use_stmt.path.span, }; - (type_name.clone(), imported) - }) - .collect() + vec![(namespace_suffix.to_string(), imported)] + } else { + // Namespace alias only, no type imports + vec![] + } + } else { + // Module doesn't exist - namespace alias only + vec![] + } } UseImports::Items(items) => { // Import specific items from the namespace @@ -974,6 +1025,11 @@ impl Workspace { module.imports.mappings.insert(local_name, imported); } + // Add namespace alias if one was created + if let Some((alias, full_namespace)) = namespace_alias_to_add { + module.imports.namespace_aliases.insert(alias, full_namespace); + } + if errors.is_empty() { Ok(()) } else { @@ -1479,13 +1535,44 @@ impl Workspace { return Err(errors); } - let target_namespace = path.segments[..path.segments.len() - 1] - .iter() - .map(|s| s.name.as_str()) - .collect::>() - .join("."); + // Multi-segment path: check for namespace alias + // If the first segment is a namespace alias, expand it + let first_segment = &path.segments[0].name; let type_name = &path.segments[path.segments.len() - 1].name; + let target_namespace = if let Some(module) = self.modules.get(current_namespace) { + if let Some(full_ns) = module.imports.namespace_aliases.get(first_segment) { + // Found a namespace alias! Expand it + // e.g., "example.defs.foo" with alias "example" -> "com.example" + // The namespace part is "example.defs" which expands to "com.example.defs" + // (excluding the last segment "foo" which is the type name) + let middle_segments: Vec<&str> = path.segments[1..path.segments.len() - 1] + .iter() + .map(|s| s.name.as_str()) + .collect(); + if middle_segments.is_empty() { + // e.g., "example.foo" -> "com.example" + full_ns.clone() + } else { + // e.g., "example.defs.foo" -> "com.example.defs" + alloc::format!("{}.{}", full_ns, middle_segments.join(".")) + } + } else { + // No alias, use the path as-is (excluding type name) + path.segments[..path.segments.len() - 1] + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(".") + } + } else { + path.segments[..path.segments.len() - 1] + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join(".") + }; + // First try: normal resolution (namespace + type) if let Some(module) = self.modules.get(&target_namespace) { if module.symbols.types.contains_key(type_name) { @@ -1496,9 +1583,14 @@ impl Workspace { // Second try: implicit main resolution // If com.atproto.repo.strongRef fails, try treating the full path as a namespace // and look for a type named "strongRef" (matching the namespace suffix) - let full_namespace = &full_path; - if let Some(module) = self.modules.get(full_namespace) { - let namespace_suffix = full_namespace.split('.').last().unwrap_or(full_namespace); + // Need to use the expanded path if an alias was used + let expanded_full_path = if target_namespace.is_empty() { + type_name.to_string() + } else { + alloc::format!("{}.{}", target_namespace, type_name) + }; + if let Some(module) = self.modules.get(&expanded_full_path) { + let namespace_suffix = expanded_full_path.split('.').last().unwrap_or(&expanded_full_path); if namespace_suffix == type_name && module.symbols.types.contains_key(type_name) { return Ok(()); } @@ -1582,19 +1674,6 @@ mod tests { assert!(ws.modules.contains_key("prelude")); } - #[test] - fn test_use_import_all() { - let mut ws = Workspace::new(); - - let a = parse_lexicon("record foo {} inline type bar = string;").unwrap(); - ws.add_module("a".into(), a).unwrap(); - - let b = parse_lexicon("use a; record baz { x: foo, y: bar, }").unwrap(); - ws.add_module("b".into(), b).unwrap(); - - assert!(ws.resolve().is_ok()); - } - #[test] fn test_use_import_with_alias() { let mut ws = Workspace::new(); @@ -2106,10 +2185,9 @@ mod tests { let b = parse_lexicon("use a.foo as Foo; record bar {}").unwrap(); ws.add_module("b".into(), b).unwrap(); + // UnusedImport is now a warning, not a blocking error let result = ws.resolve(); - assert!(result.is_err()); - let errors = result.unwrap_err(); - assert!(errors.errors.iter().any(|e| matches!(e, ValidationError::UnusedImport { .. }))); + assert!(result.is_ok()); } #[test] @@ -2136,11 +2214,155 @@ mod tests { let b = parse_lexicon("use a; record baz {}").unwrap(); ws.add_module("b".into(), b).unwrap(); + // UnusedImport is now a warning, not a blocking error let result = ws.resolve(); - assert!(result.is_err()); - let errors = result.unwrap_err(); - // Should have 2 unused import errors (foo and bar) - let unused_count = errors.errors.iter().filter(|e| matches!(e, ValidationError::UnusedImport { .. })).count(); - assert_eq!(unused_count, 2); + assert!(result.is_ok()); + } + + #[test] + fn test_use_implicit_main_resolution() { + let mut ws = Workspace::new(); + + // Create a namespace where the namespace suffix matches a type name + // No @main annotation needed for implicit main resolution + let profile_ns = parse_lexicon(r#" + def type color = { + red!: integer, + green!: integer, + blue!: integer, + }; + + record profile { + color: color, + } + "#).unwrap(); + ws.add_module("place.stream.chat.profile".into(), profile_ns).unwrap(); + + // Import using implicit main resolution - should only import profile, not color + let bookmark = parse_lexicon(r#" + use place.stream.chat.profile; + + record bookmark { + owner!: profile, + } + "#).unwrap(); + ws.add_module("com.example.bookmark".into(), bookmark).unwrap(); + + // Should resolve without unused import warning for color + let result = ws.resolve(); + assert!(result.is_ok()); + + // Verify that only profile was imported (implicit main resolution) + let imports = ws.get_imports("com.example.bookmark"); + assert_eq!(imports.len(), 1); + assert_eq!(imports[0].0, "profile"); + } + + #[test] + fn test_use_namespace_alias() { + let mut ws = Workspace::new(); + + // Create a namespace where the suffix doesn't match a type name + let defs = parse_lexicon(r#" + def type foo = string; + def type bar = integer; + "#).unwrap(); + ws.add_module("com.example.defs".into(), defs).unwrap(); + + // Using "use com.example;" creates a namespace alias "example" -> "com.example" + // This allows referencing types via the shortened path + let app = parse_lexicon(r#" + use com.example; + + record thing { + x!: example.defs.foo, + y!: example.defs.bar, + } + "#).unwrap(); + ws.add_module("com.example.app".into(), app).unwrap(); + + // Should resolve successfully using the namespace alias + let result = ws.resolve(); + if let Err(ref e) = result { + eprintln!("Errors: {:?}", e); + } + assert!(result.is_ok()); + + // Verify that no types were imported (it's just a namespace alias) + let imports = ws.get_imports("com.example.app"); + assert_eq!(imports.len(), 0); + } + + #[test] + fn test_use_namespace_alias_nested() { + let mut ws = Workspace::new(); + + // Create nested namespaces + let actor_defs = parse_lexicon(r#" + def type profileView = { + did!: string, + handle!: string, + }; + "#).unwrap(); + ws.add_module("app.bsky.actor.defs".into(), actor_defs).unwrap(); + + let feed_post = parse_lexicon(r#" + def type post = { + text!: string, + }; + "#).unwrap(); + ws.add_module("app.bsky.feed.post".into(), feed_post).unwrap(); + + // Use namespace alias to shorten references + let like = parse_lexicon(r#" + use app.bsky; + + record like { + subject!: bsky.feed.post, + actor!: bsky.actor.defs.profileView, + } + "#).unwrap(); + ws.add_module("app.bsky.feed.like".into(), like).unwrap(); + + let result = ws.resolve(); + if let Err(ref e) = result { + eprintln!("Errors: {:?}", e); + } + assert!(result.is_ok()); + } + + #[test] + fn test_use_namespace_reference_full_path() { + let mut ws = Workspace::new(); + + // Create a namespace where the suffix doesn't match a type name + let defs = parse_lexicon(r#" + def type foo = string; + def type bar = integer; + "#).unwrap(); + ws.add_module("com.example.defs".into(), defs).unwrap(); + + // Using "use com.example.defs;" where "defs" is not a type name + // creates a namespace alias "defs" -> "com.example.defs" + let app = parse_lexicon(r#" + use com.example.defs; + + record thing { + x!: defs.foo, + y!: defs.bar, + } + "#).unwrap(); + ws.add_module("com.example.app".into(), app).unwrap(); + + // Should resolve successfully using the namespace alias + let result = ws.resolve(); + if let Err(ref e) = result { + eprintln!("Errors: {:?}", e); + } + assert!(result.is_ok()); + + // Verify that no types were imported (it's just a namespace alias) + let imports = ws.get_imports("com.example.app"); + assert_eq!(imports.len(), 0); } }