A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

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

Lower T | null to nullable

authored by stavola.xyz and committed by

Tangled 56a85c7b 340904f7

+85 -21
+85 -21
mlf-codegen/src/lib.rs
··· 378 378 (properties, required) 379 379 } 380 380 381 + /// Shared iteration for records and inline object types. Returns 382 + /// `(properties, required, nullable)`: 383 + /// 384 + /// * `properties` — field-name → emitted JSON type, in declaration order. 385 + /// * `required` — non-optional field names. 386 + /// * `nullable` — fields whose MLF type is `T | null` (ATProto spec encodes 387 + /// nullability as a sibling `nullable` array on the enclosing object, 388 + /// not as a union member). The null is stripped from the emitted type. 389 + fn collect_object_fields( 390 + fields: &[Field], 391 + usage_counts: &HashMap<String, usize>, 392 + workspace: &Workspace, 393 + current_namespace: &str, 394 + ) -> (Map<String, Value>, Vec<String>, Vec<String>) { 395 + let mut properties = Map::new(); 396 + let mut required = Vec::new(); 397 + let mut nullable = Vec::new(); 398 + for field in fields { 399 + if !field.optional { 400 + required.push(field.name.name.clone()); 401 + } 402 + let (effective_ty, is_nullable) = match strip_nullable(&field.ty) { 403 + Some(stripped) => (stripped, true), 404 + None => (field.ty.clone(), false), 405 + }; 406 + if is_nullable { 407 + nullable.push(field.name.name.clone()); 408 + } 409 + let mut field_json = generate_type_json(&effective_ty, usage_counts, workspace, current_namespace); 410 + add_description_from_docs(&mut field_json, &field.docs); 411 + properties.insert(field.name.name.clone(), field_json); 412 + } 413 + (properties, required, nullable) 414 + } 415 + 416 + /// If `ty` is a union that includes a `null` primitive, return the type 417 + /// with the null stripped — the ATProto-side lowering of MLF's `T | null` 418 + /// pattern, which is how we express field nullability in the surface 419 + /// language. Returns `None` when `ty` isn't a nullable union. 420 + /// 421 + /// Unwraps parenthesized types transparently so `(T | null)` behaves the 422 + /// same as `T | null`. Collapses a single-remaining-member union to just 423 + /// that member. 424 + fn strip_nullable(ty: &Type) -> Option<Type> { 425 + if let Type::Parenthesized { inner, .. } = ty { 426 + return strip_nullable(inner); 427 + } 428 + let Type::Union { types, closed, span } = ty else { 429 + return None; 430 + }; 431 + let has_null = types.iter().any(is_null_primitive); 432 + if !has_null { 433 + return None; 434 + } 435 + let remaining: Vec<Type> = types 436 + .iter() 437 + .filter(|t| !is_null_primitive(t)) 438 + .cloned() 439 + .collect(); 440 + // A single-member union degenerates to the member itself — the same 441 + // invariant our Lexicon→MLF pass maintains, kept symmetric here. 442 + if remaining.len() == 1 { 443 + return Some(remaining.into_iter().next().unwrap()); 444 + } 445 + Some(Type::Union { 446 + types: remaining, 447 + closed: *closed, 448 + span: *span, 449 + }) 450 + } 451 + 452 + fn is_null_primitive(ty: &Type) -> bool { 453 + match ty { 454 + Type::Primitive { kind: PrimitiveType::Null, .. } => true, 455 + Type::Parenthesized { inner, .. } => is_null_primitive(inner), 456 + _ => false, 457 + } 458 + } 459 + 381 460 /// Build a `type: "params"` JSON object from the output of 382 461 /// [`build_param_properties`]. `required` is omitted when empty, and an 383 462 /// empty `properties` object is still emitted (queries always carry an ··· 436 515 } 437 516 438 517 fn generate_record_json(record: &Record, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 439 - let mut required = Vec::new(); 440 - let mut properties = Map::new(); 441 - 442 - for field in &record.fields { 443 - if !field.optional { 444 - required.push(field.name.name.clone()); 445 - } 446 - let mut field_json = generate_type_json(&field.ty, usage_counts, workspace, current_namespace); 447 - add_description_from_docs(&mut field_json, &field.docs); 448 - properties.insert(field.name.name.clone(), field_json); 449 - } 518 + let (properties, required, nullable) = 519 + collect_object_fields(&record.fields, usage_counts, workspace, current_namespace); 450 520 451 521 let mut record_obj = Map::new(); 452 522 record_obj.insert("type".to_string(), json!("object")); 453 523 insert_opt_list(&mut record_obj, "required", &required); 524 + insert_opt_list(&mut record_obj, "nullable", &nullable); 454 525 record_obj.insert("properties".to_string(), Value::Object(properties)); 455 526 456 527 // Check for @key annotation, default to "tid" ··· 674 745 Value::Object(union_obj) 675 746 } 676 747 Type::Object { fields, .. } => { 677 - let mut required = Vec::new(); 678 - let mut properties = Map::new(); 679 - for field in fields { 680 - if !field.optional { 681 - required.push(field.name.name.clone()); 682 - } 683 - let mut field_json = generate_type_json(&field.ty, usage_counts, workspace, current_namespace); 684 - add_description_from_docs(&mut field_json, &field.docs); 685 - properties.insert(field.name.name.clone(), field_json); 686 - } 748 + let (properties, required, nullable) = 749 + collect_object_fields(fields, usage_counts, workspace, current_namespace); 687 750 688 751 let mut obj = Map::new(); 689 752 obj.insert("type".to_string(), json!("object")); 690 753 insert_opt_list(&mut obj, "required", &required); 754 + insert_opt_list(&mut obj, "nullable", &nullable); 691 755 obj.insert("properties".to_string(), Value::Object(properties)); 692 756 Value::Object(obj) 693 757 }