A human-friendly DSL for ATProto Lexicons
1

Configure Feed

Select the types of activity you want to include in your feed.

Emit union refs as nsids

authored by stavola.xyz and committed by

Tangled (Apr 15, 2026, 5:58 PM +0300) 0fbec7bc 1ad33d36

+66 -74
+66 -74
mlf-codegen/src/lib.rs
··· 412 412 } 413 413 } 414 414 415 + /// Resolve a typed reference path to the ATProto NSID string it names 416 + /// (e.g. `#foo`, `app.bsky.actor.defs#profileViewBasic`, or a bare 417 + /// `com.atproto.repo.strongRef` for implicit-main references). Used by 418 + /// both direct `ref` types and union members — any place that needs to 419 + /// emit the string form of a reference. 420 + /// 421 + /// When workspace resolution fails (unresolved import, malformed path, 422 + /// etc.) we fall back to a best-effort rendering rather than erroring: 423 + /// the semantic check still happens in the resolver, so emitting a 424 + /// recognisable-looking ref keeps the JSON self-describing for tooling. 425 + fn resolve_ref_nsid(path: &Path, workspace: &Workspace, current_namespace: &str) -> String { 426 + if let Some(full_namespace) = workspace.resolve_reference_namespace(path, current_namespace) { 427 + let last_segment = path.segments.last().unwrap().name.as_str(); 428 + if full_namespace == current_namespace { 429 + // Sibling def in the current lexicon. 430 + return format!("#{}", last_segment); 431 + } 432 + return format!("{}#{}", full_namespace, last_segment); 433 + } 434 + 435 + if path.segments.len() == 1 { 436 + let name = &path.segments[0].name; 437 + // Single-segment path that didn't resolve via the workspace — 438 + // maybe an imported symbol whose original path we can look up. 439 + let imports = workspace.get_imports(current_namespace); 440 + if let Some((_, original_path)) = imports.iter().find(|(local, _)| local == name) { 441 + if original_path.len() > 1 { 442 + let namespace = original_path[..original_path.len() - 1].join("."); 443 + let type_name = original_path.last().unwrap(); 444 + return format!("{}#{}", namespace, type_name); 445 + } 446 + } 447 + // Not imported either — assume it names a sibling def. 448 + return format!("#{}", name); 449 + } 450 + 451 + // Multi-segment unresolved path: trust the author's NSID as written. 452 + let namespace = path.segments[..path.segments.len() - 1] 453 + .iter() 454 + .map(|s| s.name.as_str()) 455 + .collect::<Vec<_>>() 456 + .join("."); 457 + let def_name = &path.segments.last().unwrap().name; 458 + format!("{}#{}", namespace, def_name) 459 + } 460 + 415 461 fn generate_record_json(record: &Record, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 416 462 let mut required = Vec::new(); 417 463 let mut properties = Map::new(); ··· 609 655 match ty { 610 656 Type::Primitive { kind, .. } => generate_primitive_json(*kind), 611 657 Type::Reference { path, .. } => { 612 - // Try to resolve this reference in the workspace 613 - if let Some(resolved_ty) = workspace.resolve_type_reference(path) { 614 - // Check if this is an inline type by looking in the workspace 615 - if workspace.is_inline_type(path) { 616 - // Inline type: expand it recursively 658 + // Inline types expand in place; other references resolve to an NSID. 659 + if workspace.is_inline_type(path) { 660 + if let Some(resolved_ty) = workspace.resolve_type_reference(path) { 617 661 return generate_type_json(&resolved_ty, usage_counts, workspace, current_namespace); 618 662 } 619 663 } 620 - 621 - // Not an inline type (or couldn't resolve) - generate a ref 622 - // First, try to get the fully resolved namespace for this type 623 - if let Some(full_namespace) = workspace.resolve_reference_namespace(path, current_namespace) { 624 - // We have the full namespace where this type is defined 625 - if full_namespace == current_namespace { 626 - // It's in the current namespace - use local reference 627 - let type_name = path.segments.last().unwrap().name.as_str(); 628 - json!({ 629 - "type": "ref", 630 - "ref": format!("#{}", type_name) 631 - }) 632 - } else { 633 - // It's in a different namespace - use full reference 634 - let type_name = path.segments.last().unwrap().name.as_str(); 635 - json!({ 636 - "type": "ref", 637 - "ref": format!("{}#{}", full_namespace, type_name) 638 - }) 639 - } 640 - } else if path.segments.len() == 1 { 641 - // Couldn't resolve namespace - check if it's an imported type 642 - let name = &path.segments[0].name; 643 - let imports = workspace.get_imports(current_namespace); 644 - 645 - // Look for this name in imports 646 - if let Some((_local_name, original_path)) = imports.iter().find(|(local, _)| local == name) { 647 - // Build the full namespace#type reference from the import path 648 - // original_path is like ["com", "atproto", "label", "defs", "label"] 649 - // We want "com.atproto.label.defs#label" 650 - if original_path.len() > 1 { 651 - let namespace = original_path[..original_path.len() - 1].join("."); 652 - let type_name = original_path.last().unwrap(); 653 - json!({ 654 - "type": "ref", 655 - "ref": format!("{}#{}", namespace, type_name) 656 - }) 657 - } else { 658 - // Fallback: single-segment import (shouldn't happen but handle it) 659 - json!({ 660 - "type": "ref", 661 - "ref": format!("#{}", name) 662 - }) 663 - } 664 - } else { 665 - // Not an import - assume local reference 666 - json!({ 667 - "type": "ref", 668 - "ref": format!("#{}", name) 669 - }) 670 - } 671 - } else { 672 - // Multi-segment path ref - use as-is 673 - let namespace = path.segments[..path.segments.len()-1] 674 - .iter() 675 - .map(|s| s.name.as_str()) 676 - .collect::<Vec<_>>() 677 - .join("."); 678 - let def_name = &path.segments.last().unwrap().name; 679 - 680 - json!({ 681 - "type": "ref", 682 - "ref": format!("{}#{}", namespace, def_name) 683 - }) 684 - } 664 + let nsid = resolve_ref_nsid(path, workspace, current_namespace); 665 + json!({ "type": "ref", "ref": nsid }) 685 666 } 686 667 Type::Array { inner, .. } => { 687 668 json!({ ··· 690 671 }) 691 672 } 692 673 Type::Union { types, closed, .. } => { 674 + // ATProto unions carry an array of NSID *strings*. For ref 675 + // members we resolve the NSID directly; for non-ref members 676 + // (e.g. `null` produced by MLF's nullable-via-union pattern) 677 + // we fall through to the typed form. That fallback is 678 + // non-spec — ATProto unions strictly contain refs — but 679 + // preserves the member's semantics until a proper nullable 680 + // lowering is added upstream. 693 681 let refs: Vec<Value> = types 694 682 .iter() 695 - .map(|t| generate_type_json(t, usage_counts, workspace, current_namespace)) 683 + .map(|t| match t { 684 + Type::Reference { path, .. } => { 685 + Value::String(resolve_ref_nsid(path, workspace, current_namespace)) 686 + } 687 + _ => generate_type_json(t, usage_counts, workspace, current_namespace), 688 + }) 696 689 .collect(); 697 690 let mut union_obj = Map::new(); 698 691 union_obj.insert("type".to_string(), json!("union")); 699 - union_obj.insert("refs".to_string(), json!(refs)); 700 - // Only emit "closed" field if true (closed unions) 701 - // Open unions omit the field (defaults to false per ATProto spec) 692 + union_obj.insert("refs".to_string(), Value::Array(refs)); 693 + // Only emit "closed" when true; open unions omit it per spec. 702 694 if *closed { 703 695 union_obj.insert("closed".to_string(), json!(true)); 704 696 }