An experimental TypeSpec syntax for Lexicon

wip

+625 -357
+611 -357
packages/emitter/src/emitter.ts
··· 53 53 outputDir: string; 54 54 } 55 55 56 + // Constants for atproto format scalars 57 + const FORMAT_SCALARS = new Set([ 58 + "datetime", 59 + "did", 60 + "handle", 61 + "atUri", 62 + "cid", 63 + "tid", 64 + "nsid", 65 + "recordKey", 66 + "uri", 67 + "language", 68 + "atIdentifier", 69 + "bytes", 70 + "utcDateTime", 71 + "offsetDateTime", 72 + "plainDate", 73 + "plainTime", 74 + ]); 75 + 76 + const FORMAT_MAP: Record<string, string> = { 77 + did: "did", 78 + handle: "handle", 79 + atUri: "at-uri", 80 + datetime: "datetime", 81 + cid: "cid", 82 + tid: "tid", 83 + nsid: "nsid", 84 + recordKey: "record-key", 85 + uri: "uri", 86 + language: "language", 87 + atIdentifier: "at-identifier", 88 + utcDateTime: "datetime", 89 + offsetDateTime: "datetime", 90 + plainDate: "datetime", 91 + plainTime: "datetime", 92 + }; 93 + 56 94 export class TlexEmitter { 57 95 private lexicons = new Map<string, LexiconDocument>(); 58 96 private currentLexiconId: string | null = null; ··· 78 116 private processNamespace(ns: any) { 79 117 const fullName = getNamespaceFullName(ns); 80 118 81 - if (fullName && !fullName.startsWith("TypeSpec")) { 82 - const hasModels = ns.models.size > 0; 83 - const hasScalars = ns.scalars.size > 0; 84 - const hasUnions = ns.unions?.size > 0; 85 - const hasOperations = ns.operations?.size > 0; 86 - const hasChildNamespaces = ns.namespaces.size > 0; 87 - const hasContent = hasModels || hasScalars || hasUnions; 119 + // Skip TypeSpec internal namespaces 120 + if (!fullName || fullName.startsWith("TypeSpec")) { 121 + for (const [_, childNs] of ns.namespaces) { 122 + this.processNamespace(childNs); 123 + } 124 + return; 125 + } 126 + 127 + const namespaceType = this.classifyNamespace(ns); 88 128 89 - if (hasOperations) { 129 + switch (namespaceType) { 130 + case "operation": 90 131 this.emitOperationLexicon(ns, fullName); 91 - } else if (hasContent && !hasChildNamespaces) { 132 + break; 133 + case "content": 92 134 this.emitContentLexicon(ns, fullName); 93 - } else if (hasContent && hasChildNamespaces) { 135 + break; 136 + case "defs": 94 137 this.emitDefsLexicon(ns, fullName); 95 - } 138 + break; 139 + case "empty": 140 + // Empty namespace, skip 141 + break; 96 142 } 97 143 144 + // Recursively process child namespaces 98 145 for (const [_, childNs] of ns.namespaces) { 99 146 this.processNamespace(childNs); 100 147 } 101 148 } 102 149 150 + private classifyNamespace( 151 + ns: any, 152 + ): "operation" | "content" | "defs" | "empty" { 153 + const hasModels = ns.models.size > 0; 154 + const hasScalars = ns.scalars.size > 0; 155 + const hasUnions = ns.unions?.size > 0; 156 + const hasOperations = ns.operations?.size > 0; 157 + const hasChildNamespaces = ns.namespaces.size > 0; 158 + const hasContent = hasModels || hasScalars || hasUnions; 159 + 160 + if (hasOperations) { 161 + return "operation"; 162 + } 163 + 164 + if (hasContent && hasChildNamespaces) { 165 + return "defs"; 166 + } 167 + 168 + if (hasContent && !hasChildNamespaces) { 169 + return "content"; 170 + } 171 + 172 + return "empty"; 173 + } 174 + 103 175 private emitContentLexicon(ns: any, fullName: string) { 104 176 const models = [...ns.models.values()]; 105 177 const isDefsFile = fullName.endsWith(".defs"); ··· 117 189 118 190 if (mainModel) { 119 191 lexicon.defs.main = this.createMainDef(mainModel); 120 - this.addDefs(lexicon, ns, models.filter((m) => m.name !== "Main")); 192 + this.addDefs( 193 + lexicon, 194 + ns, 195 + models.filter((m) => m.name !== "Main"), 196 + ); 121 197 } else { 122 198 this.addDefs(lexicon, ns, models); 123 199 } ··· 127 203 } 128 204 129 205 private emitDefsLexicon(ns: any, fullName: string) { 130 - const lexiconId = fullName.endsWith(".defs") ? fullName : fullName + ".defs"; 206 + const lexiconId = fullName.endsWith(".defs") 207 + ? fullName 208 + : fullName + ".defs"; 131 209 this.currentLexiconId = lexiconId; 132 210 const lexicon = this.createLexicon(lexiconId, ns); 133 211 this.addDefs(lexicon, ns, [...ns.models.values()]); ··· 140 218 const lexicon = this.createLexicon(fullName, ns); 141 219 142 220 const mainOp = [...ns.operations].find( 143 - ([name]) => name === "main" || name === "Main" 221 + ([name]) => name === "main" || name === "Main", 144 222 )?.[1]; 145 223 146 224 if (mainOp) { ··· 153 231 } 154 232 } 155 233 156 - this.addDefs(lexicon, ns, [...ns.models.values()].filter((m) => m.name !== "Main")); 234 + this.addDefs( 235 + lexicon, 236 + ns, 237 + [...ns.models.values()].filter((m) => m.name !== "Main"), 238 + ); 157 239 this.lexicons.set(fullName, lexicon); 158 240 this.currentLexiconId = null; 159 241 } ··· 171 253 const modelDef = this.modelToLexiconObject(mainModel, !!modelDescription); 172 254 173 255 if (recordKey) { 174 - const recordDef: any = { type: "record", key: recordKey, record: modelDef }; 256 + const recordDef: any = { 257 + type: "record", 258 + key: recordKey, 259 + record: modelDef, 260 + }; 175 261 if (modelDescription) { 176 262 recordDef.description = modelDescription; 177 263 delete modelDef.description; ··· 213 299 const description = getDoc(this.program, model); 214 300 215 301 if (isToken(this.program, model)) { 216 - lexicon.defs[defName] = this.addDescription({ type: "token" }, description); 302 + lexicon.defs[defName] = this.addDescription( 303 + { type: "token" }, 304 + description, 305 + ); 217 306 return; 218 307 } 219 308 ··· 246 335 const unionDef: any = this.typeToLexiconDefinition(union, undefined, true); 247 336 if (!unionDef) return; 248 337 249 - if (unionDef.type === "union" || (unionDef.type === "string" && unionDef.knownValues)) { 338 + if ( 339 + unionDef.type === "union" || 340 + (unionDef.type === "string" && unionDef.knownValues) 341 + ) { 250 342 const defName = name.charAt(0).toLowerCase() + name.slice(1); 251 343 const description = getDoc(this.program, union); 252 344 lexicon.defs[defName] = this.addDescription(unionDef, description); ··· 260 352 return obj; 261 353 } 262 354 355 + private isBlob(model: Model): boolean { 356 + return !!( 357 + isBlob(this.program, model) || 358 + (isTemplateInstance(model) && 359 + model.templateNode && 360 + isBlob(this.program, model.templateNode as any)) || 361 + (model.baseModel && isBlob(this.program, model.baseModel)) 362 + ); 363 + } 364 + 365 + private isClosedUnionTemplate(model: Model): boolean { 366 + return !!( 367 + model.name === "Closed" || 368 + (model.node && (model.node as any).symbol?.name === "Closed") || 369 + (isTemplateInstance(model) && 370 + model.node && 371 + (model.node as any).symbol?.name === "Closed") 372 + ); 373 + } 374 + 263 375 private createBlobDef(model: Model): LexiconBlob { 264 376 const blobDef: LexiconBlob = { type: "blob" }; 265 377 ··· 278 390 if (!acceptTypes.length) acceptTypes = undefined; 279 391 } 280 392 } else if (acceptArg && Array.isArray(acceptArg.value)) { 281 - const values = acceptArg.value.filter((v: any) => typeof v === "string"); 393 + const values = acceptArg.value.filter( 394 + (v: any) => typeof v === "string", 395 + ); 282 396 if (values.length) acceptTypes = values; 283 397 } 284 398 285 399 if (acceptTypes) blobDef.accept = acceptTypes; 286 400 287 401 const maxSizeArg = templateArgs[1] as any; 288 - const maxSize = maxSizeArg?.value ?? (maxSizeArg?.type?.kind === "Number" ? Number(maxSizeArg.type.value) : undefined); 402 + const maxSize = 403 + maxSizeArg?.value ?? 404 + (maxSizeArg?.type?.kind === "Number" 405 + ? Number(maxSizeArg.type.value) 406 + : undefined); 289 407 if (maxSize !== undefined && maxSize !== 0) blobDef.maxSize = maxSize; 290 408 } 291 409 } ··· 293 411 return blobDef; 294 412 } 295 413 296 - private processUnion(unionType: Union, prop?: ModelProperty): LexiconDefinition | null { 414 + private processUnion( 415 + unionType: Union, 416 + prop?: ModelProperty, 417 + ): LexiconDefinition | null { 418 + // Parse union variants 419 + const variants = this.parseUnionVariants(unionType); 420 + 421 + // Case 1: String enum with known values (string literals + string type) 422 + if (variants.isStringEnum) { 423 + return this.createStringEnumDef(unionType, variants.stringLiterals, prop); 424 + } 425 + 426 + // Case 2: Model reference union 427 + if (variants.unionRefs.length > 0) { 428 + return this.createUnionRefDef(unionType, variants, prop); 429 + } 430 + 431 + // Case 3: Empty or invalid union 432 + if (variants.stringLiterals.length === 0 && !variants.hasUnknown) { 433 + this.program.reportDiagnostic({ 434 + code: "union-empty", 435 + severity: "error", 436 + message: `Union has no variants. Atproto unions must contain either model references or string literals.`, 437 + target: unionType, 438 + }); 439 + } 440 + 441 + return null; 442 + } 443 + 444 + private parseUnionVariants(unionType: Union) { 297 445 const unionRefs: string[] = []; 298 446 const stringLiterals: string[] = []; 299 447 let hasStringType = false; 300 448 let hasUnknown = false; 301 449 302 450 for (const variant of unionType.variants.values()) { 303 - if (variant.type.kind === "Model") { 304 - const ref = this.getModelReference(variant.type as Model); 305 - if (ref) unionRefs.push(ref); 306 - } else if (variant.type.kind === "String") { 307 - stringLiterals.push((variant.type as any).value); 308 - } else if (variant.type.kind === "Scalar" && (variant.type as Scalar).name === "string") { 309 - hasStringType = true; 310 - } else if (variant.type.kind === "Intrinsic") { 311 - const intrinsicName = (variant.type as any).name; 312 - if (intrinsicName === "unknown" || intrinsicName === "never") { 313 - hasUnknown = true; 314 - } 451 + switch (variant.type.kind) { 452 + case "Model": 453 + const ref = this.getModelReference(variant.type as Model); 454 + if (ref) unionRefs.push(ref); 455 + break; 456 + case "String": 457 + stringLiterals.push((variant.type as any).value); 458 + break; 459 + case "Scalar": 460 + if ((variant.type as Scalar).name === "string") { 461 + hasStringType = true; 462 + } 463 + break; 464 + case "Intrinsic": 465 + const intrinsicName = (variant.type as any).name; 466 + if (intrinsicName === "unknown" || intrinsicName === "never") { 467 + hasUnknown = true; 468 + } 469 + break; 315 470 } 316 471 } 317 472 318 - if (stringLiterals.length && hasStringType && !unionRefs.length) { 319 - const primitive: any = { type: "string", knownValues: stringLiterals }; 320 - 321 - const maxLength = getMaxLength(this.program, unionType); 322 - if (maxLength !== undefined) primitive.maxLength = maxLength; 473 + const isStringEnum = 474 + stringLiterals.length > 0 && hasStringType && unionRefs.length === 0; 323 475 324 - const minLength = getMinLength(this.program, unionType); 325 - if (minLength !== undefined) primitive.minLength = minLength; 476 + return { 477 + unionRefs, 478 + stringLiterals, 479 + hasStringType, 480 + hasUnknown, 481 + isStringEnum, 482 + }; 483 + } 326 484 327 - const maxGraphemes = getMaxGraphemes(this.program, unionType); 328 - if (maxGraphemes !== undefined) primitive.maxGraphemes = maxGraphemes; 485 + private createStringEnumDef( 486 + unionType: Union, 487 + stringLiterals: string[], 488 + prop?: ModelProperty, 489 + ): LexiconDefinition { 490 + const primitive: any = { type: "string", knownValues: stringLiterals }; 329 491 330 - const minGraphemes = getMinGraphemes(this.program, unionType); 331 - if (minGraphemes !== undefined) primitive.minGraphemes = minGraphemes; 492 + // Apply constraints 493 + const maxLength = getMaxLength(this.program, unionType); 494 + if (maxLength !== undefined) primitive.maxLength = maxLength; 332 495 333 - if (prop) { 334 - const propDesc = getDoc(this.program, prop); 335 - if (propDesc) primitive.description = propDesc; 496 + const minLength = getMinLength(this.program, unionType); 497 + if (minLength !== undefined) primitive.minLength = minLength; 336 498 337 - const defaultValue = (prop as any).default; 338 - if (defaultValue?.value !== undefined && typeof defaultValue.value === "string") { 339 - primitive.default = defaultValue.value; 340 - } 341 - } 342 - return primitive; 343 - } 499 + const maxGraphemes = getMaxGraphemes(this.program, unionType); 500 + if (maxGraphemes !== undefined) primitive.maxGraphemes = maxGraphemes; 344 501 345 - if (unionRefs.length) { 346 - if (stringLiterals.length) { 347 - this.program.reportDiagnostic({ 348 - code: "union-mixed-refs-literals", 349 - severity: "error", 350 - message: 351 - `Union contains both model references and string literals. Atproto unions must be either: ` + 352 - `(1) model references only (type: "union"), or ` + 353 - `(2) string literals + string type (type: "string" with knownValues). ` + 354 - `Separate these into distinct fields or nested unions.`, 355 - target: unionType, 356 - }); 357 - return null; 358 - } 502 + const minGraphemes = getMinGraphemes(this.program, unionType); 503 + if (minGraphemes !== undefined) primitive.minGraphemes = minGraphemes; 359 504 360 - const unionDef: any = { type: "union", refs: unionRefs }; 505 + // Add property-specific metadata 506 + if (prop) { 507 + const propDesc = getDoc(this.program, prop); 508 + if (propDesc) primitive.description = propDesc; 361 509 362 - if (isClosed(this.program, unionType)) { 363 - if (hasUnknown) { 364 - this.program.reportDiagnostic({ 365 - code: "closed-open-union", 366 - severity: "error", 367 - message: 368 - "@closed decorator cannot be used on open unions (unions containing 'unknown' or 'never'). Remove the @closed decorator or make the union closed by removing 'unknown'/'never'.", 369 - target: unionType, 370 - }); 371 - } else { 372 - unionDef.closed = true; 373 - } 510 + const defaultValue = (prop as any).default; 511 + if ( 512 + defaultValue?.value !== undefined && 513 + typeof defaultValue.value === "string" 514 + ) { 515 + primitive.default = defaultValue.value; 374 516 } 375 - 376 - const propDesc = prop ? getDoc(this.program, prop) : undefined; 377 - return this.addDescription(unionDef, propDesc); 378 517 } 379 518 380 - if (!stringLiterals.length && !hasUnknown) { 519 + return primitive; 520 + } 521 + 522 + private createUnionRefDef( 523 + unionType: Union, 524 + variants: ReturnType<typeof this.parseUnionVariants>, 525 + prop?: ModelProperty, 526 + ): LexiconDefinition | null { 527 + // Validate: cannot mix refs and string literals 528 + if (variants.stringLiterals.length > 0) { 381 529 this.program.reportDiagnostic({ 382 - code: "union-empty", 530 + code: "union-mixed-refs-literals", 383 531 severity: "error", 384 - message: `Union has no variants. Atproto unions must contain either model references or string literals.`, 532 + message: 533 + `Union contains both model references and string literals. Atproto unions must be either: ` + 534 + `(1) model references only (type: "union"), or ` + 535 + `(2) string literals + string type (type: "string" with knownValues). ` + 536 + `Separate these into distinct fields or nested unions.`, 385 537 target: unionType, 386 538 }); 539 + return null; 387 540 } 388 541 389 - return null; 542 + const unionDef: any = { type: "union", refs: variants.unionRefs }; 543 + 544 + // Handle closed unions 545 + if (isClosed(this.program, unionType)) { 546 + if (variants.hasUnknown) { 547 + this.program.reportDiagnostic({ 548 + code: "closed-open-union", 549 + severity: "error", 550 + message: 551 + "@closed decorator cannot be used on open unions (unions containing 'unknown' or 'never'). " + 552 + "Remove the @closed decorator or make the union closed by removing 'unknown'/'never'.", 553 + target: unionType, 554 + }); 555 + } else { 556 + unionDef.closed = true; 557 + } 558 + } 559 + 560 + const propDesc = prop ? getDoc(this.program, prop) : undefined; 561 + return this.addDescription(unionDef, propDesc); 390 562 } 391 563 392 564 private addOperationToDefs( ··· 441 613 private addProcedureParams(def: any, operation: any) { 442 614 if (!operation.parameters?.properties?.size) return; 443 615 444 - const params = Array.from(operation.parameters.properties) as [string, any][]; 616 + const params = Array.from(operation.parameters.properties) as [ 617 + string, 618 + any, 619 + ][]; 445 620 const paramCount = params.length; 446 621 622 + // Validate parameter count 447 623 if (paramCount > 2) { 448 624 this.program.reportDiagnostic({ 449 625 code: "procedure-too-many-params", 450 626 severity: "error", 451 - message: "Procedures can have at most 2 parameters (input and/or parameters)", 627 + message: 628 + "Procedures can have at most 2 parameters (input and/or parameters)", 452 629 target: operation, 453 630 }); 454 631 return; 455 632 } 456 633 457 - if (paramCount === 1) { 458 - const [paramName, param] = params[0]; 459 - if (paramName !== "input") { 460 - this.program.reportDiagnostic({ 461 - code: "procedure-invalid-param-name", 462 - severity: "error", 463 - message: `Procedure parameter must be named "input", got "${paramName}"`, 464 - target: param, 465 - }); 466 - } 467 - this.addInput(def, param); 468 - } else if (paramCount === 2) { 469 - const [param1Name, param1] = params[0]; 470 - const [param2Name, param2] = params[1]; 634 + // Handle parameter count cases 635 + switch (paramCount) { 636 + case 1: 637 + this.handleSingleProcedureParam(def, params[0], operation); 638 + break; 639 + case 2: 640 + this.handleTwoProcedureParams(def, params[0], params[1], operation); 641 + break; 642 + } 643 + } 644 + 645 + private handleSingleProcedureParam( 646 + def: any, 647 + [paramName, param]: [string, any], 648 + operation: any, 649 + ) { 650 + // Validate parameter name 651 + if (paramName !== "input") { 652 + this.program.reportDiagnostic({ 653 + code: "procedure-invalid-param-name", 654 + severity: "error", 655 + message: `Procedure parameter must be named "input", got "${paramName}"`, 656 + target: param, 657 + }); 658 + return; 659 + } 660 + 661 + this.addInput(def, param); 662 + } 663 + 664 + private handleTwoProcedureParams( 665 + def: any, 666 + [param1Name, param1]: [string, any], 667 + [param2Name, param2]: [string, any], 668 + operation: any, 669 + ) { 670 + // Validate first parameter (input) 671 + if (param1Name !== "input") { 672 + this.program.reportDiagnostic({ 673 + code: "procedure-invalid-first-param", 674 + severity: "error", 675 + message: `First parameter must be named "input", got "${param1Name}"`, 676 + target: param1, 677 + }); 678 + } 471 679 472 - if (param1Name !== "input") { 473 - this.program.reportDiagnostic({ 474 - code: "procedure-invalid-first-param", 475 - severity: "error", 476 - message: `First parameter must be named "input", got "${param1Name}"`, 477 - target: param1, 478 - }); 479 - } 680 + // Validate second parameter (parameters) 681 + if (param2Name !== "parameters") { 682 + this.program.reportDiagnostic({ 683 + code: "procedure-invalid-second-param", 684 + severity: "error", 685 + message: `Second parameter must be named "parameters", got "${param2Name}"`, 686 + target: param2, 687 + }); 688 + } 480 689 481 - if (param2Name !== "parameters") { 482 - this.program.reportDiagnostic({ 483 - code: "procedure-invalid-second-param", 484 - severity: "error", 485 - message: `Second parameter must be named "parameters", got "${param2Name}"`, 486 - target: param2, 487 - }); 488 - } 690 + // Validate that parameters is a plain object 691 + if (param2.type.kind !== "Model" || (param2.type as any).name) { 692 + this.program.reportDiagnostic({ 693 + code: "procedure-parameters-not-object", 694 + severity: "error", 695 + message: 696 + "The 'parameters' parameter must be a plain object, not a model reference", 697 + target: param2, 698 + }); 699 + } 489 700 490 - if (param2.type.kind !== "Model" || (param2.type as any).name) { 491 - this.program.reportDiagnostic({ 492 - code: "procedure-parameters-not-object", 493 - severity: "error", 494 - message: "The 'parameters' parameter must be a plain object, not a model reference", 495 - target: param2, 496 - }); 497 - } 701 + // Add input 702 + this.addInput(def, param1); 498 703 499 - this.addInput(def, param1); 704 + // Add parameters 705 + this.addParametersFromModel(def, param2.type as any); 706 + } 500 707 501 - const parametersModel = param2.type as any; 502 - if (parametersModel.kind === "Model" && parametersModel.properties) { 503 - const paramsObj: any = { type: "params", properties: {} }; 504 - const required: string[] = []; 708 + private addParametersFromModel(def: any, parametersModel: any) { 709 + if (parametersModel.kind !== "Model" || !parametersModel.properties) { 710 + return; 711 + } 505 712 506 - for (const [propName, prop] of parametersModel.properties) { 507 - const propDef = this.typeToLexiconDefinition(prop.type, prop); 508 - if (propDef) { 509 - paramsObj.properties[propName] = propDef; 510 - if (!prop.optional) required.push(propName); 511 - } 512 - } 713 + const paramsObj: any = { type: "params", properties: {} }; 714 + const required: string[] = []; 513 715 514 - if (required.length) paramsObj.required = required; 515 - def.parameters = paramsObj; 716 + for (const [propName, prop] of parametersModel.properties) { 717 + const propDef = this.typeToLexiconDefinition(prop.type, prop); 718 + if (propDef) { 719 + paramsObj.properties[propName] = propDef; 720 + if (!prop.optional) { 721 + required.push(propName); 722 + } 516 723 } 517 724 } 725 + 726 + if (required.length > 0) { 727 + paramsObj.required = required; 728 + } 729 + 730 + def.parameters = paramsObj; 518 731 } 519 732 520 733 private addInput(def: any, param: any) { ··· 558 771 if (errors?.length) def.errors = errors; 559 772 } 560 773 561 - private visitModel(model: Model) { 562 - // Skip template models 563 - if (model.templateMapper || isTemplateInstance(model)) { 564 - return; 565 - } 566 - 567 - // Get the lexicon ID from the namespace and model name 568 - const namespace = model.namespace; 569 - if (!namespace || namespace.name === "") { 570 - return; 571 - } 572 - 573 - const lexiconId = this.getModelLexiconId(model); 574 - if (!lexiconId) { 575 - return; 576 - } 577 - 578 - // Create or get the lexicon document 579 - let lexicon = this.lexicons.get(lexiconId); 580 - if (!lexicon) { 581 - lexicon = { 582 - lexicon: 1, 583 - id: lexiconId, 584 - defs: {}, 585 - }; 586 - this.lexicons.set(lexiconId, lexicon); 587 - } 588 - 589 - // Add the model as a definition 590 - const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 591 - const modelDef = this.modelToLexiconObject(model); 592 - 593 - // Check if this is a defs file (ends with .defs) 594 - if (lexiconId.endsWith(".defs")) { 595 - // For defs files, all models go directly into defs object 596 - const description = getDoc(this.program, model); 597 - if (description && !modelDef.description) { 598 - modelDef.description = description; 599 - } 600 - lexicon.defs[defName] = modelDef; 601 - } else { 602 - // For non-defs files, treat the first model as the main record 603 - if (Object.keys(lexicon.defs).length === 0) { 604 - // Check if this is the lexicon schema special case 605 - const key = lexiconId === "com.atproto.lexicon.schema" ? "nsid" : "tid"; 606 - 607 - const recordDef: any = { 608 - type: "record", 609 - key: key, 610 - record: modelDef, 611 - }; 612 - 613 - // Move description from record object to record def 614 - const description = getDoc(this.program, model); 615 - if (description) { 616 - recordDef.description = description; 617 - delete modelDef.description; 618 - } 619 - 620 - lexicon.defs.main = recordDef; 621 - } else { 622 - lexicon.defs[defName] = modelDef; 623 - } 624 - } 625 - } 626 - 627 774 private modelToLexiconObject( 628 775 model: Model, 629 776 includeModelDescription: boolean = true, ··· 658 805 if (hasNull) { 659 806 nullable.push(name); 660 807 const nonNullVariant = variants.find( 661 - (v) => !(v.type.kind === "Intrinsic" && (v.type as any).name === "null"), 808 + (v) => 809 + !(v.type.kind === "Intrinsic" && (v.type as any).name === "null"), 662 810 ); 663 811 if (nonNullVariant) typeToProcess = nonNullVariant.type; 664 812 } ··· 669 817 } 670 818 671 819 const obj: any = { type: "object" }; 672 - const description = includeModelDescription ? getDoc(this.program, model) : undefined; 820 + const description = includeModelDescription 821 + ? getDoc(this.program, model) 822 + : undefined; 673 823 if (description) obj.description = description; 674 824 if (required.length) obj.required = required; 675 825 if (nullable.length) obj.nullable = nullable; ··· 685 835 const propDesc = prop ? getDoc(this.program, prop) : undefined; 686 836 687 837 switch (type.kind) { 688 - case "Namespace": { 689 - const mainModel = (type as any).models?.get("Main"); 690 - if (mainModel) { 691 - const ref = this.getModelReference(mainModel); 692 - if (ref) return this.addDescription({ type: "ref", ref }, propDesc); 693 - } 838 + case "Namespace": 839 + return this.handleNamespaceType(type as any, propDesc); 840 + case "Enum": 841 + return this.handleEnumType(type as any, propDesc); 842 + case "Boolean": 843 + return this.handleBooleanType(type as any, propDesc); 844 + case "Scalar": 845 + return this.handleScalarType(type as Scalar, prop, propDesc); 846 + case "Model": 847 + return this.handleModelType(type as Model, prop, propDesc); 848 + case "Union": 849 + return this.handleUnionType(type as Union, prop, isDefining, propDesc); 850 + case "Intrinsic": 851 + return this.addDescription({ type: "unknown" }, propDesc); 852 + default: 853 + // Unhandled type kind 694 854 return null; 695 - } 696 - case "Enum": { 697 - const members = Array.from((type as any).members?.values?.() || []); 698 - const values = members.map((m: any) => m.value); 699 - const firstValue = values[0]; 855 + } 856 + } 700 857 701 - if (typeof firstValue === "string") { 702 - return this.addDescription({ type: "string", enum: values }, propDesc); 703 - } else if (typeof firstValue === "number" && Number.isInteger(firstValue)) { 704 - return this.addDescription({ type: "integer", enum: values }, propDesc); 705 - } 706 - return null; 858 + private handleNamespaceType( 859 + ns: any, 860 + propDesc?: string, 861 + ): LexiconDefinition | null { 862 + const mainModel = ns.models?.get("Main"); 863 + if (mainModel) { 864 + const ref = this.getModelReference(mainModel); 865 + if (ref) { 866 + return this.addDescription({ type: "ref", ref }, propDesc); 707 867 } 708 - case "Boolean": { 709 - return this.addDescription({ 710 - type: "boolean", 711 - const: (type as any).value 712 - }, propDesc); 713 - } 714 - case "Scalar": 715 - const scalar = type as Scalar; 716 - const primitive = this.scalarToLexiconPrimitive(scalar, prop); 717 - if (!primitive) return null; 868 + } 869 + return null; 870 + } 718 871 719 - if (propDesc) { 720 - primitive.description = propDesc; 721 - } else if (scalar.baseScalar && scalar.namespace?.name !== "TypeSpec") { 722 - const FORMAT_SCALARS = new Set([ 723 - "datetime", "did", "handle", "atUri", "cid", "tid", "nsid", 724 - "recordKey", "uri", "language", "atIdentifier", "bytes", 725 - "utcDateTime", "offsetDateTime", "plainDate", "plainTime", 726 - ]); 727 - if (!FORMAT_SCALARS.has(scalar.name)) { 728 - const scalarDesc = getDoc(this.program, scalar); 729 - if (scalarDesc) primitive.description = scalarDesc; 730 - } 731 - } 732 - return primitive; 733 - case "Model": 734 - const model = type as Model; 872 + private handleEnumType( 873 + enumType: any, 874 + propDesc?: string, 875 + ): LexiconDefinition | null { 876 + const members = Array.from(enumType.members?.values?.() || []); 735 877 736 - const isBlobModel = 737 - isBlob(this.program, model) || 738 - (isTemplateInstance(model) && model.templateNode && isBlob(this.program, model.templateNode as any)) || 739 - (model.baseModel && isBlob(this.program, model.baseModel)); 878 + if (members.length === 0) { 879 + // TODO: Should we error on empty enum? 880 + return null; 881 + } 740 882 741 - if (isBlobModel) { 742 - return this.addDescription(this.createBlobDef(model), propDesc); 743 - } 883 + const values = members.map((m: any) => m.value); 884 + const firstValue = values[0]; 744 885 745 - const isClosedModel = 746 - model.name === "Closed" || 747 - (model.node && (model.node as any).symbol?.name === "Closed") || 748 - (isTemplateInstance(model) && model.node && (model.node as any).symbol?.name === "Closed"); 886 + if (typeof firstValue === "string") { 887 + return this.addDescription({ type: "string", enum: values }, propDesc); 888 + } else if (typeof firstValue === "number" && Number.isInteger(firstValue)) { 889 + return this.addDescription({ type: "integer", enum: values }, propDesc); 890 + } 749 891 750 - if (isClosedModel && isTemplateInstance(model)) { 751 - const unionArg = model.templateMapper?.args?.[0]; 752 - if (unionArg && isType(unionArg) && unionArg.kind === "Union") { 753 - const unionDef = this.typeToLexiconDefinition(unionArg, prop); 754 - if (unionDef && unionDef.type === "union") { 755 - (unionDef as LexiconUnion).closed = true; 756 - return unionDef; 757 - } 758 - } 759 - } 892 + // TODO: Handle mixed-type enums or float enums 893 + return null; 894 + } 760 895 761 - const modelRef = this.getModelReference(model); 762 - if (modelRef) { 763 - return this.addDescription({ type: "ref", ref: modelRef }, propDesc); 764 - } 896 + private handleBooleanType( 897 + boolType: any, 898 + propDesc?: string, 899 + ): LexiconDefinition { 900 + return this.addDescription( 901 + { type: "boolean", const: boolType.value }, 902 + propDesc, 903 + ); 904 + } 765 905 766 - if (isArrayModelType(this.program, model)) { 767 - return this.addDescription(this.modelToLexiconArray(model, prop), propDesc); 768 - } 906 + private handleScalarType( 907 + scalar: Scalar, 908 + prop?: ModelProperty, 909 + propDesc?: string, 910 + ): LexiconDefinition | null { 911 + const primitive = this.scalarToLexiconPrimitive(scalar, prop); 912 + if (!primitive) return null; 769 913 770 - return this.addDescription(this.modelToLexiconObject(model), propDesc); 771 - case "Union": 772 - const unionType = type as Union; 914 + if (propDesc) { 915 + primitive.description = propDesc; 916 + } else if (scalar.baseScalar && scalar.namespace?.name !== "TypeSpec") { 917 + // For custom scalars that extend base types, inherit description if not a format scalar 918 + if (!FORMAT_SCALARS.has(scalar.name)) { 919 + const scalarDesc = getDoc(this.program, scalar); 920 + if (scalarDesc) primitive.description = scalarDesc; 921 + } 922 + } 923 + return primitive; 924 + } 773 925 774 - if (!isDefining) { 775 - const unionRef = this.getUnionReference(unionType); 776 - if (unionRef) { 777 - return this.addDescription({ type: "ref", ref: unionRef }, propDesc); 778 - } 926 + private handleModelType( 927 + model: Model, 928 + prop?: ModelProperty, 929 + propDesc?: string, 930 + ): LexiconDefinition | null { 931 + // 1. Check for Blob type 932 + if (this.isBlob(model)) { 933 + return this.addDescription(this.createBlobDef(model), propDesc); 934 + } 935 + 936 + // 2. Check for Closed<Union> template 937 + if (this.isClosedUnionTemplate(model)) { 938 + const unionArg = model.templateMapper?.args?.[0]; 939 + if (unionArg && isType(unionArg) && unionArg.kind === "Union") { 940 + const unionDef = this.typeToLexiconDefinition(unionArg, prop); 941 + if (unionDef && unionDef.type === "union") { 942 + (unionDef as LexiconUnion).closed = true; 943 + return unionDef; 779 944 } 945 + } 946 + // TODO: Handle Closed<> with non-union argument 947 + return null; 948 + } 780 949 781 - return this.processUnion(unionType, prop); 782 - case "Intrinsic": 783 - return this.addDescription({ type: "unknown" }, propDesc); 784 - default: 950 + // 3. Check for model reference (named models from other namespaces) 951 + const modelRef = this.getModelReference(model); 952 + if (modelRef) { 953 + return this.addDescription({ type: "ref", ref: modelRef }, propDesc); 954 + } 955 + 956 + // 4. Check for array type 957 + if (isArrayModelType(this.program, model)) { 958 + const arrayDef = this.modelToLexiconArray(model, prop); 959 + if (!arrayDef) { 960 + // TODO: Handle array conversion failure 785 961 return null; 962 + } 963 + return this.addDescription(arrayDef, propDesc); 786 964 } 965 + 966 + // 5. Inline object 967 + return this.addDescription(this.modelToLexiconObject(model), propDesc); 968 + } 969 + 970 + private handleUnionType( 971 + unionType: Union, 972 + prop?: ModelProperty, 973 + isDefining?: boolean, 974 + propDesc?: string, 975 + ): LexiconDefinition | null { 976 + // Check if this is a named union that should be referenced 977 + if (!isDefining) { 978 + const unionRef = this.getUnionReference(unionType); 979 + if (unionRef) { 980 + return this.addDescription({ type: "ref", ref: unionRef }, propDesc); 981 + } 982 + } 983 + 984 + return this.processUnion(unionType, prop); 787 985 } 788 986 789 987 private scalarToLexiconPrimitive( 790 988 scalar: Scalar, 791 989 prop?: ModelProperty, 792 990 ): LexiconDefinition | null { 793 - const FORMAT_MAP: Record<string, string> = { 794 - did: "did", handle: "handle", atUri: "at-uri", datetime: "datetime", 795 - cid: "cid", tid: "tid", nsid: "nsid", recordKey: "record-key", 796 - uri: "uri", language: "language", atIdentifier: "at-identifier", 797 - utcDateTime: "datetime", offsetDateTime: "datetime", 798 - plainDate: "datetime", plainTime: "datetime" 799 - }; 800 - 991 + // Special case: bytes type can be either blob or bytes depending on decorators 801 992 if (scalar.name === "bytes") { 802 - if (prop) { 803 - const accept = getBlobAccept(this.program, prop); 804 - const maxSize = getBlobMaxSize(this.program, prop); 805 - if (accept || maxSize !== undefined) { 806 - const blobDef: LexiconBlob = { type: "blob" }; 807 - if (accept) blobDef.accept = accept; 808 - if (maxSize !== undefined) blobDef.maxSize = maxSize; 809 - return blobDef; 810 - } 811 - } 812 - return { type: "bytes" }; 993 + return this.handleBytesScalar(prop); 813 994 } 814 995 815 - const primitive: any = { type: "string" }; 996 + // Determine base primitive type 997 + const primitive: any = this.getBasePrimitiveType(scalar); 816 998 817 - if (scalar.name === "boolean") primitive.type = "boolean"; 818 - else if (["integer", "int32", "int64", "int16", "int8"].includes(scalar.name)) primitive.type = "integer"; 819 - else if (["float32", "float64"].includes(scalar.name)) primitive.type = "number"; 820 - 821 - const format = FORMAT_MAP[scalar.name] || getLexFormat(this.program, prop || scalar); 999 + // Apply format if applicable 1000 + const format = 1001 + FORMAT_MAP[scalar.name] || getLexFormat(this.program, prop || scalar); 822 1002 if (format) primitive.format = format; 823 1003 824 - const target = prop || scalar; 1004 + // Apply constraints 1005 + this.applyStringConstraints(primitive, prop || scalar); 1006 + this.applyNumericConstraints(primitive, prop); 1007 + 1008 + // Apply property-specific metadata 1009 + if (prop) { 1010 + this.applyPropertyMetadata(primitive, prop); 1011 + } 1012 + 1013 + return primitive; 1014 + } 1015 + 1016 + private handleBytesScalar(prop?: ModelProperty): LexiconDefinition { 1017 + if (prop) { 1018 + const accept = getBlobAccept(this.program, prop); 1019 + const maxSize = getBlobMaxSize(this.program, prop); 1020 + if (accept || maxSize !== undefined) { 1021 + const blobDef: LexiconBlob = { type: "blob" }; 1022 + if (accept) blobDef.accept = accept; 1023 + if (maxSize !== undefined) blobDef.maxSize = maxSize; 1024 + return blobDef; 1025 + } 1026 + } 1027 + return { type: "bytes" }; 1028 + } 1029 + 1030 + private getBasePrimitiveType(scalar: Scalar): any { 1031 + if (scalar.name === "boolean") { 1032 + return { type: "boolean" }; 1033 + } else if ( 1034 + ["integer", "int32", "int64", "int16", "int8"].includes(scalar.name) 1035 + ) { 1036 + return { type: "integer" }; 1037 + } else if (["float32", "float64"].includes(scalar.name)) { 1038 + return { type: "number" }; 1039 + } 1040 + return { type: "string" }; 1041 + } 1042 + 1043 + private applyStringConstraints( 1044 + primitive: any, 1045 + target: Scalar | ModelProperty, 1046 + ) { 825 1047 const maxLength = getMaxLength(this.program, target); 826 1048 if (maxLength !== undefined) primitive.maxLength = maxLength; 1049 + 827 1050 const minLength = getMinLength(this.program, target); 828 1051 if (minLength !== undefined) primitive.minLength = minLength; 1052 + 829 1053 const maxGraphemes = getMaxGraphemes(this.program, target); 830 1054 if (maxGraphemes !== undefined) primitive.maxGraphemes = maxGraphemes; 1055 + 831 1056 const minGraphemes = getMinGraphemes(this.program, target); 832 1057 if (minGraphemes !== undefined) primitive.minGraphemes = minGraphemes; 1058 + } 833 1059 834 - if (prop) { 835 - const constValue = getLexConst(this.program, prop); 836 - if (constValue !== undefined && 837 - (primitive.type === "boolean" && typeof constValue === "boolean" || 838 - primitive.type === "string" && typeof constValue === "string" || 839 - primitive.type === "integer" && typeof constValue === "number")) { 840 - primitive.const = constValue; 841 - } 1060 + private applyNumericConstraints(primitive: any, prop?: ModelProperty) { 1061 + if ( 1062 + !prop || 1063 + (primitive.type !== "integer" && primitive.type !== "number") 1064 + ) { 1065 + return; 1066 + } 1067 + 1068 + const minValue = getMinValue(this.program, prop); 1069 + if (minValue !== undefined) primitive.minimum = minValue; 1070 + 1071 + const maxValue = getMaxValue(this.program, prop); 1072 + if (maxValue !== undefined) primitive.maximum = maxValue; 1073 + } 842 1074 843 - const defaultValue = (prop as any).default?.value; 844 - if (defaultValue !== undefined && 845 - (primitive.type === "string" && typeof defaultValue === "string" || 846 - primitive.type === "integer" && typeof defaultValue === "number" || 847 - primitive.type === "boolean" && typeof defaultValue === "boolean")) { 848 - primitive.default = defaultValue; 849 - } 1075 + private applyPropertyMetadata(primitive: any, prop: ModelProperty) { 1076 + // Apply const value 1077 + const constValue = getLexConst(this.program, prop); 1078 + if ( 1079 + constValue !== undefined && 1080 + this.isValidConstForType(primitive.type, constValue) 1081 + ) { 1082 + primitive.const = constValue; 1083 + } 850 1084 851 - if (primitive.type === "integer" || primitive.type === "number") { 852 - const minValue = getMinValue(this.program, prop); 853 - if (minValue !== undefined) primitive.minimum = minValue; 854 - const maxValue = getMaxValue(this.program, prop); 855 - if (maxValue !== undefined) primitive.maximum = maxValue; 856 - } 1085 + // Apply default value 1086 + const defaultValue = (prop as any).default?.value; 1087 + if ( 1088 + defaultValue !== undefined && 1089 + this.isValidDefaultForType(primitive.type, defaultValue) 1090 + ) { 1091 + primitive.default = defaultValue; 857 1092 } 1093 + } 858 1094 859 - return primitive; 1095 + private isValidConstForType(primitiveType: string, constValue: any): boolean { 1096 + return ( 1097 + (primitiveType === "boolean" && typeof constValue === "boolean") || 1098 + (primitiveType === "string" && typeof constValue === "string") || 1099 + (primitiveType === "integer" && typeof constValue === "number") 1100 + ); 1101 + } 1102 + 1103 + private isValidDefaultForType( 1104 + primitiveType: string, 1105 + defaultValue: any, 1106 + ): boolean { 1107 + return ( 1108 + (primitiveType === "string" && typeof defaultValue === "string") || 1109 + (primitiveType === "integer" && typeof defaultValue === "number") || 1110 + (primitiveType === "boolean" && typeof defaultValue === "boolean") 1111 + ); 860 1112 } 861 1113 862 1114 private getModelReference(model: Model): string | null { 863 - if (!model.name || !model.namespace || model.namespace.name === "TypeSpec") return null; 1115 + if (!model.name || !model.namespace || model.namespace.name === "TypeSpec") 1116 + return null; 864 1117 865 1118 const namespaceName = getNamespaceFullName(model.namespace); 866 1119 if (!namespaceName) return null; 867 1120 868 1121 const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 869 1122 870 - if (this.currentLexiconId === namespaceName || this.currentLexiconId === `${namespaceName}.defs`) { 1123 + if ( 1124 + this.currentLexiconId === namespaceName || 1125 + this.currentLexiconId === `${namespaceName}.defs` 1126 + ) { 871 1127 return `#${defName}`; 872 1128 } 873 1129 874 - return model.name === "Main" ? namespaceName : `${namespaceName}#${defName}`; 1130 + return model.name === "Main" 1131 + ? namespaceName 1132 + : `${namespaceName}#${defName}`; 875 1133 } 876 1134 877 1135 private getUnionReference(union: Union): string | null { ··· 884 1142 885 1143 const defName = unionName.charAt(0).toLowerCase() + unionName.slice(1); 886 1144 887 - if (this.currentLexiconId === namespaceName || this.currentLexiconId === `${namespaceName}.defs`) { 1145 + if ( 1146 + this.currentLexiconId === namespaceName || 1147 + this.currentLexiconId === `${namespaceName}.defs` 1148 + ) { 888 1149 return `#${defName}`; 889 1150 } 890 1151 ··· 917 1178 return null; 918 1179 } 919 1180 920 - private getModelLexiconId(model: Model): string | null { 921 - if (!model.namespace) return null; 922 - 923 - const namespaceName = getNamespaceFullName(model.namespace); 924 - if (!namespaceName) return null; 925 - 926 - if (model.name === "Main") return namespaceName; 927 - 928 - return `${namespaceName}.${model.name.charAt(0).toLowerCase() + model.name.slice(1)}`; 929 - } 930 - 931 1181 private getLexiconPath(lexiconId: string): string { 932 1182 const parts = lexiconId.split("."); 933 - return join(this.options.outputDir, ...parts.slice(0, -1), parts[parts.length - 1] + ".json"); 1183 + return join( 1184 + this.options.outputDir, 1185 + ...parts.slice(0, -1), 1186 + parts[parts.length - 1] + ".json", 1187 + ); 934 1188 } 935 1189 936 1190 private async writeFile(filePath: string, content: string) {
+5
packages/example/src/lexicons.ts
··· 69 69 }, 70 70 }, 71 71 }, 72 + notificationType: { 73 + type: 'string', 74 + knownValues: ['like', 'repost', 'follow', 'mention', 'reply'], 75 + description: 'Type of notification', 76 + }, 72 77 }, 73 78 }, 74 79 AppExampleFollow: {
+9
packages/example/src/types/app/example/defs.ts
··· 68 68 export function validateEntity<V>(v: V) { 69 69 return validate<Entity & V>(v, id, hashEntity) 70 70 } 71 + 72 + /** Type of notification */ 73 + export type NotificationType = 74 + | 'like' 75 + | 'repost' 76 + | 'follow' 77 + | 'mention' 78 + | 'reply' 79 + | (string & {})