A human-friendly DSL for ATProto Lexicons
27
fork

Configure Feed

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

Don't emit empty `required` or `description` properties

authored by stavola.xyz and committed by

Tangled 1ad33d36 d682d09a

+113 -120
+113 -110
mlf-codegen/src/lib.rs
··· 334 334 .join("\n") 335 335 } 336 336 337 + ///// Insert `key: value` into `map` only when `value` is non-empty. Several 338 + /// ATProto fields (`description`, per-type `format`, etc.) are optional and 339 + /// should be omitted when empty — emitting `""` causes spurious roundtrip 340 + /// diffs against authoritative lexicons. 341 + fn insert_opt_str(map: &mut Map<String, Value>, key: &str, value: &str) { 342 + if !value.is_empty() { 343 + map.insert(key.to_string(), Value::String(value.to_string())); 344 + } 345 + } 346 + 347 + /// Insert `key: values` only when the slice is non-empty. Mirrors the 348 + /// ATProto convention for `required`, `enum`, and similar list fields. 349 + fn insert_opt_list(map: &mut Map<String, Value>, key: &str, values: &[String]) { 350 + if !values.is_empty() { 351 + map.insert(key.to_string(), json!(values)); 352 + } 353 + } 354 + 355 + /// If `docs` render to a non-empty string, insert it as the `description` 356 + /// field of `obj` (which is expected to be a JSON object). No-op for 357 + /// non-object values or empty doc sets. 358 + fn add_description_from_docs(obj: &mut Value, docs: &[DocComment]) { 359 + let text = extract_docs(docs); 360 + if text.is_empty() { 361 + return; 362 + } 363 + if let Some(map) = obj.as_object_mut() { 364 + map.insert("description".to_string(), Value::String(text)); 365 + } 366 + } 367 + 368 + /// Materialise a query/procedure/subscription parameter list into 369 + /// `(properties, required)`. Each parameter is emitted through 370 + /// [`generate_type_json`], annotated with its doc comment, and collected 371 + /// into `properties`; the names of non-optional parameters are collected 372 + /// into `required` in source order. 373 + fn build_param_properties( 374 + params: &[Field], 375 + usage_counts: &HashMap<String, usize>, 376 + workspace: &Workspace, 377 + current_namespace: &str, 378 + ) -> (Map<String, Value>, Vec<String>) { 379 + let mut properties = Map::new(); 380 + let mut required = Vec::new(); 381 + for param in params { 382 + if !param.optional { 383 + required.push(param.name.name.clone()); 384 + } 385 + let mut param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 386 + add_description_from_docs(&mut param_json, &param.docs); 387 + properties.insert(param.name.name.clone(), param_json); 388 + } 389 + (properties, required) 390 + } 391 + 392 + /// Build a `type: "params"` JSON object from the output of 393 + /// [`build_param_properties`]. `required` is omitted when empty, and an 394 + /// empty `properties` object is still emitted (queries always carry an 395 + /// empty `parameters.properties` per ATProto convention). 396 + fn build_params_object(properties: Map<String, Value>, required: &[String]) -> Value { 397 + let mut obj = Map::new(); 398 + obj.insert("type".to_string(), json!("params")); 399 + insert_opt_list(&mut obj, "required", required); 400 + obj.insert("properties".to_string(), Value::Object(properties)); 401 + Value::Object(obj) 402 + } 403 + 404 + /// Like [`build_params_object`] but returns `None` when there are no 405 + /// parameters — used by subscriptions, where the entire `parameters` 406 + /// field is elided rather than emitted as an empty object. 407 + fn build_params_object_opt(properties: Map<String, Value>, required: &[String]) -> Option<Value> { 408 + if properties.is_empty() { 409 + None 410 + } else { 411 + Some(build_params_object(properties, required)) 412 + } 413 + } 414 + 337 415 fn generate_record_json(record: &Record, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 338 416 let mut required = Vec::new(); 339 417 let mut properties = Map::new(); ··· 342 420 if !field.optional { 343 421 required.push(field.name.name.clone()); 344 422 } 345 - 346 423 let mut field_json = generate_type_json(&field.ty, usage_counts, workspace, current_namespace); 347 - // Add description if the field has doc comments 348 - if !field.docs.is_empty() { 349 - if let Some(obj) = field_json.as_object_mut() { 350 - obj.insert("description".to_string(), json!(extract_docs(&field.docs))); 351 - } 352 - } 424 + add_description_from_docs(&mut field_json, &field.docs); 353 425 properties.insert(field.name.name.clone(), field_json); 354 426 } 355 427 356 - let record_obj = json!({ 357 - "type": "object", 358 - "required": required, 359 - "properties": properties 360 - }); 428 + let mut record_obj = Map::new(); 429 + record_obj.insert("type".to_string(), json!("object")); 430 + insert_opt_list(&mut record_obj, "required", &required); 431 + record_obj.insert("properties".to_string(), Value::Object(properties)); 361 432 362 433 // Check for @key annotation, default to "tid" 363 434 let key = get_annotation_string_value(&record.annotations, "key").unwrap_or_else(|| "tid".to_string()); 364 435 365 - json!({ 366 - "type": "record", 367 - "description": extract_docs(&record.docs), 368 - "key": key, 369 - "record": record_obj 370 - }) 436 + let mut record_top = Map::new(); 437 + record_top.insert("type".to_string(), json!("record")); 438 + insert_opt_str(&mut record_top, "description", &extract_docs(&record.docs)); 439 + record_top.insert("key".to_string(), Value::String(key)); 440 + record_top.insert("record".to_string(), Value::Object(record_obj)); 441 + Value::Object(record_top) 371 442 } 372 443 373 444 fn generate_query_json(query: &Query, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 374 - let mut params_properties = Map::new(); 375 - let mut params_required = Vec::new(); 376 - 377 - for param in &query.params { 378 - if !param.optional { 379 - params_required.push(param.name.name.clone()); 380 - } 381 - let mut param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 382 - // Add description if the parameter has doc comments 383 - if !param.docs.is_empty() { 384 - if let Some(obj) = param_json.as_object_mut() { 385 - obj.insert("description".to_string(), json!(extract_docs(&param.docs))); 386 - } 387 - } 388 - params_properties.insert(param.name.name.clone(), param_json); 389 - } 390 - 391 - let params = if !params_properties.is_empty() { 392 - let mut params_obj = Map::new(); 393 - params_obj.insert("type".to_string(), json!("params")); 394 - params_obj.insert("required".to_string(), json!(params_required)); 395 - params_obj.insert("properties".to_string(), json!(params_properties)); 396 - Value::Object(params_obj) 397 - } else { 398 - let mut params_obj = Map::new(); 399 - params_obj.insert("type".to_string(), json!("params")); 400 - params_obj.insert("properties".to_string(), json!({})); 401 - Value::Object(params_obj) 402 - }; 445 + let (params_properties, params_required) = 446 + build_param_properties(&query.params, usage_counts, workspace, current_namespace); 447 + let params = build_params_object(params_properties, &params_required); 403 448 404 449 // Check for @encoding annotation (output only for queries), default to "application/json" 405 450 let output_encoding = get_encoding_annotation(&query.annotations, "output") ··· 437 482 438 483 let mut query_obj = Map::new(); 439 484 query_obj.insert("type".to_string(), json!("query")); 440 - query_obj.insert("description".to_string(), json!(extract_docs(&query.docs))); 485 + insert_opt_str(&mut query_obj, "description", &extract_docs(&query.docs)); 441 486 query_obj.insert("parameters".to_string(), params); 442 487 if let Some(output_val) = output { 443 488 query_obj.insert("output".to_string(), output_val); ··· 449 494 } 450 495 451 496 fn generate_procedure_json(procedure: &Procedure, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 452 - let mut params_properties = Map::new(); 453 - let mut params_required = Vec::new(); 454 - 455 - for param in &procedure.params { 456 - if !param.optional { 457 - params_required.push(param.name.name.clone()); 458 - } 459 - let mut param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 460 - // Add description if the parameter has doc comments 461 - if !param.docs.is_empty() { 462 - if let Some(obj) = param_json.as_object_mut() { 463 - obj.insert("description".to_string(), json!(extract_docs(&param.docs))); 464 - } 465 - } 466 - params_properties.insert(param.name.name.clone(), param_json); 467 - } 497 + let (params_properties, params_required) = 498 + build_param_properties(&procedure.params, usage_counts, workspace, current_namespace); 468 499 469 500 // Check for @encoding annotation with "input" parameter, default to "application/json" 470 501 let input_encoding = get_encoding_annotation(&procedure.annotations, "input") ··· 473 504 let input = if !params_properties.is_empty() { 474 505 let mut schema_obj = Map::new(); 475 506 schema_obj.insert("type".to_string(), json!("object")); 476 - schema_obj.insert("required".to_string(), json!(params_required)); 477 - schema_obj.insert("properties".to_string(), json!(params_properties)); 507 + insert_opt_list(&mut schema_obj, "required", &params_required); 508 + schema_obj.insert("properties".to_string(), Value::Object(params_properties)); 478 509 479 510 let mut input_obj = Map::new(); 480 511 input_obj.insert("encoding".to_string(), json!(input_encoding)); ··· 520 551 521 552 let mut result = Map::new(); 522 553 result.insert("type".to_string(), json!("procedure")); 523 - result.insert("description".to_string(), json!(extract_docs(&procedure.docs))); 554 + insert_opt_str(&mut result, "description", &extract_docs(&procedure.docs)); 524 555 if let Some(input_val) = input { 525 556 result.insert("input".to_string(), input_val); 526 557 } ··· 539 570 workspace: &Workspace, 540 571 current_namespace: &str, 541 572 ) -> Value { 542 - let mut params_properties = Map::new(); 543 - let mut params_required = Vec::new(); 573 + let (params_properties, params_required) = 574 + build_param_properties(&subscription.params, usage_counts, workspace, current_namespace); 544 575 545 - for param in &subscription.params { 546 - if !param.optional { 547 - params_required.push(param.name.name.clone()); 548 - } 549 - let mut param_json = generate_type_json(&param.ty, usage_counts, workspace, current_namespace); 550 - // Add description if the parameter has doc comments 551 - if !param.docs.is_empty() { 552 - if let Some(obj) = param_json.as_object_mut() { 553 - obj.insert("description".to_string(), json!(extract_docs(&param.docs))); 554 - } 555 - } 556 - params_properties.insert(param.name.name.clone(), param_json); 557 - } 558 - 559 - let parameters = if !params_properties.is_empty() { 560 - json!({ 561 - "type": "params", 562 - "required": params_required, 563 - "properties": params_properties 564 - }) 565 - } else { 566 - Value::Null 567 - }; 568 - 569 - let mut result = json!({ 570 - "type": "subscription", 571 - "description": extract_docs(&subscription.docs) 572 - }); 576 + let mut result = Map::new(); 577 + result.insert("type".to_string(), json!("subscription")); 578 + insert_opt_str(&mut result, "description", &extract_docs(&subscription.docs)); 579 + let mut result = Value::Object(result); 573 580 574 581 if let Some(messages) = &subscription.messages { 575 582 let message = json!({ ··· 578 585 result["message"] = message; 579 586 } 580 587 581 - if !parameters.is_null() { 588 + if let Some(parameters) = build_params_object_opt(params_properties, &params_required) { 582 589 result["parameters"] = parameters; 583 590 } 584 591 ··· 587 594 588 595 fn generate_def_type_json(def_type: &DefType, usage_counts: &HashMap<String, usize>, workspace: &Workspace, current_namespace: &str) -> Value { 589 596 let mut field_json = generate_type_json(&def_type.ty, usage_counts, workspace, current_namespace); 590 - // Add description if the type definition has doc comments 591 - if !def_type.docs.is_empty() { 597 + // def types insert `description` at position 1 (right after `type`) to 598 + // match the canonical field order in published lexicons. 599 + let text = extract_docs(&def_type.docs); 600 + if !text.is_empty() { 592 601 if let Some(obj) = field_json.as_object_mut() { 593 - obj.shift_insert(1, "description".to_string(), json!(extract_docs(&def_type.docs))); 602 + obj.shift_insert(1, "description".to_string(), Value::String(text)); 594 603 } 595 604 } 596 605 field_json ··· 698 707 Type::Object { fields, .. } => { 699 708 let mut required = Vec::new(); 700 709 let mut properties = Map::new(); 701 - 702 710 for field in fields { 703 711 if !field.optional { 704 712 required.push(field.name.name.clone()); 705 713 } 706 714 let mut field_json = generate_type_json(&field.ty, usage_counts, workspace, current_namespace); 707 - // Add description if the field has doc comments 708 - if !field.docs.is_empty() { 709 - if let Some(obj) = field_json.as_object_mut() { 710 - obj.insert("description".to_string(), json!(extract_docs(&field.docs))); 711 - } 712 - } 715 + add_description_from_docs(&mut field_json, &field.docs); 713 716 properties.insert(field.name.name.clone(), field_json); 714 717 } 715 718 716 719 let mut obj = Map::new(); 717 720 obj.insert("type".to_string(), json!("object")); 718 - obj.insert("required".to_string(), json!(required)); 719 - obj.insert("properties".to_string(), json!(properties)); 721 + insert_opt_list(&mut obj, "required", &required); 722 + obj.insert("properties".to_string(), Value::Object(properties)); 720 723 Value::Object(obj) 721 724 } 722 725 Type::Parenthesized { inner, .. } => {
-1
tests/codegen/lexicon/annotations/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "custom-key", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/basic_record/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "tid", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/implicit_main/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "tid", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/integer_constraints/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "tid", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/local_references/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "tid", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/nested_objects/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "tid", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/record_key_any/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "any", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/record_key_literal/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "literal:self", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/string_constraints/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "tid", 10 9 "record": { 11 10 "type": "object",
-1
tests/codegen/lexicon/union_types/expected.json
··· 5 5 "defs": { 6 6 "main": { 7 7 "type": "record", 8 - "description": "", 9 8 "key": "tid", 10 9 "record": { 11 10 "type": "object",