A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

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

emit canonicalized refs

authored by stavola.xyz and committed by

Tangled 1e59547a 0fbec7bc

+174 -69
+20 -32
mlf-codegen/src/lib.rs
··· 1 1 use mlf_lang::ast::*; 2 - use mlf_lang::Workspace; 2 + use mlf_lang::{ResolvedRef, Workspace}; 3 3 use serde_json::{json, Map, Value}; 4 4 use std::collections::HashMap; 5 5 ··· 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. 415 + /// Resolve a typed reference path to the ATProto NSID string it names. 420 416 /// 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. 417 + /// The three valid output forms — local `#foo`, external `ns.path#foo`, 418 + /// and bare `ns.path` (implicit-main) — map one-to-one onto the 419 + /// [`ResolvedRef`] variants, so there's no guessing about the def name 420 + /// or whether to append a fragment. Unresolvable paths (typically a 421 + /// typo caught elsewhere by validation) fall through to a best-effort 422 + /// rendering so the JSON stays self-describing for tooling. 425 423 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); 424 + match workspace.resolve_ref(path, current_namespace) { 425 + Some(ResolvedRef::Local { def_name }) => format!("#{}", def_name), 426 + Some(ResolvedRef::ImplicitMain { namespace, .. }) => namespace, 427 + Some(ResolvedRef::External { namespace, def_name }) => format!("{}#{}", namespace, def_name), 428 + None => unresolved_ref_fallback(path), 433 429 } 430 + } 434 431 432 + /// Render a reference path the workspace couldn't resolve. Multi- 433 + /// segment paths are emitted as `ns.path#def_name` on the assumption 434 + /// that the author wrote a full NSID; single-segment paths render as a 435 + /// local `#name` fragment. 436 + fn unresolved_ref_fallback(path: &Path) -> String { 435 437 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); 438 + return format!("#{}", path.segments[0].name); 449 439 } 450 - 451 - // Multi-segment unresolved path: trust the author's NSID as written. 452 440 let namespace = path.segments[..path.segments.len() - 1] 453 441 .iter() 454 442 .map(|s| s.name.as_str())
+1 -1
mlf-lang/src/lib.rs
··· 14 14 pub use error::{ParseError, ValidationError, ValidationErrors}; 15 15 pub use parser::parse_lexicon; 16 16 pub use validate::validate_lexicon; 17 - pub use workspace::Workspace; 17 + pub use workspace::{ResolvedRef, Workspace}; 18 18 19 19 // Standard library directory 20 20 use include_dir::{include_dir, Dir};
+82 -34
mlf-lang/src/workspace.rs
··· 8 8 modules: BTreeMap<String, Module>, 9 9 } 10 10 11 + /// How a reference path resolves in the workspace. 12 + /// 13 + /// ATProto names a def with three syntactically distinct forms — a local 14 + /// fragment (`#foo`), an explicit external reference (`ns.path#foo`), and 15 + /// a bare NSID (`ns.path`, shorthand for `#main`). MLF source collapses 16 + /// these into path expressions, and downstream code (codegen, LSP, 17 + /// diagnostics) needs to know which form to emit or display for the 18 + /// canonical output. `ResolvedRef` carries that discrimination so callers 19 + /// don't have to re-derive it from implementation details of the resolver. 20 + #[derive(Debug, Clone, PartialEq, Eq)] 21 + pub enum ResolvedRef { 22 + /// The reference names a def in the same lexicon as the reference 23 + /// site. Codegen emits this as `#def_name`. 24 + Local { def_name: String }, 25 + /// The reference names a def in a different lexicon. Codegen emits 26 + /// this as `namespace#def_name`. 27 + External { namespace: String, def_name: String }, 28 + /// The reference resolves via MLF's implicit-main convention: the 29 + /// whole path names a lexicon whose "main" def is conventionally the 30 + /// def matching the last segment. In ATProto this corresponds to the 31 + /// bare NSID form (`namespace`, equivalent to `namespace#main`). 32 + ImplicitMain { namespace: String, def_name: String }, 33 + } 34 + 11 35 #[derive(Debug, Clone, PartialEq)] 12 36 struct Module { 13 37 namespace: String, ··· 767 791 } 768 792 769 793 /// Resolve a type reference to its actual namespace 770 - /// Returns the namespace where the type is defined, or None if not found 771 - pub fn resolve_reference_namespace(&self, path: &Path, current_namespace: &str) -> Option<String> { 794 + /// Resolve a reference path (evaluated from inside `current_namespace`) 795 + /// to the lexicon and def it names. Returns a structured 796 + /// [`ResolvedRef`] so callers can distinguish local, external, and 797 + /// implicit-main references without re-deriving the resolution 798 + /// decision from a bare namespace string. 799 + pub fn resolve_ref(&self, path: &Path, current_namespace: &str) -> Option<ResolvedRef> { 772 800 if path.segments.len() == 1 { 773 801 let name = &path.segments[0].name; 774 802 775 - // Check current module first 776 803 if let Some(module) = self.modules.get(current_namespace) { 777 - // Check if it's a local type 778 804 if module.symbols.types.contains_key(name) { 779 - return Some(current_namespace.to_string()); 805 + return Some(ResolvedRef::Local { def_name: name.clone() }); 780 806 } 781 807 782 - // Check if it's imported 783 808 if let Some(imported) = module.imports.mappings.get(name) { 784 - // Build the full namespace from the original path 785 - // original_path is Vec<String> like ["place", "stream", "key", "key"] 786 - // We want "place.stream.key" (drop the last segment which is the type name) 809 + // `original_path` is the fully-qualified import path 810 + // (e.g. `["com", "atproto", "label", "defs", "label"]`). 811 + // All but the last segment form the namespace. 787 812 if imported.original_path.len() > 1 { 788 813 let namespace = imported.original_path[..imported.original_path.len() - 1].join("."); 789 - return Some(namespace); 814 + let def_name = imported.original_path.last().unwrap().clone(); 815 + return Some(ResolvedRef::External { namespace, def_name }); 790 816 } else { 791 - // Edge case: just a single segment, assume it's in the current namespace 792 - return Some(current_namespace.to_string()); 817 + // Pathological single-segment import: treat as local. 818 + return Some(ResolvedRef::Local { def_name: name.clone() }); 793 819 } 794 820 } 795 821 } 796 822 797 - // Check prelude 798 823 if let Some(module) = self.modules.get("prelude") { 799 824 if module.symbols.types.contains_key(name) { 800 - return Some("prelude".to_string()); 825 + return Some(ResolvedRef::External { 826 + namespace: "prelude".to_string(), 827 + def_name: name.clone(), 828 + }); 801 829 } 802 830 } 803 831 804 832 return None; 805 - } else { 806 - // Multi-segment path: resolve normally 807 - let target_namespace = path.segments[..path.segments.len() - 1] 808 - .iter() 809 - .map(|s| s.name.as_str()) 810 - .collect::<Vec<_>>() 811 - .join("."); 812 - let type_name = &path.segments[path.segments.len() - 1].name; 833 + } 813 834 814 - // First try: normal resolution 815 - if let Some(module) = self.modules.get(&target_namespace) { 816 - if module.symbols.types.contains_key(type_name) { 817 - return Some(target_namespace); 835 + // Multi-segment path: try the explicit `namespace.def_name` form first. 836 + let target_namespace = path.segments[..path.segments.len() - 1] 837 + .iter() 838 + .map(|s| s.name.as_str()) 839 + .collect::<Vec<_>>() 840 + .join("."); 841 + let type_name = &path.segments[path.segments.len() - 1].name; 842 + 843 + if let Some(module) = self.modules.get(&target_namespace) { 844 + if module.symbols.types.contains_key(type_name) { 845 + let namespace = target_namespace; 846 + if namespace == current_namespace { 847 + return Some(ResolvedRef::Local { def_name: type_name.clone() }); 818 848 } 849 + return Some(ResolvedRef::External { namespace, def_name: type_name.clone() }); 819 850 } 851 + } 820 852 821 - // Second try: implicit main resolution 822 - let full_namespace = path.to_string(); 823 - if let Some(module) = self.modules.get(&full_namespace) { 824 - let namespace_suffix = full_namespace.split('.').last().unwrap_or(&full_namespace); 825 - if namespace_suffix == type_name && module.symbols.types.contains_key(type_name) { 826 - return Some(full_namespace); 827 - } 853 + // Fall back to the implicit-main convention: the whole path names a 854 + // lexicon whose "main" def is conventionally the def matching the 855 + // last segment. In ATProto this corresponds to a bare NSID. 856 + let full_namespace = path.to_string(); 857 + if let Some(module) = self.modules.get(&full_namespace) { 858 + let namespace_suffix = full_namespace.split('.').last().unwrap_or(&full_namespace); 859 + if namespace_suffix == type_name && module.symbols.types.contains_key(type_name) { 860 + return Some(ResolvedRef::ImplicitMain { 861 + namespace: full_namespace, 862 + def_name: type_name.clone(), 863 + }); 828 864 } 865 + } 829 866 830 - None 867 + None 868 + } 869 + 870 + /// Return only the resolved lexicon namespace for a reference path. 871 + /// Thin shim over [`Self::resolve_ref`]; prefer that for new call 872 + /// sites where the variant (local vs external vs implicit-main) 873 + /// matters. 874 + pub fn resolve_reference_namespace(&self, path: &Path, current_namespace: &str) -> Option<String> { 875 + match self.resolve_ref(path, current_namespace)? { 876 + ResolvedRef::Local { .. } => Some(current_namespace.to_string()), 877 + ResolvedRef::External { namespace, .. } 878 + | ResolvedRef::ImplicitMain { namespace, .. } => Some(namespace), 831 879 } 832 880 } 833 881
+71 -2
tests/real_world/roundtrip.rs
··· 285 285 ) -> ComparisonResult { 286 286 let mut acceptable_diffs = Vec::new(); 287 287 288 - let original_stripped = strip_dollar_type(original); 289 - let generated_stripped = strip_dollar_type(generated); 288 + // Lexicon authors use any of three syntactic forms for references 289 + // (`#foo`, `ns.path#foo`, bare `ns.path` for implicit-main), so we 290 + // rewrite both sides to a single canonical form before comparing. 291 + // This folds away C7/C8-style diffs that are purely stylistic. 292 + let lexicon_id = original 293 + .get("id") 294 + .and_then(|v| v.as_str()) 295 + .unwrap_or("") 296 + .to_string(); 297 + let mut original = original.clone(); 298 + let mut generated = generated.clone(); 299 + canonicalize_refs(&mut original, &lexicon_id); 300 + canonicalize_refs(&mut generated, &lexicon_id); 301 + 302 + let original_stripped = strip_dollar_type(&original); 303 + let generated_stripped = strip_dollar_type(&generated); 290 304 291 305 if original_stripped == generated_stripped { 292 306 return ComparisonResult::Perfect; ··· 300 314 } 301 315 302 316 ComparisonResult::Failure("Structural differences detected".to_string()) 317 + } 318 + 319 + /// Rewrite every reference string inside `value` to its canonical 320 + /// `authority#defName` form, so the three syntactic variants compare 321 + /// equal: 322 + /// 323 + /// * `#foo` → `<lexicon_id>#foo` 324 + /// * `ns.path#foo` → `ns.path#foo` (unchanged) 325 + /// * `ns.path` (bare NSID) → `ns.path#main` (ATProto spec shorthand) 326 + /// 327 + /// The walk descends into every value, but only rewrites strings found 328 + /// at well-known ref positions (`{"type":"ref", "ref": ...}` and the 329 + /// `refs` array of a union). 330 + fn canonicalize_refs(value: &mut serde_json::Value, lexicon_id: &str) { 331 + match value { 332 + serde_json::Value::Object(obj) => { 333 + let kind = obj.get("type").and_then(|v| v.as_str()).map(str::to_owned); 334 + match kind.as_deref() { 335 + Some("ref") => { 336 + if let Some(serde_json::Value::String(s)) = obj.get_mut("ref") { 337 + *s = canonicalize_ref_string(s, lexicon_id); 338 + } 339 + } 340 + Some("union") => { 341 + if let Some(serde_json::Value::Array(arr)) = obj.get_mut("refs") { 342 + for item in arr.iter_mut() { 343 + if let serde_json::Value::String(s) = item { 344 + *s = canonicalize_ref_string(s, lexicon_id); 345 + } 346 + } 347 + } 348 + } 349 + _ => {} 350 + } 351 + for (_, v) in obj.iter_mut() { 352 + canonicalize_refs(v, lexicon_id); 353 + } 354 + } 355 + serde_json::Value::Array(arr) => { 356 + for v in arr.iter_mut() { 357 + canonicalize_refs(v, lexicon_id); 358 + } 359 + } 360 + _ => {} 361 + } 362 + } 363 + 364 + fn canonicalize_ref_string(ref_str: &str, lexicon_id: &str) -> String { 365 + if let Some(fragment) = ref_str.strip_prefix('#') { 366 + format!("{}#{}", lexicon_id, fragment) 367 + } else if ref_str.contains('#') { 368 + ref_str.to_string() 369 + } else { 370 + format!("{}#main", ref_str) 371 + } 303 372 } 304 373 305 374 fn strip_dollar_type(value: &serde_json::Value) -> serde_json::Value {