diff --git a/mlf-codegen/src/lib.rs b/mlf-codegen/src/lib.rs index a09d399..0e29261 100644 --- a/mlf-codegen/src/lib.rs +++ b/mlf-codegen/src/lib.rs @@ -412,6 +412,52 @@ fn build_params_object_opt(properties: Map, required: &[String]) } } +/// Resolve a typed reference path to the ATProto NSID string it names +/// (e.g. `#foo`, `app.bsky.actor.defs#profileViewBasic`, or a bare +/// `com.atproto.repo.strongRef` for implicit-main references). Used by +/// both direct `ref` types and union members — any place that needs to +/// emit the string form of a reference. +/// +/// When workspace resolution fails (unresolved import, malformed path, +/// etc.) we fall back to a best-effort rendering rather than erroring: +/// the semantic check still happens in the resolver, so emitting a +/// recognisable-looking ref keeps the JSON self-describing for tooling. +fn resolve_ref_nsid(path: &Path, workspace: &Workspace, current_namespace: &str) -> String { + if let Some(full_namespace) = workspace.resolve_reference_namespace(path, current_namespace) { + let last_segment = path.segments.last().unwrap().name.as_str(); + if full_namespace == current_namespace { + // Sibling def in the current lexicon. + return format!("#{}", last_segment); + } + return format!("{}#{}", full_namespace, last_segment); + } + + if path.segments.len() == 1 { + let name = &path.segments[0].name; + // Single-segment path that didn't resolve via the workspace — + // maybe an imported symbol whose original path we can look up. + let imports = workspace.get_imports(current_namespace); + if let Some((_, original_path)) = imports.iter().find(|(local, _)| local == name) { + if original_path.len() > 1 { + let namespace = original_path[..original_path.len() - 1].join("."); + let type_name = original_path.last().unwrap(); + return format!("{}#{}", namespace, type_name); + } + } + // Not imported either — assume it names a sibling def. + return format!("#{}", name); + } + + // Multi-segment unresolved path: trust the author's NSID as written. + let namespace = path.segments[..path.segments.len() - 1] + .iter() + .map(|s| s.name.as_str()) + .collect::>() + .join("."); + let def_name = &path.segments.last().unwrap().name; + format!("{}#{}", namespace, def_name) +} + fn generate_record_json(record: &Record, usage_counts: &HashMap, workspace: &Workspace, current_namespace: &str) -> Value { let mut required = Vec::new(); let mut properties = Map::new(); @@ -609,79 +655,14 @@ fn generate_type_json(ty: &Type, usage_counts: &HashMap, workspac match ty { Type::Primitive { kind, .. } => generate_primitive_json(*kind), Type::Reference { path, .. } => { - // Try to resolve this reference in the workspace - if let Some(resolved_ty) = workspace.resolve_type_reference(path) { - // Check if this is an inline type by looking in the workspace - if workspace.is_inline_type(path) { - // Inline type: expand it recursively + // Inline types expand in place; other references resolve to an NSID. + if workspace.is_inline_type(path) { + if let Some(resolved_ty) = workspace.resolve_type_reference(path) { return generate_type_json(&resolved_ty, usage_counts, workspace, current_namespace); } } - - // Not an inline type (or couldn't resolve) - generate a ref - // First, try to get the fully resolved namespace for this type - if let Some(full_namespace) = workspace.resolve_reference_namespace(path, current_namespace) { - // We have the full namespace where this type is defined - if full_namespace == current_namespace { - // It's in the current namespace - use local reference - let type_name = path.segments.last().unwrap().name.as_str(); - json!({ - "type": "ref", - "ref": format!("#{}", type_name) - }) - } else { - // It's in a different namespace - use full reference - let type_name = path.segments.last().unwrap().name.as_str(); - json!({ - "type": "ref", - "ref": format!("{}#{}", full_namespace, type_name) - }) - } - } else if path.segments.len() == 1 { - // Couldn't resolve namespace - check if it's an imported type - let name = &path.segments[0].name; - let imports = workspace.get_imports(current_namespace); - - // Look for this name in imports - if let Some((_local_name, original_path)) = imports.iter().find(|(local, _)| local == name) { - // Build the full namespace#type reference from the import path - // original_path is like ["com", "atproto", "label", "defs", "label"] - // We want "com.atproto.label.defs#label" - if original_path.len() > 1 { - let namespace = original_path[..original_path.len() - 1].join("."); - let type_name = original_path.last().unwrap(); - json!({ - "type": "ref", - "ref": format!("{}#{}", namespace, type_name) - }) - } else { - // Fallback: single-segment import (shouldn't happen but handle it) - json!({ - "type": "ref", - "ref": format!("#{}", name) - }) - } - } else { - // Not an import - assume local reference - json!({ - "type": "ref", - "ref": format!("#{}", name) - }) - } - } else { - // Multi-segment path ref - use as-is - let namespace = path.segments[..path.segments.len()-1] - .iter() - .map(|s| s.name.as_str()) - .collect::>() - .join("."); - let def_name = &path.segments.last().unwrap().name; - - json!({ - "type": "ref", - "ref": format!("{}#{}", namespace, def_name) - }) - } + let nsid = resolve_ref_nsid(path, workspace, current_namespace); + json!({ "type": "ref", "ref": nsid }) } Type::Array { inner, .. } => { json!({ @@ -690,15 +671,26 @@ fn generate_type_json(ty: &Type, usage_counts: &HashMap, workspac }) } Type::Union { types, closed, .. } => { + // ATProto unions carry an array of NSID *strings*. For ref + // members we resolve the NSID directly; for non-ref members + // (e.g. `null` produced by MLF's nullable-via-union pattern) + // we fall through to the typed form. That fallback is + // non-spec — ATProto unions strictly contain refs — but + // preserves the member's semantics until a proper nullable + // lowering is added upstream. let refs: Vec = types .iter() - .map(|t| generate_type_json(t, usage_counts, workspace, current_namespace)) + .map(|t| match t { + Type::Reference { path, .. } => { + Value::String(resolve_ref_nsid(path, workspace, current_namespace)) + } + _ => generate_type_json(t, usage_counts, workspace, current_namespace), + }) .collect(); let mut union_obj = Map::new(); union_obj.insert("type".to_string(), json!("union")); - union_obj.insert("refs".to_string(), json!(refs)); - // Only emit "closed" field if true (closed unions) - // Open unions omit the field (defaults to false per ATProto spec) + union_obj.insert("refs".to_string(), Value::Array(refs)); + // Only emit "closed" when true; open unions omit it per spec. if *closed { union_obj.insert("closed".to_string(), json!(true)); }