···11use mlf_lang::ast::*;
22-use mlf_lang::Workspace;
22+use mlf_lang::{ResolvedRef, Workspace};
33use serde_json::{json, Map, Value};
44use std::collections::HashMap;
55···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.
415415+/// Resolve a typed reference path to the ATProto NSID string it names.
420416///
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.
417417+/// The three valid output forms — local `#foo`, external `ns.path#foo`,
418418+/// and bare `ns.path` (implicit-main) — map one-to-one onto the
419419+/// [`ResolvedRef`] variants, so there's no guessing about the def name
420420+/// or whether to append a fragment. Unresolvable paths (typically a
421421+/// typo caught elsewhere by validation) fall through to a best-effort
422422+/// rendering so the JSON stays self-describing for tooling.
425423fn 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);
424424+ match workspace.resolve_ref(path, current_namespace) {
425425+ Some(ResolvedRef::Local { def_name }) => format!("#{}", def_name),
426426+ Some(ResolvedRef::ImplicitMain { namespace, .. }) => namespace,
427427+ Some(ResolvedRef::External { namespace, def_name }) => format!("{}#{}", namespace, def_name),
428428+ None => unresolved_ref_fallback(path),
433429 }
430430+}
434431432432+/// Render a reference path the workspace couldn't resolve. Multi-
433433+/// segment paths are emitted as `ns.path#def_name` on the assumption
434434+/// that the author wrote a full NSID; single-segment paths render as a
435435+/// local `#name` fragment.
436436+fn unresolved_ref_fallback(path: &Path) -> String {
435437 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);
438438+ return format!("#{}", path.segments[0].name);
449439 }
450450-451451- // Multi-segment unresolved path: trust the author's NSID as written.
452440 let namespace = path.segments[..path.segments.len() - 1]
453441 .iter()
454442 .map(|s| s.name.as_str())
+1-1
mlf-lang/src/lib.rs
···1414pub use error::{ParseError, ValidationError, ValidationErrors};
1515pub use parser::parse_lexicon;
1616pub use validate::validate_lexicon;
1717-pub use workspace::Workspace;
1717+pub use workspace::{ResolvedRef, Workspace};
18181919// Standard library directory
2020use include_dir::{include_dir, Dir};
+82-34
mlf-lang/src/workspace.rs
···88 modules: BTreeMap<String, Module>,
99}
10101111+/// How a reference path resolves in the workspace.
1212+///
1313+/// ATProto names a def with three syntactically distinct forms — a local
1414+/// fragment (`#foo`), an explicit external reference (`ns.path#foo`), and
1515+/// a bare NSID (`ns.path`, shorthand for `#main`). MLF source collapses
1616+/// these into path expressions, and downstream code (codegen, LSP,
1717+/// diagnostics) needs to know which form to emit or display for the
1818+/// canonical output. `ResolvedRef` carries that discrimination so callers
1919+/// don't have to re-derive it from implementation details of the resolver.
2020+#[derive(Debug, Clone, PartialEq, Eq)]
2121+pub enum ResolvedRef {
2222+ /// The reference names a def in the same lexicon as the reference
2323+ /// site. Codegen emits this as `#def_name`.
2424+ Local { def_name: String },
2525+ /// The reference names a def in a different lexicon. Codegen emits
2626+ /// this as `namespace#def_name`.
2727+ External { namespace: String, def_name: String },
2828+ /// The reference resolves via MLF's implicit-main convention: the
2929+ /// whole path names a lexicon whose "main" def is conventionally the
3030+ /// def matching the last segment. In ATProto this corresponds to the
3131+ /// bare NSID form (`namespace`, equivalent to `namespace#main`).
3232+ ImplicitMain { namespace: String, def_name: String },
3333+}
3434+1135#[derive(Debug, Clone, PartialEq)]
1236struct Module {
1337 namespace: String,
···767791 }
768792769793 /// Resolve a type reference to its actual namespace
770770- /// Returns the namespace where the type is defined, or None if not found
771771- pub fn resolve_reference_namespace(&self, path: &Path, current_namespace: &str) -> Option<String> {
794794+ /// Resolve a reference path (evaluated from inside `current_namespace`)
795795+ /// to the lexicon and def it names. Returns a structured
796796+ /// [`ResolvedRef`] so callers can distinguish local, external, and
797797+ /// implicit-main references without re-deriving the resolution
798798+ /// decision from a bare namespace string.
799799+ pub fn resolve_ref(&self, path: &Path, current_namespace: &str) -> Option<ResolvedRef> {
772800 if path.segments.len() == 1 {
773801 let name = &path.segments[0].name;
774802775775- // Check current module first
776803 if let Some(module) = self.modules.get(current_namespace) {
777777- // Check if it's a local type
778804 if module.symbols.types.contains_key(name) {
779779- return Some(current_namespace.to_string());
805805+ return Some(ResolvedRef::Local { def_name: name.clone() });
780806 }
781807782782- // Check if it's imported
783808 if let Some(imported) = module.imports.mappings.get(name) {
784784- // Build the full namespace from the original path
785785- // original_path is Vec<String> like ["place", "stream", "key", "key"]
786786- // We want "place.stream.key" (drop the last segment which is the type name)
809809+ // `original_path` is the fully-qualified import path
810810+ // (e.g. `["com", "atproto", "label", "defs", "label"]`).
811811+ // All but the last segment form the namespace.
787812 if imported.original_path.len() > 1 {
788813 let namespace = imported.original_path[..imported.original_path.len() - 1].join(".");
789789- return Some(namespace);
814814+ let def_name = imported.original_path.last().unwrap().clone();
815815+ return Some(ResolvedRef::External { namespace, def_name });
790816 } else {
791791- // Edge case: just a single segment, assume it's in the current namespace
792792- return Some(current_namespace.to_string());
817817+ // Pathological single-segment import: treat as local.
818818+ return Some(ResolvedRef::Local { def_name: name.clone() });
793819 }
794820 }
795821 }
796822797797- // Check prelude
798823 if let Some(module) = self.modules.get("prelude") {
799824 if module.symbols.types.contains_key(name) {
800800- return Some("prelude".to_string());
825825+ return Some(ResolvedRef::External {
826826+ namespace: "prelude".to_string(),
827827+ def_name: name.clone(),
828828+ });
801829 }
802830 }
803831804832 return None;
805805- } else {
806806- // Multi-segment path: resolve normally
807807- let target_namespace = path.segments[..path.segments.len() - 1]
808808- .iter()
809809- .map(|s| s.name.as_str())
810810- .collect::<Vec<_>>()
811811- .join(".");
812812- let type_name = &path.segments[path.segments.len() - 1].name;
833833+ }
813834814814- // First try: normal resolution
815815- if let Some(module) = self.modules.get(&target_namespace) {
816816- if module.symbols.types.contains_key(type_name) {
817817- return Some(target_namespace);
835835+ // Multi-segment path: try the explicit `namespace.def_name` form first.
836836+ let target_namespace = path.segments[..path.segments.len() - 1]
837837+ .iter()
838838+ .map(|s| s.name.as_str())
839839+ .collect::<Vec<_>>()
840840+ .join(".");
841841+ let type_name = &path.segments[path.segments.len() - 1].name;
842842+843843+ if let Some(module) = self.modules.get(&target_namespace) {
844844+ if module.symbols.types.contains_key(type_name) {
845845+ let namespace = target_namespace;
846846+ if namespace == current_namespace {
847847+ return Some(ResolvedRef::Local { def_name: type_name.clone() });
818848 }
849849+ return Some(ResolvedRef::External { namespace, def_name: type_name.clone() });
819850 }
851851+ }
820852821821- // Second try: implicit main resolution
822822- let full_namespace = path.to_string();
823823- if let Some(module) = self.modules.get(&full_namespace) {
824824- let namespace_suffix = full_namespace.split('.').last().unwrap_or(&full_namespace);
825825- if namespace_suffix == type_name && module.symbols.types.contains_key(type_name) {
826826- return Some(full_namespace);
827827- }
853853+ // Fall back to the implicit-main convention: the whole path names a
854854+ // lexicon whose "main" def is conventionally the def matching the
855855+ // last segment. In ATProto this corresponds to a bare NSID.
856856+ let full_namespace = path.to_string();
857857+ if let Some(module) = self.modules.get(&full_namespace) {
858858+ let namespace_suffix = full_namespace.split('.').last().unwrap_or(&full_namespace);
859859+ if namespace_suffix == type_name && module.symbols.types.contains_key(type_name) {
860860+ return Some(ResolvedRef::ImplicitMain {
861861+ namespace: full_namespace,
862862+ def_name: type_name.clone(),
863863+ });
828864 }
865865+ }
829866830830- None
867867+ None
868868+ }
869869+870870+ /// Return only the resolved lexicon namespace for a reference path.
871871+ /// Thin shim over [`Self::resolve_ref`]; prefer that for new call
872872+ /// sites where the variant (local vs external vs implicit-main)
873873+ /// matters.
874874+ pub fn resolve_reference_namespace(&self, path: &Path, current_namespace: &str) -> Option<String> {
875875+ match self.resolve_ref(path, current_namespace)? {
876876+ ResolvedRef::Local { .. } => Some(current_namespace.to_string()),
877877+ ResolvedRef::External { namespace, .. }
878878+ | ResolvedRef::ImplicitMain { namespace, .. } => Some(namespace),
831879 }
832880 }
833881
+71-2
tests/real_world/roundtrip.rs
···285285) -> ComparisonResult {
286286 let mut acceptable_diffs = Vec::new();
287287288288- let original_stripped = strip_dollar_type(original);
289289- let generated_stripped = strip_dollar_type(generated);
288288+ // Lexicon authors use any of three syntactic forms for references
289289+ // (`#foo`, `ns.path#foo`, bare `ns.path` for implicit-main), so we
290290+ // rewrite both sides to a single canonical form before comparing.
291291+ // This folds away C7/C8-style diffs that are purely stylistic.
292292+ let lexicon_id = original
293293+ .get("id")
294294+ .and_then(|v| v.as_str())
295295+ .unwrap_or("")
296296+ .to_string();
297297+ let mut original = original.clone();
298298+ let mut generated = generated.clone();
299299+ canonicalize_refs(&mut original, &lexicon_id);
300300+ canonicalize_refs(&mut generated, &lexicon_id);
301301+302302+ let original_stripped = strip_dollar_type(&original);
303303+ let generated_stripped = strip_dollar_type(&generated);
290304291305 if original_stripped == generated_stripped {
292306 return ComparisonResult::Perfect;
···300314 }
301315302316 ComparisonResult::Failure("Structural differences detected".to_string())
317317+}
318318+319319+/// Rewrite every reference string inside `value` to its canonical
320320+/// `authority#defName` form, so the three syntactic variants compare
321321+/// equal:
322322+///
323323+/// * `#foo` → `<lexicon_id>#foo`
324324+/// * `ns.path#foo` → `ns.path#foo` (unchanged)
325325+/// * `ns.path` (bare NSID) → `ns.path#main` (ATProto spec shorthand)
326326+///
327327+/// The walk descends into every value, but only rewrites strings found
328328+/// at well-known ref positions (`{"type":"ref", "ref": ...}` and the
329329+/// `refs` array of a union).
330330+fn canonicalize_refs(value: &mut serde_json::Value, lexicon_id: &str) {
331331+ match value {
332332+ serde_json::Value::Object(obj) => {
333333+ let kind = obj.get("type").and_then(|v| v.as_str()).map(str::to_owned);
334334+ match kind.as_deref() {
335335+ Some("ref") => {
336336+ if let Some(serde_json::Value::String(s)) = obj.get_mut("ref") {
337337+ *s = canonicalize_ref_string(s, lexicon_id);
338338+ }
339339+ }
340340+ Some("union") => {
341341+ if let Some(serde_json::Value::Array(arr)) = obj.get_mut("refs") {
342342+ for item in arr.iter_mut() {
343343+ if let serde_json::Value::String(s) = item {
344344+ *s = canonicalize_ref_string(s, lexicon_id);
345345+ }
346346+ }
347347+ }
348348+ }
349349+ _ => {}
350350+ }
351351+ for (_, v) in obj.iter_mut() {
352352+ canonicalize_refs(v, lexicon_id);
353353+ }
354354+ }
355355+ serde_json::Value::Array(arr) => {
356356+ for v in arr.iter_mut() {
357357+ canonicalize_refs(v, lexicon_id);
358358+ }
359359+ }
360360+ _ => {}
361361+ }
362362+}
363363+364364+fn canonicalize_ref_string(ref_str: &str, lexicon_id: &str) -> String {
365365+ if let Some(fragment) = ref_str.strip_prefix('#') {
366366+ format!("{}#{}", lexicon_id, fragment)
367367+ } else if ref_str.contains('#') {
368368+ ref_str.to_string()
369369+ } else {
370370+ format!("{}#main", ref_str)
371371+ }
303372}
304373305374fn strip_dollar_type(value: &serde_json::Value) -> serde_json::Value {