···412412 }
413413}
414414415415+/// Resolve a typed reference path to the ATProto NSID string it names
416416+/// (e.g. `#foo`, `app.bsky.actor.defs#profileViewBasic`, or a bare
417417+/// `com.atproto.repo.strongRef` for implicit-main references). Used by
418418+/// both direct `ref` types and union members — any place that needs to
419419+/// emit the string form of a reference.
420420+///
421421+/// When workspace resolution fails (unresolved import, malformed path,
422422+/// etc.) we fall back to a best-effort rendering rather than erroring:
423423+/// the semantic check still happens in the resolver, so emitting a
424424+/// recognisable-looking ref keeps the JSON self-describing for tooling.
425425+fn resolve_ref_nsid(path: &Path, workspace: &Workspace, current_namespace: &str) -> String {
426426+ if let Some(full_namespace) = workspace.resolve_reference_namespace(path, current_namespace) {
427427+ let last_segment = path.segments.last().unwrap().name.as_str();
428428+ if full_namespace == current_namespace {
429429+ // Sibling def in the current lexicon.
430430+ return format!("#{}", last_segment);
431431+ }
432432+ return format!("{}#{}", full_namespace, last_segment);
433433+ }
434434+435435+ if path.segments.len() == 1 {
436436+ let name = &path.segments[0].name;
437437+ // Single-segment path that didn't resolve via the workspace —
438438+ // maybe an imported symbol whose original path we can look up.
439439+ let imports = workspace.get_imports(current_namespace);
440440+ if let Some((_, original_path)) = imports.iter().find(|(local, _)| local == name) {
441441+ if original_path.len() > 1 {
442442+ let namespace = original_path[..original_path.len() - 1].join(".");
443443+ let type_name = original_path.last().unwrap();
444444+ return format!("{}#{}", namespace, type_name);
445445+ }
446446+ }
447447+ // Not imported either — assume it names a sibling def.
448448+ return format!("#{}", name);
449449+ }
450450+451451+ // Multi-segment unresolved path: trust the author's NSID as written.
452452+ let namespace = path.segments[..path.segments.len() - 1]
453453+ .iter()
454454+ .map(|s| s.name.as_str())
455455+ .collect::<Vec<_>>()
456456+ .join(".");
457457+ let def_name = &path.segments.last().unwrap().name;
458458+ format!("{}#{}", namespace, def_name)
459459+}
460460+415461fn generate_record_json(record: &Record, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value {
416462 let mut required = Vec::new();
417463 let mut properties = Map::new();
···609655 match ty {
610656 Type::Primitive { kind, .. } => generate_primitive_json(*kind),
611657 Type::Reference { path, .. } => {
612612- // Try to resolve this reference in the workspace
613613- if let Some(resolved_ty) = workspace.resolve_type_reference(path) {
614614- // Check if this is an inline type by looking in the workspace
615615- if workspace.is_inline_type(path) {
616616- // Inline type: expand it recursively
658658+ // Inline types expand in place; other references resolve to an NSID.
659659+ if workspace.is_inline_type(path) {
660660+ if let Some(resolved_ty) = workspace.resolve_type_reference(path) {
617661 return generate_type_json(&resolved_ty, usage_counts, workspace, current_namespace);
618662 }
619663 }
620620-621621- // Not an inline type (or couldn't resolve) - generate a ref
622622- // First, try to get the fully resolved namespace for this type
623623- if let Some(full_namespace) = workspace.resolve_reference_namespace(path, current_namespace) {
624624- // We have the full namespace where this type is defined
625625- if full_namespace == current_namespace {
626626- // It's in the current namespace - use local reference
627627- let type_name = path.segments.last().unwrap().name.as_str();
628628- json!({
629629- "type": "ref",
630630- "ref": format!("#{}", type_name)
631631- })
632632- } else {
633633- // It's in a different namespace - use full reference
634634- let type_name = path.segments.last().unwrap().name.as_str();
635635- json!({
636636- "type": "ref",
637637- "ref": format!("{}#{}", full_namespace, type_name)
638638- })
639639- }
640640- } else if path.segments.len() == 1 {
641641- // Couldn't resolve namespace - check if it's an imported type
642642- let name = &path.segments[0].name;
643643- let imports = workspace.get_imports(current_namespace);
644644-645645- // Look for this name in imports
646646- if let Some((_local_name, original_path)) = imports.iter().find(|(local, _)| local == name) {
647647- // Build the full namespace#type reference from the import path
648648- // original_path is like ["com", "atproto", "label", "defs", "label"]
649649- // We want "com.atproto.label.defs#label"
650650- if original_path.len() > 1 {
651651- let namespace = original_path[..original_path.len() - 1].join(".");
652652- let type_name = original_path.last().unwrap();
653653- json!({
654654- "type": "ref",
655655- "ref": format!("{}#{}", namespace, type_name)
656656- })
657657- } else {
658658- // Fallback: single-segment import (shouldn't happen but handle it)
659659- json!({
660660- "type": "ref",
661661- "ref": format!("#{}", name)
662662- })
663663- }
664664- } else {
665665- // Not an import - assume local reference
666666- json!({
667667- "type": "ref",
668668- "ref": format!("#{}", name)
669669- })
670670- }
671671- } else {
672672- // Multi-segment path ref - use as-is
673673- let namespace = path.segments[..path.segments.len()-1]
674674- .iter()
675675- .map(|s| s.name.as_str())
676676- .collect::<Vec<_>>()
677677- .join(".");
678678- let def_name = &path.segments.last().unwrap().name;
679679-680680- json!({
681681- "type": "ref",
682682- "ref": format!("{}#{}", namespace, def_name)
683683- })
684684- }
664664+ let nsid = resolve_ref_nsid(path, workspace, current_namespace);
665665+ json!({ "type": "ref", "ref": nsid })
685666 }
686667 Type::Array { inner, .. } => {
687668 json!({
···690671 })
691672 }
692673 Type::Union { types, closed, .. } => {
674674+ // ATProto unions carry an array of NSID *strings*. For ref
675675+ // members we resolve the NSID directly; for non-ref members
676676+ // (e.g. `null` produced by MLF's nullable-via-union pattern)
677677+ // we fall through to the typed form. That fallback is
678678+ // non-spec — ATProto unions strictly contain refs — but
679679+ // preserves the member's semantics until a proper nullable
680680+ // lowering is added upstream.
693681 let refs: Vec<Value> = types
694682 .iter()
695695- .map(|t| generate_type_json(t, usage_counts, workspace, current_namespace))
683683+ .map(|t| match t {
684684+ Type::Reference { path, .. } => {
685685+ Value::String(resolve_ref_nsid(path, workspace, current_namespace))
686686+ }
687687+ _ => generate_type_json(t, usage_counts, workspace, current_namespace),
688688+ })
696689 .collect();
697690 let mut union_obj = Map::new();
698691 union_obj.insert("type".to_string(), json!("union"));
699699- union_obj.insert("refs".to_string(), json!(refs));
700700- // Only emit "closed" field if true (closed unions)
701701- // Open unions omit the field (defaults to false per ATProto spec)
692692+ union_obj.insert("refs".to_string(), Value::Array(refs));
693693+ // Only emit "closed" when true; open unions omit it per spec.
702694 if *closed {
703695 union_obj.insert("closed".to_string(), json!(true));
704696 }