An experimental TypeSpec syntax for Lexicon
at cli 1569 lines 48 kB view raw
1import { 2 Program, 3 Type, 4 Model, 5 ModelProperty, 6 Scalar, 7 Union, 8 Namespace, 9 StringLiteral, 10 NumericLiteral, 11 BooleanLiteral, 12 IntrinsicType, 13 ArrayValue, 14 StringValue, 15 IndeterminateEntity, 16 getDoc, 17 getNamespaceFullName, 18 isTemplateInstance, 19 isType, 20 getMaxLength, 21 getMinLength, 22 getMinValue, 23 getMaxValue, 24 getMaxItems, 25 getMinItems, 26 isArrayModelType, 27 serializeValueAsJson, 28 Operation, 29} from "@typespec/compiler"; 30import { join, dirname } from "path"; 31import type { 32 LexiconDoc, 33 LexObject, 34 LexArray, 35 LexBlob, 36 LexXrpcQuery, 37 LexXrpcProcedure, 38 LexXrpcSubscription, 39 LexObjectProperty, 40 LexArrayItem, 41 LexXrpcParameterProperty, 42 LexRefUnion, 43 LexUserType, 44 LexRecord, 45 LexXrpcBody, 46 LexXrpcParameters, 47 LexBytes, 48 LexCidLink, 49 LexRefVariant, 50 LexToken, 51} from "./types.js"; 52 53import { 54 getMaxGraphemes, 55 getMinGraphemes, 56 getRecordKey, 57 isRequired, 58 isReadOnly, 59 isToken, 60 isClosed, 61 isQuery, 62 isProcedure, 63 isSubscription, 64 getErrors, 65 isErrorModel, 66 getEncoding, 67 isInline, 68 getMaxBytes, 69 getMinBytes, 70 isExternal, 71} from "./decorators.js"; 72 73export interface EmitterOptions { 74 outputDir: string; 75} 76 77// Constants for string format scalars (type: "string" with format field) 78const STRING_FORMAT_MAP: Record<string, string> = { 79 did: "did", 80 handle: "handle", 81 atUri: "at-uri", 82 datetime: "datetime", 83 cid: "cid", 84 tid: "tid", 85 nsid: "nsid", 86 recordKey: "record-key", 87 uri: "uri", 88 language: "language", 89 atIdentifier: "at-identifier", 90}; 91 92export class TypelexEmitter { 93 private lexicons = new Map<string, LexiconDoc>(); 94 private currentLexiconId: string | null = null; 95 96 constructor( 97 private program: Program, 98 private options: EmitterOptions, 99 ) {} 100 101 async emit() { 102 const globalNs = this.program.getGlobalNamespaceType(); 103 104 // Process all namespaces to find models and operations 105 this.processNamespace(globalNs); 106 107 // Write all lexicon files 108 for (const [id, lexicon] of this.lexicons) { 109 const filePath = this.getLexiconPath(id); 110 await this.writeFile(filePath, JSON.stringify(lexicon, null, 2) + "\n"); 111 } 112 } 113 114 private processNamespace(ns: Namespace) { 115 const fullName = getNamespaceFullName(ns); 116 117 // Skip TypeSpec internal namespaces 118 if (!fullName || fullName.startsWith("TypeSpec")) { 119 for (const [_, childNs] of ns.namespaces) { 120 this.processNamespace(childNs); 121 } 122 return; 123 } 124 125 // Skip external namespaces - they don't emit JSON files 126 if (isExternal(this.program, ns)) { 127 // Validate that all models in external namespaces are empty (stub-only) 128 for (const [_, model] of ns.models) { 129 if (model.properties && model.properties.size > 0) { 130 this.program.reportDiagnostic({ 131 code: "external-model-not-empty", 132 severity: "error", 133 message: `Models in @external namespaces must be empty stubs. Model '${model.name}' in namespace '${fullName}' has properties.`, 134 target: model, 135 }); 136 } 137 } 138 return; 139 } 140 141 // Check for TypeSpec enum syntax and throw error 142 if (ns.enums && ns.enums.size > 0) { 143 for (const [_, enumType] of ns.enums) { 144 this.program.reportDiagnostic({ 145 code: "enum-not-supported", 146 severity: "error", 147 message: 148 "TypeSpec enum syntax is not supported. Use @closed @inline union instead.", 149 target: enumType, 150 }); 151 } 152 } 153 154 const namespaceType = this.classifyNamespace(ns); 155 156 switch (namespaceType) { 157 case "operation": 158 this.emitOperationLexicon(ns, fullName); 159 break; 160 case "content": 161 this.emitContentLexicon(ns, fullName); 162 break; 163 case "defs": 164 this.emitDefsLexicon(ns, fullName); 165 break; 166 case "empty": 167 // Empty namespace, skip 168 break; 169 } 170 171 // Recursively process child namespaces 172 for (const [_, childNs] of ns.namespaces) { 173 this.processNamespace(childNs); 174 } 175 } 176 177 private classifyNamespace( 178 ns: Namespace, 179 ): "operation" | "content" | "defs" | "empty" { 180 const hasModels = ns.models.size > 0; 181 const hasScalars = ns.scalars.size > 0; 182 const hasUnions = ns.unions?.size > 0; 183 const hasOperations = ns.operations?.size > 0; 184 const hasContent = hasModels || hasScalars || hasUnions; 185 186 if (hasOperations) { 187 return "operation"; 188 } 189 190 if (hasContent) { 191 return "content"; 192 } 193 194 return "empty"; 195 } 196 197 private emitContentLexicon(ns: Namespace, fullName: string) { 198 const models = [...ns.models.values()]; 199 const isDefsFile = fullName.endsWith(".defs"); 200 const mainModel = isDefsFile ? null : models.find((m) => m.name === "Main"); 201 202 if (!isDefsFile && !mainModel) { 203 this.program.reportDiagnostic({ 204 code: "missing-main-model", 205 severity: "error", 206 message: 207 `Namespace "${fullName}" has models/scalars but no Main model. ` + 208 `Either add a "model Main" or rename namespace to "${fullName}.defs" for shared definitions.`, 209 target: ns, 210 }); 211 return; 212 } 213 214 this.currentLexiconId = fullName; 215 const lexicon = this.createLexicon(fullName, ns); 216 217 if (mainModel) { 218 lexicon.defs.main = this.createMainDef(mainModel); 219 this.addDefs( 220 lexicon, 221 ns, 222 models.filter((m) => m.name !== "Main"), 223 ); 224 } else { 225 this.addDefs(lexicon, ns, models); 226 } 227 228 this.lexicons.set(fullName, lexicon); 229 this.currentLexiconId = null; 230 } 231 232 private emitDefsLexicon(ns: Namespace, fullName: string) { 233 const lexiconId = fullName.endsWith(".defs") 234 ? fullName 235 : fullName + ".defs"; 236 this.currentLexiconId = lexiconId; 237 const lexicon = this.createLexicon(lexiconId, ns); 238 this.addDefs(lexicon, ns, [...ns.models.values()]); 239 this.lexicons.set(lexiconId, lexicon); 240 this.currentLexiconId = null; 241 } 242 243 private emitOperationLexicon(ns: Namespace, fullName: string) { 244 this.currentLexiconId = fullName; 245 const lexicon = this.createLexicon(fullName, ns); 246 247 const mainOp = [...ns.operations].find( 248 ([name]) => name === "main" || name === "Main", 249 )?.[1]; 250 251 if (mainOp) { 252 this.addOperationToDefs(lexicon, mainOp, "main"); 253 } 254 255 for (const [name, operation] of ns.operations) { 256 if (name !== "main" && name !== "Main") { 257 this.addOperationToDefs(lexicon, operation, name); 258 } 259 } 260 261 this.addDefs( 262 lexicon, 263 ns, 264 [...ns.models.values()].filter((m) => m.name !== "Main"), 265 ); 266 this.lexicons.set(fullName, lexicon); 267 this.currentLexiconId = null; 268 } 269 270 private createLexicon(id: string, ns: Namespace): LexiconDoc { 271 const description = getDoc(this.program, ns); 272 return { 273 lexicon: 1, 274 id, 275 defs: {}, 276 ...(description && { description }), 277 }; 278 } 279 280 private createMainDef(mainModel: Model): LexRecord | LexObject | LexToken { 281 const modelDescription = getDoc(this.program, mainModel); 282 283 // Check if this is a token type 284 if (isToken(this.program, mainModel)) { 285 return { 286 type: "token", 287 description: modelDescription, 288 }; 289 } 290 291 const recordKey = getRecordKey(this.program, mainModel); 292 const modelDef = this.modelToLexiconObject(mainModel, !!modelDescription); 293 294 if (recordKey) { 295 const recordDef: LexRecord = { 296 type: "record", 297 key: recordKey, 298 record: modelDef, 299 }; 300 if (modelDescription) { 301 recordDef.description = modelDescription; 302 delete modelDef.description; 303 } 304 return recordDef; 305 } 306 307 return modelDef; 308 } 309 310 private addDefs(lexicon: LexiconDoc, ns: Namespace, models: Model[]) { 311 for (const model of models) { 312 this.addModelToDefs(lexicon, model); 313 } 314 for (const [_, scalar] of ns.scalars) { 315 this.addScalarToDefs(lexicon, scalar); 316 } 317 if (ns.unions) { 318 for (const [_, union] of ns.unions) { 319 this.addUnionToDefs(lexicon, union); 320 } 321 } 322 } 323 324 private addModelToDefs(lexicon: LexiconDoc, model: Model) { 325 if (model.name[0] !== model.name[0].toUpperCase()) { 326 this.program.reportDiagnostic({ 327 code: "invalid-model-name", 328 severity: "error", 329 message: `Model name "${model.name}" must use PascalCase. Did you mean "${model.name[0].toUpperCase() + model.name.slice(1)}"?`, 330 target: model, 331 }); 332 return; 333 } 334 335 if (isErrorModel(this.program, model)) return; 336 if (isInline(this.program, model)) return; 337 338 const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 339 const description = getDoc(this.program, model); 340 341 if (isToken(this.program, model)) { 342 lexicon.defs[defName] = { type: "token", description }; 343 return; 344 } 345 346 if (isArrayModelType(this.program, model)) { 347 const arrayDef = this.modelToLexiconArray(model); 348 if (arrayDef) { 349 lexicon.defs[defName] = { ...arrayDef, description }; 350 return; 351 } 352 } 353 354 const modelDef = this.modelToLexiconObject(model); 355 lexicon.defs[defName] = { ...modelDef, description }; 356 } 357 358 private addScalarToDefs(lexicon: LexiconDoc, scalar: Scalar) { 359 if (scalar.namespace?.name === "TypeSpec") return; 360 if (scalar.baseScalar?.namespace?.name === "TypeSpec") return; 361 362 // Skip @inline scalars - they should be inlined, not defined separately 363 if (isInline(this.program, scalar)) { 364 return; 365 } 366 367 const defName = scalar.name.charAt(0).toLowerCase() + scalar.name.slice(1); 368 const scalarDef = this.scalarToLexiconPrimitive(scalar, undefined); 369 if (scalarDef) { 370 const description = getDoc(this.program, scalar); 371 lexicon.defs[defName] = { ...scalarDef, description } as LexUserType; 372 } 373 } 374 375 private addUnionToDefs(lexicon: LexiconDoc, union: Union) { 376 const name = union.name; 377 if (!name) return; 378 379 // Skip @inline unions - they should be inlined, not defined separately 380 if (isInline(this.program, union)) { 381 return; 382 } 383 384 const unionDef = this.typeToLexiconDefinition(union, undefined, true); 385 if (!unionDef) { 386 return; 387 } 388 389 // Only string enums (including token refs) can be added as defs 390 // Union refs (type: "union") must be inlined at usage sites 391 if (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum)) { 392 const defName = name.charAt(0).toLowerCase() + name.slice(1); 393 const description = getDoc(this.program, union); 394 lexicon.defs[defName] = { ...unionDef, description }; 395 } else if (unionDef.type === "union") { 396 this.program.reportDiagnostic({ 397 code: "union-refs-not-allowed-as-def", 398 severity: "error", 399 message: 400 `Named unions of non-token model references cannot be defined as standalone defs. ` + 401 `Use @inline to inline them at usage sites, use @token models for known values, or use string literals.`, 402 target: union, 403 }); 404 } 405 } 406 407 private isBlob(model: Model): boolean { 408 // Check if model itself is named Blob 409 if (model.name === "Blob") { 410 return true; 411 } 412 413 // Check if it's a template instance of Blob 414 if (isTemplateInstance(model) && model.sourceModel?.name === "Blob") { 415 return true; 416 } 417 418 // Check base model (model ImageBlob extends Blob<...>) 419 if (model.baseModel) { 420 return this.isBlob(model.baseModel); 421 } 422 423 return false; 424 } 425 426 private createBlobDef(model: Model): LexBlob { 427 const blobDef: LexBlob = { type: "blob" }; 428 429 if (!isTemplateInstance(model)) { 430 return blobDef; 431 } 432 433 const args = model.templateMapper?.args; 434 if (!args?.length) { 435 return blobDef; 436 } 437 438 // First arg: accept types (array of mime type strings) 439 if (args.length >= 1) { 440 const acceptArg = args[0]; 441 if ( 442 isType(acceptArg) || 443 (acceptArg as ArrayValue).valueKind !== "ArrayValue" 444 ) { 445 throw new Error( 446 "Blob template first argument must be an array of mime types", 447 ); 448 } 449 const arrayValue = acceptArg as ArrayValue; 450 const acceptTypes = arrayValue.values.map((v) => { 451 if ((v as StringValue).valueKind !== "StringValue") { 452 throw new Error("Blob accept types must be strings"); 453 } 454 return (v as StringValue).value; 455 }); 456 if (acceptTypes.length > 0) { 457 blobDef.accept = acceptTypes; 458 } 459 } 460 461 // Second arg: maxSize (numeric literal) 462 if (args.length >= 2) { 463 const maxSizeArg = args[1] as IndeterminateEntity; 464 if (!isType(maxSizeArg.type) || maxSizeArg.type.kind !== "Number") { 465 throw new Error( 466 "Blob template second argument must be a numeric literal", 467 ); 468 } 469 const maxSize = (maxSizeArg.type as NumericLiteral).value; 470 if (maxSize > 0) { 471 blobDef.maxSize = maxSize; 472 } 473 } 474 475 return blobDef; 476 } 477 478 private unionToLexiconProperty( 479 unionType: Union, 480 prop?: ModelProperty, 481 isDefining?: boolean, 482 ): LexObjectProperty | null { 483 const variants = this.parseUnionVariants(unionType); 484 485 // Boolean literal unions are not supported in Lexicon 486 if (variants.booleanLiterals.length > 0) { 487 this.program.reportDiagnostic({ 488 code: "boolean-literals-not-supported", 489 severity: "error", 490 message: 491 "Boolean literal unions are not supported in Lexicon. Use boolean type with const or default instead.", 492 target: unionType, 493 }); 494 return null; 495 } 496 497 // Integer enum (@closed only) 498 if ( 499 variants.numericLiterals.length > 0 && 500 variants.unionRefs.length === 0 && 501 isClosed(this.program, unionType) 502 ) { 503 const propDesc = prop ? getDoc(this.program, prop) : undefined; 504 const defaultValue = prop?.defaultValue 505 ? serializeValueAsJson(this.program, prop.defaultValue, prop) 506 : undefined; 507 return { 508 type: "integer", 509 enum: variants.numericLiterals, 510 ...(propDesc && { description: propDesc }), 511 ...(defaultValue !== undefined && 512 typeof defaultValue === "number" && { default: defaultValue }), 513 }; 514 } 515 516 // String enum (string literals with or without string type) 517 // isStringEnum: has literals + string type + no refs 518 // Closed enum: has literals + no string type + no refs + @closed 519 if ( 520 variants.isStringEnum || 521 (variants.stringLiterals.length > 0 && 522 !variants.hasStringType && 523 variants.unionRefs.length === 0 && 524 variants.knownValueRefs.length === 0 && 525 isClosed(this.program, unionType)) 526 ) { 527 const isClosedUnion = isClosed(this.program, unionType); 528 const propDesc = prop ? getDoc(this.program, prop) : undefined; 529 const defaultValue = prop?.defaultValue 530 ? serializeValueAsJson(this.program, prop.defaultValue, prop) 531 : undefined; 532 const maxLength = getMaxLength(this.program, unionType); 533 const minLength = getMinLength(this.program, unionType); 534 const maxGraphemes = getMaxGraphemes(this.program, unionType); 535 const minGraphemes = getMinGraphemes(this.program, unionType); 536 537 // Combine string literals and token refs for known values 538 const allKnownValues = [ 539 ...variants.stringLiterals, 540 ...variants.knownValueRefs, 541 ]; 542 543 return { 544 type: "string", 545 [isClosedUnion ? "enum" : "knownValues"]: allKnownValues, 546 ...(propDesc && { description: propDesc }), 547 ...(defaultValue !== undefined && 548 typeof defaultValue === "string" && { default: defaultValue }), 549 ...(maxLength !== undefined && { maxLength }), 550 ...(minLength !== undefined && { minLength }), 551 ...(maxGraphemes !== undefined && { maxGraphemes }), 552 ...(minGraphemes !== undefined && { minGraphemes }), 553 }; 554 } 555 556 // Model reference union (including empty union with unknown) 557 if (variants.unionRefs.length > 0 || variants.hasUnknown) { 558 if ( 559 variants.stringLiterals.length > 0 || 560 variants.knownValueRefs.length > 0 561 ) { 562 this.program.reportDiagnostic({ 563 code: "union-mixed-refs-literals", 564 severity: "error", 565 message: 566 `Union contains both non-token model references and string literals/token refs. Lexicon unions must be either: ` + 567 `(1) non-token model references only (type: "union"), ` + 568 `(2) token refs + string literals + string type (type: "string" with knownValues), or ` + 569 `(3) integer literals + integer type (type: "integer" with knownValues). ` + 570 `Separate these into distinct fields or nested unions.`, 571 target: unionType, 572 }); 573 return null; 574 } 575 576 const isClosedUnion = isClosed(this.program, unionType); 577 if (isClosedUnion && variants.hasUnknown) { 578 this.program.reportDiagnostic({ 579 code: "closed-open-union", 580 severity: "error", 581 message: 582 "@closed decorator cannot be used on open unions (unions containing `unknown` or `never`). " + 583 "Remove the @closed decorator or make the union closed by removing `unknown` / `never`.", 584 target: unionType, 585 }); 586 } 587 588 const propDesc = prop ? getDoc(this.program, prop) : undefined; 589 return { 590 type: "union", 591 refs: variants.unionRefs, 592 ...(propDesc && { description: propDesc }), 593 ...(isClosedUnion && !variants.hasUnknown && { closed: true }), 594 }; 595 } 596 597 // Empty union without unknown 598 if ( 599 variants.stringLiterals.length === 0 && 600 variants.numericLiterals.length === 0 && 601 variants.booleanLiterals.length === 0 602 ) { 603 this.program.reportDiagnostic({ 604 code: "union-empty", 605 severity: "error", 606 message: `Union has no variants. Lexicon unions must contain either model references or literals.`, 607 target: unionType, 608 }); 609 return null; 610 } 611 612 // Invalid string literal union (has literals but no string type and not @closed) 613 if (variants.stringLiterals.length > 0 && !variants.hasStringType) { 614 this.program.reportDiagnostic({ 615 code: "string-literal-union-invalid", 616 severity: "error", 617 message: 618 'Open string unions must include "| string" to allow unknown values. ' + 619 "Use @closed decorator if this is intentionally a closed enum.", 620 target: unionType, 621 }); 622 return null; 623 } 624 625 // Unexpected case 626 this.program.reportDiagnostic({ 627 code: "union-unexpected-type", 628 severity: "error", 629 message: 630 "Unexpected union type: neither string enum nor model refs nor empty.", 631 target: unionType, 632 }); 633 return null; 634 } 635 636 private parseUnionVariants(unionType: Union) { 637 const unionRefs: string[] = []; 638 const stringLiterals: string[] = []; 639 const numericLiterals: number[] = []; 640 const booleanLiterals: boolean[] = []; 641 const tokenModels: Model[] = []; 642 let hasStringType = false; 643 let hasUnknown = false; 644 645 for (const variant of unionType.variants.values()) { 646 switch (variant.type.kind) { 647 case "Model": 648 const model = variant.type as Model; 649 // Collect token models separately - they're treated differently based on hasStringType 650 if (isToken(this.program, model)) { 651 tokenModels.push(model); 652 } else { 653 const ref = this.getModelReference(model); 654 if (ref) unionRefs.push(ref); 655 } 656 break; 657 case "String": 658 stringLiterals.push((variant.type as StringLiteral).value); 659 break; 660 case "Number": 661 numericLiterals.push((variant.type as NumericLiteral).value); 662 break; 663 case "Boolean": 664 booleanLiterals.push((variant.type as BooleanLiteral).value); 665 break; 666 case "Scalar": 667 if ((variant.type as Scalar).name === "string") { 668 hasStringType = true; 669 } 670 break; 671 case "Intrinsic": 672 const intrinsicName = (variant.type as IntrinsicType).name; 673 if (intrinsicName === "unknown" || intrinsicName === "never") { 674 hasUnknown = true; 675 } 676 break; 677 } 678 } 679 680 // Validate: tokens must appear with | string 681 // Per Lexicon spec line 240: "unions can not reference token" 682 if (tokenModels.length > 0 && !hasStringType) { 683 this.program.reportDiagnostic({ 684 code: "tokens-require-string", 685 severity: "error", 686 message: 687 "Tokens must be used with | string. Per Lexicon spec, tokens encode as string values and cannot appear in union refs.", 688 target: unionType, 689 }); 690 } 691 692 // Token models become "known values" (always fully qualified refs) 693 const knownValueRefs = tokenModels 694 .map((m) => this.getModelReference(m, true)) 695 .filter((ref): ref is string => ref !== null); 696 697 const isStringEnum = 698 (stringLiterals.length > 0 || knownValueRefs.length > 0) && 699 hasStringType && 700 unionRefs.length === 0; 701 702 return { 703 unionRefs, 704 stringLiterals, 705 numericLiterals, 706 booleanLiterals, 707 knownValueRefs, 708 hasStringType, 709 hasUnknown, 710 isStringEnum, 711 }; 712 } 713 714 private addOperationToDefs( 715 lexicon: LexiconDoc, 716 operation: Operation, 717 defName: string, 718 ) { 719 const description = getDoc(this.program, operation); 720 const errors = getErrors(this.program, operation); 721 722 if (isQuery(this.program, operation)) { 723 const parameters = this.buildParameters(operation); 724 const output = this.buildOutput(operation); 725 726 lexicon.defs[defName] = { 727 type: "query", 728 ...(description && { description }), 729 ...(parameters && { parameters }), 730 ...(output && { output }), 731 ...(errors?.length && { errors }), 732 } as LexXrpcQuery; 733 } else if (isProcedure(this.program, operation)) { 734 const { input, parameters } = this.buildProcedureParams(operation); 735 const output = this.buildOutput(operation); 736 737 lexicon.defs[defName] = { 738 type: "procedure", 739 ...(description && { description }), 740 ...(input && { input }), 741 ...(parameters && { parameters }), 742 ...(output && { output }), 743 ...(errors?.length && { errors }), 744 } as LexXrpcProcedure; 745 } else if (isSubscription(this.program, operation)) { 746 const parameters = this.buildParameters(operation); 747 const message = this.buildSubscriptionMessage(operation); 748 749 lexicon.defs[defName] = { 750 type: "subscription", 751 ...(description && { description }), 752 ...(parameters && { parameters }), 753 ...(message && { message }), 754 ...(errors?.length && { errors }), 755 } as LexXrpcSubscription; 756 } 757 } 758 759 private buildParameters(operation: Operation): LexXrpcParameters | undefined { 760 if (!operation.parameters?.properties?.size) return undefined; 761 762 const properties: Record<string, LexXrpcParameterProperty> = {}; 763 const required: string[] = []; 764 765 for (const [paramName, param] of operation.parameters.properties) { 766 // Check for conflicting @required on optional property 767 if (param.optional && isRequired(this.program, param)) { 768 this.program.reportDiagnostic({ 769 code: "required-on-optional", 770 message: 771 `Parameter "${paramName}" has conflicting markers: @required decorator with optional "?". ` + 772 `Either remove @required to make it optional (preferred), or remove the "?".`, 773 target: param, 774 severity: "error", 775 }); 776 } 777 778 if (!param.optional) { 779 if (!isRequired(this.program, param)) { 780 this.program.reportDiagnostic({ 781 code: "parameter-missing-required", 782 message: 783 `Required parameter "${paramName}" must be explicitly marked with @required decorator. ` + 784 `In atproto, required fields are discouraged and must be intentional. ` + 785 `Either add @required to the parameter or make it optional with "?".`, 786 target: param, 787 severity: "error", 788 }); 789 } 790 required.push(paramName); 791 } 792 793 const paramDef = this.typeToLexiconDefinition(param.type, param); 794 if (paramDef && this.isXrpcParameterProperty(paramDef)) { 795 properties[paramName] = paramDef; 796 } 797 } 798 799 return { 800 type: "params" as const, 801 properties, 802 ...(required.length && { required }), 803 }; 804 } 805 806 private isXrpcParameterProperty( 807 type: LexObjectProperty, 808 ): type is LexXrpcParameterProperty { 809 // XRPC parameters can only be primitives or arrays of primitives 810 if (type.type === "array") { 811 const arrayType = type as LexArray; 812 return ( 813 arrayType.items.type === "boolean" || 814 arrayType.items.type === "integer" || 815 arrayType.items.type === "string" || 816 arrayType.items.type === "unknown" 817 ); 818 } 819 return ( 820 type.type === "boolean" || 821 type.type === "integer" || 822 type.type === "string" || 823 type.type === "unknown" 824 ); 825 } 826 827 private buildProcedureParams(operation: Operation): { 828 input?: LexXrpcBody; 829 parameters?: LexXrpcParameters; 830 } { 831 if (!operation.parameters?.properties?.size) { 832 return {}; 833 } 834 835 const params = Array.from(operation.parameters.properties) as [ 836 string, 837 ModelProperty, 838 ][]; 839 840 if (params.length === 0) { 841 return {}; 842 } 843 844 if (params.length > 2) { 845 this.program.reportDiagnostic({ 846 code: "procedure-too-many-params", 847 severity: "error", 848 message: 849 "Procedures can have at most 2 parameters (input and/or parameters)", 850 target: operation, 851 }); 852 return {}; 853 } 854 855 // Single parameter: must be named "input" 856 if (params.length === 1) { 857 const [paramName, param] = params[0]; 858 if (paramName !== "input") { 859 this.program.reportDiagnostic({ 860 code: "procedure-invalid-param-name", 861 severity: "error", 862 message: `Procedure parameter must be named "input", got "${paramName}"`, 863 target: param, 864 }); 865 return {}; 866 } 867 868 const input = this.buildInput(param); 869 if (!input) { 870 return {}; 871 } 872 return { input }; 873 } 874 875 // Two parameters: must be "input" and "parameters" 876 const [param1Name, param1] = params[0]; 877 const [param2Name, param2] = params[1]; 878 879 if (param1Name !== "input") { 880 this.program.reportDiagnostic({ 881 code: "procedure-invalid-first-param", 882 severity: "error", 883 message: `First parameter must be named "input", got "${param1Name}"`, 884 target: param1, 885 }); 886 } 887 888 if (param2Name !== "parameters") { 889 this.program.reportDiagnostic({ 890 code: "procedure-invalid-second-param", 891 severity: "error", 892 message: `Second parameter must be named "parameters", got "${param2Name}"`, 893 target: param2, 894 }); 895 } 896 897 if (param2.type.kind !== "Model" || (param2.type as Model).name) { 898 this.program.reportDiagnostic({ 899 code: "procedure-parameters-not-object", 900 severity: "error", 901 message: 902 "The 'parameters' parameter must be a plain object, not a model reference", 903 target: param2, 904 }); 905 } 906 907 const input = this.buildInput(param1); 908 const parameters = this.buildParametersFromModel(param2.type as Model); 909 910 const result: { input?: LexXrpcBody; parameters?: LexXrpcParameters } = {}; 911 if (input) { 912 result.input = input; 913 } 914 if (parameters) { 915 result.parameters = parameters; 916 } 917 return result; 918 } 919 920 private buildParametersFromModel( 921 parametersModel: Model, 922 ): LexXrpcParameters | undefined { 923 if (parametersModel.kind !== "Model" || !parametersModel.properties) { 924 return undefined; 925 } 926 927 const properties: Record<string, LexXrpcParameterProperty> = {}; 928 const required: string[] = []; 929 930 for (const [propName, prop] of parametersModel.properties) { 931 // Check for conflicting @required on optional property 932 if (prop.optional && isRequired(this.program, prop)) { 933 this.program.reportDiagnostic({ 934 code: "required-on-optional", 935 message: 936 `Parameter "${propName}" has conflicting markers: @required decorator with optional "?". ` + 937 `Either remove @required to make it optional (preferred), or remove the "?".`, 938 target: prop, 939 severity: "error", 940 }); 941 } 942 943 if (!prop.optional) { 944 if (!isRequired(this.program, prop)) { 945 this.program.reportDiagnostic({ 946 code: "parameter-missing-required", 947 message: 948 `Required parameter "${propName}" must be explicitly marked with @required decorator. ` + 949 `In atproto, required fields are discouraged and must be intentional. ` + 950 `Either add @required to the parameter or make it optional with "?".`, 951 target: prop, 952 severity: "error", 953 }); 954 } 955 required.push(propName); 956 } 957 958 const propDef = this.typeToLexiconDefinition(prop.type, prop); 959 if (propDef && this.isXrpcParameterProperty(propDef)) { 960 properties[propName] = propDef; 961 } 962 } 963 964 return { 965 type: "params" as const, 966 properties, 967 ...(required.length > 0 && { required }), 968 }; 969 } 970 971 private buildInput(param: ModelProperty): LexXrpcBody | undefined { 972 const encoding = getEncoding(this.program, param); 973 974 if (param.type?.kind === "Intrinsic") { 975 return encoding ? { encoding } : undefined; 976 } 977 978 const inputSchema = this.typeToLexiconDefinition(param.type); 979 if (!inputSchema) { 980 return undefined; 981 } 982 983 const validSchema = this.toValidBodySchema(inputSchema); 984 if (!validSchema) { 985 return undefined; 986 } 987 988 return { 989 encoding: encoding || "application/json", 990 schema: validSchema, 991 }; 992 } 993 994 private buildOutput(operation: Operation): LexXrpcBody | undefined { 995 const encoding = getEncoding(this.program, operation); 996 997 if (operation.returnType?.kind === "Intrinsic") { 998 return encoding ? { encoding } : undefined; 999 } 1000 1001 const schema = this.typeToLexiconDefinition(operation.returnType); 1002 if (!schema) { 1003 return undefined; 1004 } 1005 1006 const validSchema = this.toValidBodySchema(schema); 1007 if (!validSchema) { 1008 return undefined; 1009 } 1010 1011 return { 1012 encoding: encoding || "application/json", 1013 schema: validSchema, 1014 }; 1015 } 1016 1017 private toValidBodySchema( 1018 schema: LexObjectProperty, 1019 ): LexRefVariant | LexObject | null { 1020 if ( 1021 schema.type === "ref" || 1022 schema.type === "union" || 1023 schema.type === "object" 1024 ) { 1025 return schema as LexRefVariant | LexObject; 1026 } 1027 return null; 1028 } 1029 1030 private buildSubscriptionMessage( 1031 operation: Operation, 1032 ): { schema: LexRefUnion } | undefined { 1033 if (operation.returnType?.kind === "Union") { 1034 const messageSchema = this.typeToLexiconDefinition(operation.returnType); 1035 if (messageSchema && messageSchema.type === "union") { 1036 return { schema: messageSchema }; 1037 } 1038 } else if (operation.returnType?.kind !== "Intrinsic") { 1039 this.program.reportDiagnostic({ 1040 code: "subscription-return-not-union", 1041 severity: "error", 1042 message: "Subscription return type must be a union", 1043 target: operation, 1044 }); 1045 } 1046 return undefined; 1047 } 1048 1049 private modelToLexiconObject( 1050 model: Model, 1051 includeModelDescription: boolean = true, 1052 ): LexObject { 1053 const required: string[] = []; 1054 const nullable: string[] = []; 1055 const properties: Record<string, LexObjectProperty> = {}; 1056 1057 for (const [name, prop] of model.properties) { 1058 // Check for conflicting @required on optional property 1059 if (prop.optional && isRequired(this.program, prop)) { 1060 this.program.reportDiagnostic({ 1061 code: "required-on-optional", 1062 message: 1063 `Property "${name}" has conflicting markers: @required decorator with optional "?". ` + 1064 `Either remove @required to make it optional (preferred), or remove the "?".`, 1065 target: prop, 1066 severity: "error", 1067 }); 1068 } 1069 1070 if (!prop.optional) { 1071 if (!isRequired(this.program, prop)) { 1072 this.program.reportDiagnostic({ 1073 code: "closed-open-union-inline", 1074 message: 1075 `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` + 1076 `In atproto, required fields are discouraged and must be intentional. ` + 1077 `Either add @required to the field or make it optional with "?".`, 1078 target: model, 1079 severity: "error", 1080 }); 1081 } 1082 required.push(name); 1083 } 1084 1085 let typeToProcess = prop.type; 1086 if (prop.type.kind === "Union") { 1087 const variants = Array.from((prop.type as Union).variants.values()); 1088 const hasNull = variants.some( 1089 (v) => 1090 v.type.kind === "Intrinsic" && 1091 (v.type as IntrinsicType).name === "null", 1092 ); 1093 1094 if (hasNull) { 1095 nullable.push(name); 1096 const nonNullVariant = variants.find( 1097 (v) => 1098 !( 1099 v.type.kind === "Intrinsic" && 1100 (v.type as IntrinsicType).name === "null" 1101 ), 1102 ); 1103 if (nonNullVariant) typeToProcess = nonNullVariant.type; 1104 } 1105 } 1106 1107 const propDef = this.typeToLexiconDefinition(typeToProcess, prop); 1108 if (propDef) properties[name] = propDef; 1109 } 1110 1111 const description = includeModelDescription 1112 ? getDoc(this.program, model) 1113 : undefined; 1114 1115 return { 1116 type: "object", 1117 properties, 1118 ...(description && { description }), 1119 ...(required.length && { required }), 1120 ...(nullable.length && { nullable }), 1121 }; 1122 } 1123 1124 private typeToLexiconDefinition( 1125 type: Type, 1126 prop?: ModelProperty, 1127 isDefining?: boolean, 1128 ): LexObjectProperty | null { 1129 const propDesc = prop ? getDoc(this.program, prop) : undefined; 1130 1131 switch (type.kind) { 1132 case "Scalar": 1133 return this.handleScalarType(type as Scalar, prop, propDesc); 1134 case "Model": 1135 return this.handleModelType(type as Model, prop, propDesc); 1136 case "Union": 1137 return this.handleUnionType(type as Union, prop, isDefining, propDesc); 1138 case "Intrinsic": 1139 const intrinsicType = type as IntrinsicType; 1140 if (intrinsicType.name === "null") { 1141 return { type: "null" as const, description: propDesc }; 1142 } 1143 return { type: "unknown" as const, description: propDesc }; 1144 default: 1145 // Unhandled type kind 1146 this.program.reportDiagnostic({ 1147 code: "unhandled-type-kind", 1148 severity: "error", 1149 message: `Unhandled type kind "${type.kind}" in typeToLexiconDefinition`, 1150 target: type, 1151 }); 1152 return null; 1153 } 1154 } 1155 1156 private handleScalarType( 1157 scalar: Scalar, 1158 prop?: ModelProperty, 1159 propDesc?: string, 1160 ): LexObjectProperty | null { 1161 const primitive = this.scalarToLexiconPrimitive(scalar, prop); 1162 if (!primitive) return null; 1163 1164 // Determine description: prop description, or inherited scalar description for custom scalars 1165 let description = propDesc; 1166 if ( 1167 !description && 1168 scalar.baseScalar && 1169 scalar.namespace?.name !== "TypeSpec" 1170 ) { 1171 // Don't inherit description for built-in scalars (formats, bytes, cidLink) 1172 const isBuiltInScalar = 1173 STRING_FORMAT_MAP[scalar.name] || 1174 this.isScalarOfType(scalar, "bytes") || 1175 this.isScalarOfType(scalar, "cidLink"); 1176 if (!isBuiltInScalar) { 1177 description = getDoc(this.program, scalar); 1178 } 1179 } 1180 1181 return { ...primitive, description }; 1182 } 1183 1184 private handleModelType( 1185 model: Model, 1186 prop?: ModelProperty, 1187 propDesc?: string, 1188 ): LexObjectProperty | null { 1189 // 1. Check for Blob type 1190 if (this.isBlob(model)) { 1191 return { ...this.createBlobDef(model), description: propDesc }; 1192 } 1193 1194 // 2. Check for model reference (named models) 1195 const modelRef = this.getModelReference(model); 1196 1197 // Tokens must be referenced, not inlined 1198 if (isToken(this.program, model)) { 1199 if (!modelRef) { 1200 this.program.reportDiagnostic({ 1201 code: "token-must-be-named", 1202 severity: "error", 1203 message: "Token types must be named and referenced, not used inline", 1204 target: model, 1205 }); 1206 return null; 1207 } 1208 return { type: "ref" as const, ref: modelRef, description: propDesc }; 1209 } 1210 1211 if (modelRef) { 1212 return { type: "ref" as const, ref: modelRef, description: propDesc }; 1213 } 1214 1215 // 3. Check for array type 1216 if (isArrayModelType(this.program, model)) { 1217 const arrayDef = this.modelToLexiconArray(model, prop); 1218 if (!arrayDef) { 1219 this.program.reportDiagnostic({ 1220 code: "array-conversion-failed", 1221 severity: "error", 1222 message: 1223 "Array type conversion failed - array must have a valid item type", 1224 target: model, 1225 }); 1226 return null; 1227 } 1228 return { ...arrayDef, description: propDesc }; 1229 } 1230 1231 // 4. Inline object 1232 const objDef = this.modelToLexiconObject(model); 1233 // Only add propDesc if the object doesn't already have a description 1234 return propDesc && !objDef.description 1235 ? { ...objDef, description: propDesc } 1236 : objDef; 1237 } 1238 1239 private handleUnionType( 1240 unionType: Union, 1241 prop?: ModelProperty, 1242 isDefining?: boolean, 1243 propDesc?: string, 1244 ): LexObjectProperty | null { 1245 // Check if this is a named union that should be referenced 1246 if (!isDefining) { 1247 const unionRef = this.getUnionReference(unionType); 1248 if (unionRef) { 1249 return { type: "ref" as const, ref: unionRef, description: propDesc }; 1250 } 1251 } 1252 1253 const unionDef = this.unionToLexiconProperty(unionType, prop, isDefining); 1254 if (!unionDef) return null; 1255 1256 // Inherit description from union if no prop description and union is @inline 1257 if (!propDesc && isInline(this.program, unionType)) { 1258 const unionDesc = getDoc(this.program, unionType); 1259 if (unionDesc) { 1260 return { ...unionDef, description: unionDesc }; 1261 } 1262 } 1263 1264 return unionDef; 1265 } 1266 1267 private scalarToLexiconPrimitive( 1268 scalar: Scalar, 1269 prop?: ModelProperty, 1270 ): LexObjectProperty | null { 1271 // Check if this scalar (or its base) is bytes type 1272 if (this.isScalarOfType(scalar, "bytes")) { 1273 const byteDef: LexBytes = { type: "bytes" }; 1274 const target = prop || scalar; 1275 1276 const minLength = getMinBytes(this.program, target); 1277 if (minLength !== undefined) { 1278 byteDef.minLength = minLength; 1279 } 1280 1281 const maxLength = getMaxBytes(this.program, target); 1282 if (maxLength !== undefined) { 1283 byteDef.maxLength = maxLength; 1284 } 1285 1286 if (prop) { 1287 return this.applyPropertyMetadata(byteDef, prop); 1288 } 1289 return byteDef; 1290 } 1291 1292 // Check if this scalar (or its base) is cidLink type 1293 if (this.isScalarOfType(scalar, "cidLink")) { 1294 const cidLinkDef: LexCidLink = { type: "cid-link" }; 1295 if (prop) { 1296 return this.applyPropertyMetadata(cidLinkDef, prop); 1297 } 1298 return cidLinkDef; 1299 } 1300 1301 // Build primitive with constraints and metadata 1302 let primitive = this.getBasePrimitiveType(scalar); 1303 if (!primitive) return null; 1304 1305 // Apply format if applicable - check the scalar chain for format 1306 const format = this.getScalarFormat(scalar); 1307 if (format && primitive.type === "string") { 1308 primitive = { ...primitive, format }; 1309 } 1310 1311 // Apply string constraints 1312 if (primitive.type === "string") { 1313 const target = prop || scalar; 1314 const maxLength = getMaxLength(this.program, target); 1315 if (maxLength !== undefined) { 1316 primitive.maxLength = maxLength; 1317 } 1318 const minLength = getMinLength(this.program, target); 1319 if (minLength !== undefined) { 1320 primitive.minLength = minLength; 1321 } 1322 const maxGraphemes = getMaxGraphemes(this.program, target); 1323 if (maxGraphemes !== undefined) { 1324 primitive.maxGraphemes = maxGraphemes; 1325 } 1326 const minGraphemes = getMinGraphemes(this.program, target); 1327 if (minGraphemes !== undefined) { 1328 primitive.minGraphemes = minGraphemes; 1329 } 1330 } 1331 1332 // Apply numeric constraints 1333 if (prop && primitive.type === "integer") { 1334 const minValue = getMinValue(this.program, prop); 1335 if (minValue !== undefined) { 1336 primitive.minimum = minValue; 1337 } 1338 const maxValue = getMaxValue(this.program, prop); 1339 if (maxValue !== undefined) { 1340 primitive.maximum = maxValue; 1341 } 1342 } 1343 1344 // Apply property-specific metadata 1345 if (prop) { 1346 primitive = this.applyPropertyMetadata(primitive, prop); 1347 } 1348 1349 return primitive; 1350 } 1351 1352 private isScalarOfType(scalar: Scalar, typeName: string): boolean { 1353 if (scalar.name === typeName) { 1354 return true; 1355 } 1356 if (scalar.baseScalar && this.isScalarOfType(scalar.baseScalar, typeName)) { 1357 return true; 1358 } 1359 return false; 1360 } 1361 1362 private getScalarFormat(scalar: Scalar): string | undefined { 1363 // Check if this scalar has a format 1364 const format = STRING_FORMAT_MAP[scalar.name]; 1365 if (format) { 1366 return format; 1367 } 1368 // Check base scalar 1369 if (scalar.baseScalar) { 1370 return this.getScalarFormat(scalar.baseScalar); 1371 } 1372 return undefined; 1373 } 1374 1375 private getBasePrimitiveType(scalar: Scalar): LexObjectProperty | null { 1376 // Custom scalars extending valid primitives (like did, atUri, etc. extending string) 1377 if (scalar.baseScalar) { 1378 return this.getBasePrimitiveType(scalar.baseScalar); 1379 } 1380 // Valid Lexicon primitive types 1381 switch (scalar.name) { 1382 case "boolean": 1383 return { type: "boolean" }; 1384 case "string": 1385 return { type: "string" }; 1386 case "numeric": 1387 // TODO: Any way to narrow it down? 1388 return { type: "integer" }; 1389 } 1390 this.program.reportDiagnostic({ 1391 code: "unknown-scalar-type", 1392 severity: "error", 1393 message: `Scalar type "${scalar.name}" is not a valid Lexicon primitive. Valid types: boolean, integer, string`, 1394 target: scalar, 1395 }); 1396 return null; 1397 } 1398 1399 private applyPropertyMetadata<T extends LexObjectProperty>( 1400 primitive: T, 1401 prop: ModelProperty, 1402 ): T { 1403 let defaultValue; 1404 if (prop.defaultValue !== undefined) { 1405 defaultValue = serializeValueAsJson( 1406 this.program, 1407 prop.defaultValue, 1408 prop, 1409 ); 1410 } 1411 if (defaultValue !== undefined) { 1412 this.assertValidValueForType(primitive.type, defaultValue, prop); 1413 } 1414 if (isReadOnly(this.program, prop)) { 1415 if (defaultValue === undefined) { 1416 this.program.reportDiagnostic({ 1417 code: "readonly-missing-default", 1418 severity: "error", 1419 message: "@readOnly requires a default value assignment", 1420 target: prop, 1421 }); 1422 return primitive; 1423 } 1424 return { ...primitive, const: defaultValue } as T; 1425 } else if (defaultValue !== undefined) { 1426 return { ...primitive, default: defaultValue } as T; 1427 } 1428 return primitive; 1429 } 1430 1431 private assertValidValueForType( 1432 primitiveType: string, 1433 value: unknown, 1434 prop: ModelProperty, 1435 ): void { 1436 const valid = 1437 (primitiveType === "boolean" && typeof value === "boolean") || 1438 (primitiveType === "string" && typeof value === "string") || 1439 (primitiveType === "integer" && typeof value === "number"); 1440 if (!valid) { 1441 this.program.reportDiagnostic({ 1442 code: "invalid-default-value-type", 1443 severity: "error", 1444 message: `Default value type mismatch: expected ${primitiveType}, got ${typeof value}`, 1445 target: prop, 1446 }); 1447 } 1448 } 1449 1450 private getReference( 1451 entity: Model | Union, 1452 name: string | undefined, 1453 namespace: Namespace | undefined, 1454 fullyQualified = false, 1455 ): string | null { 1456 if (!name || !namespace || namespace.name === "TypeSpec") return null; 1457 1458 // If entity is marked as @inline, don't create a reference - inline it instead 1459 if (isInline(this.program, entity)) { 1460 return null; 1461 } 1462 1463 const defName = name.charAt(0).toLowerCase() + name.slice(1); 1464 const namespaceName = getNamespaceFullName(namespace); 1465 if (!namespaceName) { 1466 this.program.reportDiagnostic({ 1467 code: "no-namespace", 1468 severity: "error", 1469 message: `Missing namespace definition`, 1470 target: entity, 1471 }); 1472 } 1473 1474 // For knownValues (fullyQualified=true), always use fully qualified refs 1475 if (fullyQualified) { 1476 return `${namespaceName}#${defName}`; 1477 } 1478 1479 // Local reference (same namespace) - use short ref 1480 if ( 1481 this.currentLexiconId === namespaceName || 1482 this.currentLexiconId === `${namespaceName}.defs` 1483 ) { 1484 return `#${defName}`; 1485 } 1486 1487 // Cross-namespace reference: Main models reference just the namespace 1488 if (entity.kind === "Model" && name === "Main") { 1489 return namespaceName; 1490 } 1491 1492 // All other refs use fully qualified format 1493 return `${namespaceName}#${defName}`; 1494 } 1495 1496 private getModelReference( 1497 model: Model, 1498 fullyQualified = false, 1499 ): string | null { 1500 return this.getReference( 1501 model, 1502 model.name, 1503 model.namespace, 1504 fullyQualified, 1505 ); 1506 } 1507 1508 private getUnionReference(union: Union): string | null { 1509 return this.getReference(union, union.name, union.namespace); 1510 } 1511 1512 private modelToLexiconArray( 1513 model: Model, 1514 prop?: ModelProperty, 1515 ): LexArray | null { 1516 const arrayModel = model.sourceModel || model; 1517 const itemType = arrayModel.templateMapper?.args?.[0]; 1518 1519 if (itemType && isType(itemType)) { 1520 const itemDef = this.typeToLexiconDefinition(itemType); 1521 if (!itemDef) { 1522 this.program.reportDiagnostic({ 1523 code: "array-item-conversion-failed", 1524 severity: "error", 1525 message: "Failed to convert array item type to lexicon definition", 1526 target: model, 1527 }); 1528 return null; 1529 } 1530 1531 const arrayDef: LexArray = { 1532 type: "array", 1533 items: itemDef as LexArrayItem, 1534 }; 1535 1536 if (prop) { 1537 const maxItems = getMaxItems(this.program, prop); 1538 if (maxItems !== undefined) arrayDef.maxLength = maxItems; 1539 const minItems = getMinItems(this.program, prop); 1540 if (minItems !== undefined) arrayDef.minLength = minItems; 1541 } 1542 1543 return arrayDef; 1544 } 1545 1546 this.program.reportDiagnostic({ 1547 code: "array-missing-item-type", 1548 severity: "error", 1549 message: "Array type must have a valid item type argument", 1550 target: model, 1551 }); 1552 return null; 1553 } 1554 1555 private getLexiconPath(lexiconId: string): string { 1556 const parts = lexiconId.split("."); 1557 return join( 1558 this.options.outputDir, 1559 ...parts.slice(0, -1), 1560 parts[parts.length - 1] + ".json", 1561 ); 1562 } 1563 1564 private async writeFile(filePath: string, content: string) { 1565 const dir = dirname(filePath); 1566 await this.program.host.mkdirp(dir); 1567 await this.program.host.writeFile(filePath, content); 1568 } 1569}