An experimental TypeSpec syntax for Lexicon

simp

+479 -1183
+479 -1183
packages/emitter/src/emitter.ts
··· 78 78 private processNamespace(ns: any) { 79 79 const fullName = getNamespaceFullName(ns); 80 80 81 - // Skip built-in TypeSpec namespaces but still process their children 82 81 if (fullName && !fullName.startsWith("TypeSpec")) { 83 - // Check if this namespace should be a lexicon file 84 82 const hasModels = ns.models.size > 0; 85 83 const hasScalars = ns.scalars.size > 0; 86 84 const hasUnions = ns.unions?.size > 0; 87 85 const hasOperations = ns.operations?.size > 0; 88 86 const hasChildNamespaces = ns.namespaces.size > 0; 89 - 90 - // Heuristic: if namespace has models/scalars/unions but no operations and no child namespaces 91 - const shouldEmitLexicon = 92 - (hasModels || hasScalars || hasUnions) && 93 - !hasOperations && 94 - !hasChildNamespaces; 95 - 96 - if (shouldEmitLexicon) { 97 - const models = [...ns.models.values()]; 98 - const lexiconId = fullName; 99 - const isDefsFile = fullName.endsWith(".defs"); 100 - 101 - // Find Main model (if not a .defs file) 102 - const mainModel = isDefsFile 103 - ? null 104 - : models.find((m) => m.name === "Main"); 105 - const otherModels = models.filter((m) => m.name !== "Main"); 106 - 107 - // Case 1: Namespace ends with .defs -> shared defs file (no main) 108 - if (isDefsFile) { 109 - this.currentLexiconId = lexiconId; 110 - 111 - const lexicon: LexiconDocument = { 112 - lexicon: 1, 113 - id: lexiconId, 114 - defs: {}, 115 - }; 116 - 117 - const nsDescription = getDoc(this.program, ns); 118 - if (nsDescription) { 119 - lexicon.description = nsDescription; 120 - } 121 - 122 - // All models go into defs 123 - for (const model of models) { 124 - this.addModelToDefs(lexicon, model); 125 - } 126 - 127 - // All scalars go into defs 128 - for (const [_, scalar] of ns.scalars) { 129 - this.addScalarToDefs(lexicon, scalar); 130 - } 131 - 132 - // All unions go into defs 133 - if (ns.unions) { 134 - for (const [_, union] of ns.unions) { 135 - this.addUnionToDefs(lexicon, union); 136 - } 137 - } 138 - 139 - this.lexicons.set(lexiconId, lexicon); 140 - this.currentLexiconId = null; 141 - } 142 - // Case 2: Model named "Main" -> lexicon with main + other defs 143 - else if (mainModel) { 144 - this.currentLexiconId = lexiconId; 145 - 146 - // Explicit description rules: 147 - // - Namespace @doc -> lexicon.description 148 - // - Main model @doc -> defs.main.description 149 - const nsDescription = getDoc(this.program, ns); 150 - const modelDescription = getDoc(this.program, mainModel); 151 - 152 - // Check if this is a record type 153 - const recordKey = getRecordKey(this.program, mainModel); 154 - 155 - // For Main model, always include its description on the def 156 - const modelDef = this.modelToLexiconObject( 157 - mainModel, 158 - modelDescription !== undefined, 159 - ); 160 - let mainDef: any = modelDef; 161 - 162 - if (recordKey) { 163 - // Wrap the object in a record structure 164 - mainDef = { 165 - type: "record", 166 - key: recordKey, 167 - record: modelDef, 168 - }; 169 - 170 - // For records, move description from record object to record def 171 - if (modelDescription) { 172 - mainDef.description = modelDescription; 173 - delete modelDef.description; 174 - } 175 - } 176 - 177 - const lexicon: LexiconDocument = { 178 - lexicon: 1, 179 - id: lexiconId, 180 - defs: { 181 - main: mainDef, 182 - }, 183 - }; 184 - 185 - // Namespace description always goes to lexicon level 186 - if (nsDescription) { 187 - lexicon.description = nsDescription; 188 - } 189 - 190 - // Add other models as additional defs in same file 191 - for (const model of otherModels) { 192 - this.addModelToDefs(lexicon, model); 193 - } 87 + const hasContent = hasModels || hasScalars || hasUnions; 194 88 195 - // Add scalars as defs 196 - for (const [_, scalar] of ns.scalars) { 197 - this.addScalarToDefs(lexicon, scalar); 198 - } 199 - 200 - // Add unions as defs 201 - if (ns.unions) { 202 - for (const [_, union] of ns.unions) { 203 - this.addUnionToDefs(lexicon, union); 204 - } 205 - } 89 + if (hasOperations) { 90 + this.emitOperationLexicon(ns, fullName); 91 + } else if (hasContent && !hasChildNamespaces) { 92 + this.emitContentLexicon(ns, fullName); 93 + } else if (hasContent && hasChildNamespaces) { 94 + this.emitDefsLexicon(ns, fullName); 95 + } 96 + } 206 97 207 - this.lexicons.set(lexiconId, lexicon); 208 - this.currentLexiconId = null; 209 - } 210 - // Case 3: No Main model and not .defs -> error 211 - else { 212 - // Namespace has models/scalars but no Main model and doesn't end with .defs 213 - // This is an error - user must be explicit 214 - throw new Error( 215 - `Namespace "${fullName}" has models/scalars but no Main model. ` + 216 - `Either add a "model Main" or rename namespace to "${fullName}.defs" for shared definitions.`, 217 - ); 218 - } 219 - } else if ( 220 - (hasModels || hasScalars || hasUnions) && 221 - !hasOperations && 222 - hasChildNamespaces 223 - ) { 224 - // Namespace has both models and child namespaces 225 - // Only create a .defs file if the namespace doesn't already end with .defs 226 - const isDefsNs = fullName.endsWith(".defs"); 227 - const lexiconId = isDefsNs ? fullName : fullName + ".defs"; 228 - this.currentLexiconId = lexiconId; 98 + for (const [_, childNs] of ns.namespaces) { 99 + this.processNamespace(childNs); 100 + } 101 + } 229 102 230 - const lexicon: LexiconDocument = { 231 - lexicon: 1, 232 - id: lexiconId, 233 - defs: {}, 234 - }; 103 + private emitContentLexicon(ns: any, fullName: string) { 104 + const models = [...ns.models.values()]; 105 + const isDefsFile = fullName.endsWith(".defs"); 106 + const mainModel = isDefsFile ? null : models.find((m) => m.name === "Main"); 235 107 236 - const nsDescription = getDoc(this.program, ns); 237 - if (nsDescription) { 238 - lexicon.description = nsDescription; 239 - } 108 + if (!isDefsFile && !mainModel) { 109 + throw new Error( 110 + `Namespace "${fullName}" has models/scalars but no Main model. ` + 111 + `Either add a "model Main" or rename namespace to "${fullName}.defs" for shared definitions.`, 112 + ); 113 + } 240 114 241 - for (const [_, model] of ns.models) { 242 - this.addModelToDefs(lexicon, model); 243 - } 115 + this.currentLexiconId = fullName; 116 + const lexicon = this.createLexicon(fullName, ns); 244 117 245 - for (const [_, scalar] of ns.scalars) { 246 - this.addScalarToDefs(lexicon, scalar); 247 - } 118 + if (mainModel) { 119 + lexicon.defs.main = this.createMainDef(mainModel); 120 + this.addDefs(lexicon, ns, models.filter((m) => m.name !== "Main")); 121 + } else { 122 + this.addDefs(lexicon, ns, models); 123 + } 248 124 249 - if (ns.unions) { 250 - for (const [_, union] of ns.unions) { 251 - this.addUnionToDefs(lexicon, union); 252 - } 253 - } 125 + this.lexicons.set(fullName, lexicon); 126 + this.currentLexiconId = null; 127 + } 254 128 255 - this.lexicons.set(lexiconId, lexicon); 256 - this.currentLexiconId = null; 257 - } else if (hasOperations) { 258 - // Process operations for queries and procedures 259 - const lexiconId = fullName!; 260 - const lexicon: LexiconDocument = { 261 - lexicon: 1, 262 - id: lexiconId, 263 - defs: {}, 264 - }; 129 + private emitDefsLexicon(ns: any, fullName: string) { 130 + const lexiconId = fullName.endsWith(".defs") ? fullName : fullName + ".defs"; 131 + this.currentLexiconId = lexiconId; 132 + const lexicon = this.createLexicon(lexiconId, ns); 133 + this.addDefs(lexicon, ns, [...ns.models.values()]); 134 + this.lexicons.set(lexiconId, lexicon); 135 + this.currentLexiconId = null; 136 + } 265 137 266 - this.currentLexiconId = lexiconId; 138 + private emitOperationLexicon(ns: any, fullName: string) { 139 + this.currentLexiconId = fullName; 140 + const lexicon = this.createLexicon(fullName, ns); 267 141 268 - // Check if there's a Main operation 269 - let mainOp = null; 270 - for (const [name, operation] of ns.operations) { 271 - if (name === "main" || name === "Main") { 272 - mainOp = operation; 273 - break; 274 - } 275 - } 142 + const mainOp = [...ns.operations].find( 143 + ([name]) => name === "main" || name === "Main" 144 + )?.[1]; 276 145 277 - if (mainOp) { 278 - this.addOperationToDefs(lexicon, mainOp, "main"); 279 - } 146 + if (mainOp) { 147 + this.addOperationToDefs(lexicon, mainOp, "main"); 148 + } 280 149 281 - // Add other operations as defs 282 - for (const [name, operation] of ns.operations) { 283 - if (name !== "main" && name !== "Main") { 284 - this.addOperationToDefs(lexicon, operation, name); 285 - } 286 - } 150 + for (const [name, operation] of ns.operations) { 151 + if (name !== "main" && name !== "Main") { 152 + this.addOperationToDefs(lexicon, operation, name); 153 + } 154 + } 287 155 288 - // Also add any models in this namespace (like getLikes.Like) 289 - for (const [_, model] of ns.models) { 290 - if (model.name !== "Main") { 291 - this.addModelToDefs(lexicon, model); 292 - } 293 - } 156 + this.addDefs(lexicon, ns, [...ns.models.values()].filter((m) => m.name !== "Main")); 157 + this.lexicons.set(fullName, lexicon); 158 + this.currentLexiconId = null; 159 + } 294 160 295 - // Add scalars 296 - for (const [_, scalar] of ns.scalars) { 297 - this.addScalarToDefs(lexicon, scalar); 298 - } 161 + private createLexicon(id: string, ns: any): LexiconDocument { 162 + const lexicon: LexiconDocument = { lexicon: 1, id, defs: {} }; 163 + const description = getDoc(this.program, ns); 164 + if (description) lexicon.description = description; 165 + return lexicon; 166 + } 299 167 300 - // Add unions 301 - if (ns.unions) { 302 - for (const [_, union] of ns.unions) { 303 - this.addUnionToDefs(lexicon, union); 304 - } 305 - } 168 + private createMainDef(mainModel: Model): any { 169 + const modelDescription = getDoc(this.program, mainModel); 170 + const recordKey = getRecordKey(this.program, mainModel); 171 + const modelDef = this.modelToLexiconObject(mainModel, !!modelDescription); 306 172 307 - this.lexicons.set(lexiconId, lexicon); 308 - this.currentLexiconId = null; 173 + if (recordKey) { 174 + const recordDef: any = { type: "record", key: recordKey, record: modelDef }; 175 + if (modelDescription) { 176 + recordDef.description = modelDescription; 177 + delete modelDef.description; 309 178 } 179 + return recordDef; 310 180 } 311 181 312 - // Always recursively process child namespaces 313 - for (const [_, childNs] of ns.namespaces) { 314 - this.processNamespace(childNs); 182 + return modelDef; 183 + } 184 + 185 + private addDefs(lexicon: LexiconDocument, ns: any, models: Model[]) { 186 + for (const model of models) { 187 + this.addModelToDefs(lexicon, model); 188 + } 189 + for (const [_, scalar] of ns.scalars) { 190 + this.addScalarToDefs(lexicon, scalar); 191 + } 192 + if (ns.unions) { 193 + for (const [_, union] of ns.unions) { 194 + this.addUnionToDefs(lexicon, union); 195 + } 315 196 } 316 197 } 317 198 318 - /** 319 - * Add a model to a lexicon's defs object 320 - */ 321 199 private addModelToDefs(lexicon: LexiconDocument, model: Model) { 322 - // Validate PascalCase convention for model names 323 - if (model.name && model.name[0] !== model.name[0].toUpperCase()) { 200 + if (model.name[0] !== model.name[0].toUpperCase()) { 324 201 this.program.reportDiagnostic({ 325 202 code: "invalid-model-name", 326 203 severity: "error", ··· 330 207 return; 331 208 } 332 209 333 - const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 210 + if (isErrorModel(this.program, model)) return; 334 211 335 - // Skip error models - they're not emitted as defs 336 - if (isErrorModel(this.program, model)) { 337 - return; 338 - } 212 + const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 213 + const description = getDoc(this.program, model); 339 214 340 - // Check if this is a token type 341 215 if (isToken(this.program, model)) { 342 - const tokenDef: any = { 343 - type: "token", 344 - }; 345 - 346 - const description = getDoc(this.program, model); 347 - if (description) { 348 - tokenDef.description = description; 349 - } 350 - 351 - lexicon.defs[defName] = tokenDef; 216 + lexicon.defs[defName] = this.addDescription({ type: "token" }, description); 352 217 return; 353 218 } 354 219 355 - // Check if this model is actually an array type (via `is` declaration) 356 - // e.g., `model Preferences is SomeUnion[]` 357 220 if (isArrayModelType(this.program, model)) { 358 221 const arrayDef = this.modelToLexiconArray(model); 359 222 if (arrayDef) { 360 - const description = getDoc(this.program, model); 361 - if (description && !arrayDef.description) { 362 - arrayDef.description = description; 363 - } 364 - lexicon.defs[defName] = arrayDef; 223 + lexicon.defs[defName] = this.addDescription(arrayDef, description); 365 224 return; 366 225 } 367 226 } 368 227 369 228 const modelDef = this.modelToLexiconObject(model); 370 - 371 - const description = getDoc(this.program, model); 372 - if (description && !modelDef.description) { 373 - modelDef.description = description; 374 - } 375 - 376 - lexicon.defs[defName] = modelDef; 229 + lexicon.defs[defName] = this.addDescription(modelDef, description); 377 230 } 378 231 379 - /** 380 - * Add a scalar to a lexicon's defs object 381 - */ 382 232 private addScalarToDefs(lexicon: LexiconDocument, scalar: Scalar) { 383 - // Skip built-in TypeSpec scalars 384 - if (scalar.namespace && scalar.namespace.name === "TypeSpec") { 385 - return; 386 - } 387 - 388 - // Skip scalars that extend built-in types - they should be inlined 389 - // Only add scalars that are explicitly marked as atproto format scalars 390 - const baseScalar = scalar.baseScalar; 391 - if (baseScalar && baseScalar.namespace?.name === "TypeSpec") { 392 - // This scalar just adds constraints to a built-in type 393 - // Don't emit as a separate def - will be inlined where used 394 - return; 395 - } 233 + if (scalar.namespace?.name === "TypeSpec") return; 234 + if (scalar.baseScalar?.namespace?.name === "TypeSpec") return; 396 235 397 236 const defName = scalar.name.charAt(0).toLowerCase() + scalar.name.slice(1); 398 - 399 - // Convert scalar to lexicon primitive/string def 400 - const scalarDef: any = this.scalarToLexiconPrimitive(scalar, undefined); 401 - 237 + const scalarDef = this.scalarToLexiconPrimitive(scalar, undefined); 402 238 const description = getDoc(this.program, scalar); 403 - if (description && !scalarDef.description) { 404 - scalarDef.description = description; 405 - } 406 - 407 - lexicon.defs[defName] = scalarDef; 239 + lexicon.defs[defName] = this.addDescription(scalarDef, description); 408 240 } 409 241 410 - /** 411 - * Add a union to a lexicon's defs object 412 - */ 413 242 private addUnionToDefs(lexicon: LexiconDocument, union: Union) { 414 - const defName = 415 - (union as any).name?.charAt(0).toLowerCase() + 416 - (union as any).name?.slice(1); 417 - if (!defName) return; 243 + const name = (union as any).name; 244 + if (!name) return; 418 245 419 - // Convert union to lexicon def - pass true to prevent self-reference 420 246 const unionDef: any = this.typeToLexiconDefinition(union, undefined, true); 421 247 if (!unionDef) return; 422 248 423 - const description = getDoc(this.program, union); 249 + if (unionDef.type === "union" || (unionDef.type === "string" && unionDef.knownValues)) { 250 + const defName = name.charAt(0).toLowerCase() + name.slice(1); 251 + const description = getDoc(this.program, union); 252 + lexicon.defs[defName] = this.addDescription(unionDef, description); 253 + } 254 + } 424 255 425 - // Emit union def if it's: 426 - // 1. A union of model refs (type: "union") 427 - // 2. A string primitive with knownValues (string literals + string type) 428 - if (unionDef.type === "union" || (unionDef.type === "string" && (unionDef as any).knownValues)) { 429 - if (description && !unionDef.description) { 430 - unionDef.description = description; 431 - } 432 - lexicon.defs[defName] = unionDef; 256 + private addDescription(obj: any, description?: string): any { 257 + if (description && !obj.description) { 258 + obj.description = description; 433 259 } 260 + return obj; 434 261 } 435 262 436 - private addOperationToDefs( 437 - lexicon: LexiconDocument, 438 - operation: any, 439 - defName: string, 440 - ) { 441 - const description = getDoc(this.program, operation); 263 + private createBlobDef(model: Model): LexiconBlob { 264 + const blobDef: LexiconBlob = { type: "blob" }; 265 + 266 + if (isTemplateInstance(model)) { 267 + const templateArgs = model.templateMapper?.args; 268 + if (templateArgs?.length >= 2) { 269 + const acceptArg = templateArgs[0] as any; 270 + let acceptTypes: string[] | undefined; 271 + 272 + if (acceptArg?.type?.kind === "Tuple") { 273 + const tuple = acceptArg.type; 274 + if (tuple.values?.length > 0) { 275 + acceptTypes = tuple.values 276 + .map((v: any) => (v.kind === "String" ? v.value : null)) 277 + .filter((v: string | null) => v !== null) as string[]; 278 + if (!acceptTypes.length) acceptTypes = undefined; 279 + } 280 + } else if (acceptArg && Array.isArray(acceptArg.value)) { 281 + const values = acceptArg.value.filter((v: any) => typeof v === "string"); 282 + if (values.length) acceptTypes = values; 283 + } 442 284 443 - if (isQuery(this.program, operation)) { 444 - // Build query definition 445 - const queryDef: any = { 446 - type: "query", 447 - }; 285 + if (acceptTypes) blobDef.accept = acceptTypes; 448 286 449 - if (description) { 450 - queryDef.description = description; 287 + const maxSizeArg = templateArgs[1] as any; 288 + const maxSize = maxSizeArg?.value ?? (maxSizeArg?.type?.kind === "Number" ? Number(maxSizeArg.type.value) : undefined); 289 + if (maxSize !== undefined && maxSize !== 0) blobDef.maxSize = maxSize; 451 290 } 291 + } 452 292 453 - // Handle parameters - only include if operation has actual parameters 454 - if ( 455 - operation.parameters && 456 - operation.parameters.properties && 457 - operation.parameters.properties.size > 0 458 - ) { 459 - const params: any = { 460 - type: "params", 461 - properties: {}, 462 - }; 293 + return blobDef; 294 + } 463 295 464 - const required: string[] = []; 296 + private processUnion(unionType: Union, prop?: ModelProperty): LexiconDefinition | null { 297 + const unionRefs: string[] = []; 298 + const stringLiterals: string[] = []; 299 + let hasStringType = false; 300 + let hasUnknown = false; 465 301 466 - for (const [paramName, param] of operation.parameters.properties) { 467 - const paramDef = this.typeToLexiconDefinition(param.type, param); 468 - if (paramDef) { 469 - params.properties[paramName] = paramDef; 470 - if (!param.optional) { 471 - required.push(paramName); 472 - } 473 - } 302 + 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; 474 314 } 315 + } 316 + } 475 317 476 - if (required.length > 0) { 477 - params.required = required; 478 - } 318 + if (stringLiterals.length && hasStringType && !unionRefs.length) { 319 + const primitive: any = { type: "string", knownValues: stringLiterals }; 479 320 480 - queryDef.parameters = params; 481 - } 321 + const maxLength = getMaxLength(this.program, unionType); 322 + if (maxLength !== undefined) primitive.maxLength = maxLength; 482 323 483 - // Handle output 484 - const customEncoding = getEncoding(this.program, operation); 485 - if (operation.returnType && operation.returnType.kind !== "Intrinsic") { 486 - const outputSchema = this.typeToLexiconDefinition(operation.returnType); 487 - if (outputSchema) { 488 - queryDef.output = { 489 - encoding: customEncoding || "application/json", 490 - schema: outputSchema, 491 - }; 492 - } 493 - } else if (customEncoding) { 494 - // Custom encoding with no schema (e.g., application/jsonl with void return) 495 - queryDef.output = { 496 - encoding: customEncoding, 497 - }; 498 - } 324 + const minLength = getMinLength(this.program, unionType); 325 + if (minLength !== undefined) primitive.minLength = minLength; 499 326 500 - // Handle errors 501 - const errors = getErrors(this.program, operation); 502 - if (errors && errors.length > 0) { 503 - queryDef.errors = errors; 504 - } 327 + const maxGraphemes = getMaxGraphemes(this.program, unionType); 328 + if (maxGraphemes !== undefined) primitive.maxGraphemes = maxGraphemes; 505 329 506 - lexicon.defs[defName] = queryDef; 507 - } else if (isProcedure(this.program, operation)) { 508 - // Build procedure definition 509 - const procedureDef: any = { 510 - type: "procedure", 511 - }; 330 + const minGraphemes = getMinGraphemes(this.program, unionType); 331 + if (minGraphemes !== undefined) primitive.minGraphemes = minGraphemes; 512 332 513 - if (description) { 514 - procedureDef.description = description; 333 + if (prop) { 334 + const propDesc = getDoc(this.program, prop); 335 + if (propDesc) primitive.description = propDesc; 336 + 337 + const defaultValue = (prop as any).default; 338 + if (defaultValue?.value !== undefined && typeof defaultValue.value === "string") { 339 + primitive.default = defaultValue.value; 340 + } 515 341 } 342 + return primitive; 343 + } 516 344 517 - // Validate and parse operation parameters 518 - if (operation.parameters && operation.parameters.properties.size > 0) { 519 - const paramCount = operation.parameters.properties.size; 520 - const params = Array.from(operation.parameters.properties) as [ 521 - string, 522 - any, 523 - ][]; 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 + } 524 359 525 - // Validate param count 526 - if (paramCount > 2) { 360 + const unionDef: any = { type: "union", refs: unionRefs }; 361 + 362 + if (isClosed(this.program, unionType)) { 363 + if (hasUnknown) { 527 364 this.program.reportDiagnostic({ 528 - code: "procedure-too-many-params", 365 + code: "closed-open-union", 529 366 severity: "error", 530 367 message: 531 - "Procedures can have at most 2 parameters (input and/or parameters)", 532 - target: operation, 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, 533 370 }); 534 - } else if (paramCount === 1) { 535 - // Single param: must be named "input" 536 - const [paramName, param] = params[0] as [string, any]; 371 + } else { 372 + unionDef.closed = true; 373 + } 374 + } 537 375 538 - if (paramName !== "input") { 539 - this.program.reportDiagnostic({ 540 - code: "procedure-invalid-param-name", 541 - severity: "error", 542 - message: `Procedure parameter must be named "input", got "${paramName}"`, 543 - target: param, 544 - }); 545 - } 376 + const propDesc = prop ? getDoc(this.program, prop) : undefined; 377 + return this.addDescription(unionDef, propDesc); 378 + } 546 379 547 - // Treat as input 548 - const inputSchema = this.typeToLexiconDefinition(param.type); 549 - if (inputSchema) { 550 - const inputEncoding = getEncoding(this.program, param); 551 - procedureDef.input = { 552 - encoding: inputEncoding || "application/json", 553 - schema: inputSchema, 554 - }; 555 - } 556 - } else if (paramCount === 2) { 557 - // Two params: must be "input" and "parameters" 558 - const [param1Name, param1] = params[0] as [string, any]; 559 - const [param2Name, param2] = params[1] as [string, any]; 380 + if (!stringLiterals.length && !hasUnknown) { 381 + this.program.reportDiagnostic({ 382 + code: "union-empty", 383 + severity: "error", 384 + message: `Union has no variants. Atproto unions must contain either model references or string literals.`, 385 + target: unionType, 386 + }); 387 + } 560 388 561 - if (param1Name !== "input") { 562 - this.program.reportDiagnostic({ 563 - code: "procedure-invalid-first-param", 564 - severity: "error", 565 - message: `First parameter must be named "input", got "${param1Name}"`, 566 - target: param1, 567 - }); 568 - } 389 + return null; 390 + } 391 + 392 + private addOperationToDefs( 393 + lexicon: LexiconDocument, 394 + operation: any, 395 + defName: string, 396 + ) { 397 + const description = getDoc(this.program, operation); 398 + 399 + if (isQuery(this.program, operation)) { 400 + const queryDef: any = { type: "query" }; 401 + this.addDescription(queryDef, description); 402 + this.addParameters(queryDef, operation); 403 + this.addOutput(queryDef, operation); 404 + this.addErrors(queryDef, operation); 405 + lexicon.defs[defName] = queryDef; 406 + } else if (isProcedure(this.program, operation)) { 407 + const procedureDef: any = { type: "procedure" }; 408 + this.addDescription(procedureDef, description); 409 + this.addProcedureParams(procedureDef, operation); 410 + this.addOutput(procedureDef, operation); 411 + this.addErrors(procedureDef, operation); 412 + lexicon.defs[defName] = procedureDef; 413 + } else if (isSubscription(this.program, operation)) { 414 + const subscriptionDef: any = { type: "subscription" }; 415 + this.addDescription(subscriptionDef, description); 416 + this.addParameters(subscriptionDef, operation); 417 + this.addMessage(subscriptionDef, operation); 418 + this.addErrors(subscriptionDef, operation); 419 + lexicon.defs[defName] = subscriptionDef; 420 + } 421 + } 569 422 570 - if (param2Name !== "parameters") { 571 - this.program.reportDiagnostic({ 572 - code: "procedure-invalid-second-param", 573 - severity: "error", 574 - message: `Second parameter must be named "parameters", got "${param2Name}"`, 575 - target: param2, 576 - }); 577 - } 423 + private addParameters(def: any, operation: any) { 424 + if (!operation.parameters?.properties?.size) return; 578 425 579 - // Validate that parameters is a plain object (not a model reference) 580 - if (param2.type.kind !== "Model" || (param2.type as any).name) { 581 - this.program.reportDiagnostic({ 582 - code: "procedure-parameters-not-object", 583 - severity: "error", 584 - message: 585 - "The 'parameters' parameter must be a plain object, not a model reference", 586 - target: param2, 587 - }); 588 - } 426 + const params: any = { type: "params", properties: {} }; 427 + const required: string[] = []; 589 428 590 - // Handle input (first param) 591 - const inputSchema = this.typeToLexiconDefinition(param1.type); 592 - if (inputSchema) { 593 - const inputEncoding = getEncoding(this.program, param1); 594 - procedureDef.input = { 595 - encoding: inputEncoding || "application/json", 596 - schema: inputSchema, 597 - }; 598 - } 429 + for (const [paramName, param] of operation.parameters.properties) { 430 + const paramDef = this.typeToLexiconDefinition(param.type, param); 431 + if (paramDef) { 432 + params.properties[paramName] = paramDef; 433 + if (!param.optional) required.push(paramName); 434 + } 435 + } 599 436 600 - // Handle parameters (second param) 601 - const parametersModel = param2.type as any; 602 - if (parametersModel.kind === "Model" && parametersModel.properties) { 603 - const paramsObj: any = { 604 - type: "params", 605 - properties: {}, 606 - }; 437 + if (required.length) params.required = required; 438 + def.parameters = params; 439 + } 607 440 608 - const required: string[] = []; 441 + private addProcedureParams(def: any, operation: any) { 442 + if (!operation.parameters?.properties?.size) return; 609 443 610 - for (const [propName, prop] of parametersModel.properties) { 611 - const propDef = this.typeToLexiconDefinition(prop.type, prop); 612 - if (propDef) { 613 - paramsObj.properties[propName] = propDef; 614 - if (!prop.optional) { 615 - required.push(propName); 616 - } 617 - } 618 - } 444 + const params = Array.from(operation.parameters.properties) as [string, any][]; 445 + const paramCount = params.length; 619 446 620 - if (required.length > 0) { 621 - paramsObj.required = required; 622 - } 447 + if (paramCount > 2) { 448 + this.program.reportDiagnostic({ 449 + code: "procedure-too-many-params", 450 + severity: "error", 451 + message: "Procedures can have at most 2 parameters (input and/or parameters)", 452 + target: operation, 453 + }); 454 + return; 455 + } 623 456 624 - procedureDef.parameters = paramsObj; 625 - } 626 - } 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 + }); 627 466 } 467 + this.addInput(def, param); 468 + } else if (paramCount === 2) { 469 + const [param1Name, param1] = params[0]; 470 + const [param2Name, param2] = params[1]; 628 471 629 - // Handle output (with custom encoding on operation) 630 - const outputEncoding = getEncoding(this.program, operation); 631 - if (operation.returnType && operation.returnType.kind !== "Intrinsic") { 632 - const outputSchema = this.typeToLexiconDefinition(operation.returnType); 633 - if (outputSchema) { 634 - procedureDef.output = { 635 - encoding: outputEncoding || "application/json", 636 - schema: outputSchema, 637 - }; 638 - } 639 - } else if (outputEncoding) { 640 - // Custom encoding with no schema (e.g., application/jsonl with void return) 641 - procedureDef.output = { 642 - encoding: outputEncoding, 643 - }; 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 + }); 644 479 } 645 480 646 - // Handle errors 647 - const errors = getErrors(this.program, operation); 648 - if (errors && errors.length > 0) { 649 - procedureDef.errors = errors; 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 + }); 650 488 } 651 489 652 - lexicon.defs[defName] = procedureDef; 653 - } else if (isSubscription(this.program, operation)) { 654 - // Build subscription definition 655 - const subscriptionDef: any = { 656 - type: "subscription", 657 - }; 658 - 659 - if (description) { 660 - subscriptionDef.description = description; 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 + }); 661 497 } 662 498 663 - // Handle parameters (same as query) 664 - if (operation.parameters && operation.parameters.properties.size > 0) { 665 - const params: any = { 666 - type: "params", 667 - properties: {}, 668 - }; 499 + this.addInput(def, param1); 669 500 501 + const parametersModel = param2.type as any; 502 + if (parametersModel.kind === "Model" && parametersModel.properties) { 503 + const paramsObj: any = { type: "params", properties: {} }; 670 504 const required: string[] = []; 671 505 672 - for (const [paramName, param] of operation.parameters.properties) { 673 - const paramDef = this.typeToLexiconDefinition(param.type, param); 674 - if (paramDef) { 675 - params.properties[paramName] = paramDef; 676 - if (!param.optional) { 677 - required.push(paramName); 678 - } 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); 679 511 } 680 512 } 681 513 682 - if (required.length > 0) { 683 - params.required = required; 684 - } 685 - 686 - subscriptionDef.parameters = params; 514 + if (required.length) paramsObj.required = required; 515 + def.parameters = paramsObj; 687 516 } 517 + } 518 + } 688 519 689 - // Handle message (return type must be a union) 690 - if (operation.returnType && operation.returnType.kind === "Union") { 691 - const messageSchema = this.typeToLexiconDefinition( 692 - operation.returnType, 693 - ); 694 - if (messageSchema) { 695 - subscriptionDef.message = { 696 - schema: messageSchema, 697 - }; 698 - } 699 - } else if ( 700 - operation.returnType && 701 - operation.returnType.kind !== "Intrinsic" 702 - ) { 703 - this.program.reportDiagnostic({ 704 - code: "subscription-return-not-union", 705 - severity: "error", 706 - message: "Subscription return type must be a union", 707 - target: operation, 708 - }); 520 + private addInput(def: any, param: any) { 521 + const inputSchema = this.typeToLexiconDefinition(param.type); 522 + if (inputSchema) { 523 + const encoding = getEncoding(this.program, param) || "application/json"; 524 + def.input = { encoding, schema: inputSchema }; 525 + } 526 + } 527 + 528 + private addOutput(def: any, operation: any) { 529 + const encoding = getEncoding(this.program, operation); 530 + if (operation.returnType?.kind !== "Intrinsic") { 531 + const schema = this.typeToLexiconDefinition(operation.returnType); 532 + if (schema) { 533 + def.output = { encoding: encoding || "application/json", schema }; 709 534 } 535 + } else if (encoding) { 536 + def.output = { encoding }; 537 + } 538 + } 710 539 711 - // Handle errors 712 - const errors = getErrors(this.program, operation); 713 - if (errors && errors.length > 0) { 714 - subscriptionDef.errors = errors; 540 + private addMessage(def: any, operation: any) { 541 + if (operation.returnType?.kind === "Union") { 542 + const messageSchema = this.typeToLexiconDefinition(operation.returnType); 543 + if (messageSchema) { 544 + def.message = { schema: messageSchema }; 715 545 } 716 - 717 - lexicon.defs[defName] = subscriptionDef; 546 + } else if (operation.returnType?.kind !== "Intrinsic") { 547 + this.program.reportDiagnostic({ 548 + code: "subscription-return-not-union", 549 + severity: "error", 550 + message: "Subscription return type must be a union", 551 + target: operation, 552 + }); 718 553 } 554 + } 555 + 556 + private addErrors(def: any, operation: any) { 557 + const errors = getErrors(this.program, operation); 558 + if (errors?.length) def.errors = errors; 719 559 } 720 560 721 561 private visitModel(model: Model) { ··· 788 628 model: Model, 789 629 includeModelDescription: boolean = true, 790 630 ): LexiconObject { 791 - const description = includeModelDescription 792 - ? getDoc(this.program, model) 793 - : undefined; 794 631 const required: string[] = []; 795 632 const nullable: string[] = []; 796 633 const properties: any = {}; 797 634 798 635 for (const [name, prop] of model.properties) { 799 - if (prop.optional !== true) { 800 - // Field is required - check if it has the @required decorator 636 + if (!prop.optional) { 801 637 if (!isRequired(this.program, prop)) { 802 638 this.program.reportDiagnostic({ 803 639 code: "closed-open-union-inline", ··· 812 648 required.push(name); 813 649 } 814 650 815 - // Check if property type is a union with null 816 651 let typeToProcess = prop.type; 817 652 if (prop.type.kind === "Union") { 818 - const unionType = prop.type as Union; 819 - const variants = Array.from(unionType.variants.values()); 820 - 821 - // Check if null is one of the variants 653 + const variants = Array.from((prop.type as Union).variants.values()); 822 654 const hasNull = variants.some( 823 655 (v) => v.type.kind === "Intrinsic" && (v.type as any).name === "null", 824 656 ); 825 657 826 658 if (hasNull) { 827 - // Mark this property as nullable 828 659 nullable.push(name); 829 - 830 - // Find the non-null variant 831 660 const nonNullVariant = variants.find( 832 - (v) => 833 - !(v.type.kind === "Intrinsic" && (v.type as any).name === "null"), 661 + (v) => !(v.type.kind === "Intrinsic" && (v.type as any).name === "null"), 834 662 ); 835 - if (nonNullVariant) { 836 - typeToProcess = nonNullVariant.type; 837 - } 663 + if (nonNullVariant) typeToProcess = nonNullVariant.type; 838 664 } 839 665 } 840 666 841 667 const propDef = this.typeToLexiconDefinition(typeToProcess, prop); 842 - if (propDef) { 843 - properties[name] = propDef; 844 - } 845 - } 846 - 847 - // Build object with correct key order 848 - const obj: any = { 849 - type: "object", 850 - }; 851 - 852 - if (description) { 853 - obj.description = description; 668 + if (propDef) properties[name] = propDef; 854 669 } 855 670 856 - if (required.length > 0) { 857 - obj.required = required; 858 - } 859 - 860 - if (nullable.length > 0) { 861 - obj.nullable = nullable; 862 - } 863 - 671 + const obj: any = { type: "object" }; 672 + const description = includeModelDescription ? getDoc(this.program, model) : undefined; 673 + if (description) obj.description = description; 674 + if (required.length) obj.required = required; 675 + if (nullable.length) obj.nullable = nullable; 864 676 obj.properties = properties; 865 - 866 677 return obj; 867 678 } 868 679 ··· 871 682 prop?: ModelProperty, 872 683 isDefining?: boolean, 873 684 ): LexiconDefinition | null { 685 + const propDesc = prop ? getDoc(this.program, prop) : undefined; 686 + 874 687 switch (type.kind) { 875 688 case "Namespace": { 876 - // Handle namespace references - look for Main model in the namespace 877 - const namespace = type as any; 878 - const mainModel = namespace.models?.get("Main"); 689 + const mainModel = (type as any).models?.get("Main"); 879 690 if (mainModel) { 880 691 const ref = this.getModelReference(mainModel); 881 - if (ref) { 882 - const refDef: LexiconRef = { 883 - type: "ref", 884 - ref: ref, 885 - }; 886 - if (prop) { 887 - const propDesc = getDoc(this.program, prop); 888 - if (propDesc) { 889 - refDef.description = propDesc; 890 - } 891 - } 892 - return refDef; 893 - } 692 + if (ref) return this.addDescription({ type: "ref", ref }, propDesc); 894 693 } 895 694 return null; 896 695 } 897 696 case "Enum": { 898 - // Handle enum types - convert to primitive with enum constraint 899 - const enumType = type as any; 900 - const members = Array.from(enumType.members?.values?.() || []); 697 + const members = Array.from((type as any).members?.values?.() || []); 901 698 const values = members.map((m: any) => m.value); 902 - 903 - // Determine if this is a string or integer enum 904 699 const firstValue = values[0]; 905 - const isStringEnum = typeof firstValue === "string"; 906 - const isIntegerEnum = 907 - typeof firstValue === "number" && Number.isInteger(firstValue); 908 700 909 - if (isStringEnum) { 910 - const primitive: LexiconPrimitive = { 911 - type: "string", 912 - enum: values as string[], 913 - }; 914 - if (prop) { 915 - const propDesc = getDoc(this.program, prop); 916 - if (propDesc) { 917 - primitive.description = propDesc; 918 - } 919 - } 920 - return primitive; 921 - } else if (isIntegerEnum) { 922 - const primitive: LexiconPrimitive = { 923 - type: "integer", 924 - enum: values as number[], 925 - }; 926 - if (prop) { 927 - const propDesc = getDoc(this.program, prop); 928 - if (propDesc) { 929 - primitive.description = propDesc; 930 - } 931 - } 932 - return primitive; 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); 933 705 } 934 - // Unsupported enum type (e.g., float) 935 706 return null; 936 707 } 937 708 case "Boolean": { 938 - // Handle boolean literal types (e.g., `true` or `false`) 939 - const booleanType = type as any; 940 - const primitive: LexiconPrimitive = { 709 + return this.addDescription({ 941 710 type: "boolean", 942 - const: booleanType.value, 943 - }; 944 - if (prop) { 945 - const propDesc = getDoc(this.program, prop); 946 - if (propDesc) { 947 - primitive.description = propDesc; 948 - } 949 - } 950 - return primitive; 711 + const: (type as any).value 712 + }, propDesc); 951 713 } 952 714 case "Scalar": 953 715 const scalar = type as Scalar; 954 716 const primitive = this.scalarToLexiconPrimitive(scalar, prop); 955 - if (primitive) { 956 - // Property description takes precedence over scalar description 957 - if (prop) { 958 - const propDesc = getDoc(this.program, prop); 959 - if (propDesc) { 960 - primitive.description = propDesc; 961 - } 962 - } 963 - // If no property description, use scalar's description for user-defined scalars 964 - // Exclude: TypeSpec built-ins and our predefined format scalars 965 - if ( 966 - !primitive.description && 967 - scalar.baseScalar && 968 - scalar.namespace?.name !== "TypeSpec" 969 - ) { 970 - // List of predefined format scalars (from @tlex/emitter and TypeSpec) 971 - const FORMAT_SCALARS = new Set([ 972 - "datetime", 973 - "did", 974 - "handle", 975 - "atUri", 976 - "cid", 977 - "tid", 978 - "nsid", 979 - "recordKey", 980 - "uri", 981 - "language", 982 - "atIdentifier", 983 - "bytes", 984 - "utcDateTime", 985 - "offsetDateTime", 986 - "plainDate", 987 - "plainTime", 988 - ]); 717 + if (!primitive) return null; 989 718 990 - if (!FORMAT_SCALARS.has(scalar.name)) { 991 - const scalarDesc = getDoc(this.program, scalar); 992 - if (scalarDesc) { 993 - primitive.description = scalarDesc; 994 - } 995 - } 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; 996 730 } 997 731 } 998 732 return primitive; 999 733 case "Model": 1000 - // Check if this is the Blob model or extends Blob 1001 734 const model = type as Model; 1002 735 1003 - // Check if this is a Blob model instance 1004 - // Strategy: Check decorator first (most reliable), then template instance pattern, then extends 1005 736 const isBlobModel = 1006 737 isBlob(this.program, model) || 1007 738 (isTemplateInstance(model) && model.templateNode && isBlob(this.program, model.templateNode as any)) || 1008 739 (model.baseModel && isBlob(this.program, model.baseModel)); 1009 740 1010 741 if (isBlobModel) { 1011 - // Extract template parameters if this is a template instance 1012 - let acceptTypes: string[] | undefined; 1013 - let maxSize: number | undefined; 1014 - 1015 - if (isTemplateInstance(model)) { 1016 - const templateArgs = model.templateMapper?.args; 1017 - if (templateArgs && templateArgs.length >= 2) { 1018 - // First arg is Accept (valueof unknown - tuple) 1019 - const acceptArg = templateArgs[0] as any; 1020 - // Handle both Type and Value cases 1021 - if (acceptArg && acceptArg.type?.kind === "Tuple") { 1022 - const tuple = acceptArg.type; 1023 - if (tuple.values && tuple.values.length > 0) { 1024 - // Extract string values from the tuple 1025 - acceptTypes = tuple.values 1026 - .map((v: any) => (v.kind === "String" ? v.value : null)) 1027 - .filter((v: string | null) => v !== null) as string[]; 1028 - if (acceptTypes.length === 0) acceptTypes = undefined; 1029 - } 1030 - } else if (acceptArg && Array.isArray(acceptArg.value)) { 1031 - // Handle direct array values 1032 - const values = acceptArg.value.filter( 1033 - (v: any) => typeof v === "string", 1034 - ); 1035 - if (values.length > 0) acceptTypes = values; 1036 - } 1037 - 1038 - // Second arg is MaxSize (valueof int32) 1039 - const maxSizeArg = templateArgs[1] as any; 1040 - if (maxSizeArg && typeof maxSizeArg.value === "number") { 1041 - maxSize = maxSizeArg.value; 1042 - } else if (maxSizeArg && maxSizeArg.type?.kind === "Number") { 1043 - maxSize = Number(maxSizeArg.type.value); 1044 - } 1045 - } 1046 - } 1047 - // Always emit as blob type (with or without constraints) 1048 - const blobDef: LexiconBlob = { 1049 - type: "blob", 1050 - }; 1051 - if (acceptTypes) { 1052 - blobDef.accept = acceptTypes; 1053 - } 1054 - if (maxSize !== undefined && maxSize !== 0) { 1055 - blobDef.maxSize = maxSize; 1056 - } 1057 - if (prop) { 1058 - const propDesc = getDoc(this.program, prop); 1059 - if (propDesc) { 1060 - (blobDef as any).description = propDesc; 1061 - } 1062 - } 1063 - return blobDef; 742 + return this.addDescription(this.createBlobDef(model), propDesc); 1064 743 } 1065 744 1066 - // Check if this is a Closed<T> model instance 1067 745 const isClosedModel = 1068 746 model.name === "Closed" || 1069 747 (model.node && (model.node as any).symbol?.name === "Closed") || 1070 - (isTemplateInstance(model) && 1071 - model.node && 1072 - (model.node as any).symbol?.name === "Closed"); 748 + (isTemplateInstance(model) && model.node && (model.node as any).symbol?.name === "Closed"); 1073 749 1074 750 if (isClosedModel && isTemplateInstance(model)) { 1075 - // Extract the union type parameter 1076 - const templateArgs = model.templateMapper?.args; 1077 - if (templateArgs && templateArgs.length > 0) { 1078 - const unionArg = templateArgs[0]; 1079 - if (isType(unionArg) && unionArg.kind === "Union") { 1080 - // Process the union with closed flag 1081 - const unionDef = this.typeToLexiconDefinition(unionArg, prop); 1082 - if (unionDef && unionDef.type === "union") { 1083 - (unionDef as LexiconUnion).closed = true; 1084 - return unionDef; 1085 - } 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; 1086 757 } 1087 758 } 1088 759 } 1089 760 1090 - // Check if this is a reference to another model (including named array models) 1091 - // This must come BEFORE the array check to ensure named arrays are referenced, not inlined 1092 - const modelRef = this.getModelReference(type as Model); 761 + const modelRef = this.getModelReference(model); 1093 762 if (modelRef) { 1094 - const refDef: LexiconRef = { 1095 - type: "ref", 1096 - ref: modelRef, 1097 - }; 1098 - if (prop) { 1099 - const propDesc = getDoc(this.program, prop); 1100 - if (propDesc) { 1101 - refDef.description = propDesc; 1102 - } 1103 - } 1104 - return refDef; 763 + return this.addDescription({ type: "ref", ref: modelRef }, propDesc); 1105 764 } 1106 765 1107 - // Check for anonymous array types (inline arrays like `SomeModel[]` in property) 1108 - if (isArrayModelType(this.program, type as Model)) { 1109 - const array = this.modelToLexiconArray(type as Model, prop); 1110 - if (array && prop) { 1111 - const propDesc = getDoc(this.program, prop); 1112 - if (propDesc) { 1113 - array.description = propDesc; 1114 - } 1115 - } 1116 - return array; 1117 - } 1118 - const obj = this.modelToLexiconObject(type as Model); 1119 - if (prop) { 1120 - const propDesc = getDoc(this.program, prop); 1121 - if (propDesc) { 1122 - obj.description = propDesc; 1123 - } 766 + if (isArrayModelType(this.program, model)) { 767 + return this.addDescription(this.modelToLexiconArray(model, prop), propDesc); 1124 768 } 1125 - return obj; 769 + 770 + return this.addDescription(this.modelToLexiconObject(model), propDesc); 1126 771 case "Union": 1127 - // Handle union types naturally 1128 772 const unionType = type as Union; 1129 773 1130 - // Check if this is a named union that should be referenced 1131 - // (but not if we're defining the union itself) 1132 774 if (!isDefining) { 1133 775 const unionRef = this.getUnionReference(unionType); 1134 776 if (unionRef) { 1135 - const refDef: LexiconRef = { 1136 - type: "ref", 1137 - ref: unionRef, 1138 - }; 1139 - if (prop) { 1140 - const propDesc = getDoc(this.program, prop); 1141 - if (propDesc) { 1142 - refDef.description = propDesc; 1143 - } 1144 - } 1145 - return refDef; 1146 - } 1147 - } 1148 - 1149 - const unionRefs: string[] = []; 1150 - const stringLiterals: string[] = []; 1151 - let hasStringType = false; 1152 - let hasUnknown = false; 1153 - 1154 - // Iterate through all variants in the union 1155 - for (const variant of unionType.variants.values()) { 1156 - if (variant.type.kind === "Model") { 1157 - const ref = this.getModelReference(variant.type as Model); 1158 - if (ref) { 1159 - unionRefs.push(ref); 1160 - } 1161 - } else if (variant.type.kind === "String") { 1162 - // String literal 1163 - stringLiterals.push((variant.type as any).value); 1164 - } else if ( 1165 - variant.type.kind === "Scalar" && 1166 - (variant.type as Scalar).name === "string" 1167 - ) { 1168 - // String type 1169 - hasStringType = true; 1170 - } else if (variant.type.kind === "Intrinsic") { 1171 - // Check for unknown or never (both indicate open union) 1172 - const intrinsicName = (variant.type as any).name; 1173 - if (intrinsicName === "unknown" || intrinsicName === "never") { 1174 - hasUnknown = true; 1175 - } 777 + return this.addDescription({ type: "ref", ref: unionRef }, propDesc); 1176 778 } 1177 779 } 1178 780 1179 - // If union is string literals + string, emit as knownValues 1180 - if ( 1181 - stringLiterals.length > 0 && 1182 - hasStringType && 1183 - unionRefs.length === 0 1184 - ) { 1185 - const primitive: LexiconPrimitive = { 1186 - type: "string", 1187 - knownValues: stringLiterals, 1188 - }; 1189 - 1190 - // Add decorators from the union itself (for def-level constraints) 1191 - const maxLength = getMaxLength(this.program, unionType); 1192 - if (maxLength !== undefined) { 1193 - primitive.maxLength = maxLength; 1194 - } 1195 - 1196 - const minLength = getMinLength(this.program, unionType); 1197 - if (minLength !== undefined) { 1198 - primitive.minLength = minLength; 1199 - } 1200 - 1201 - const maxGraphemes = getMaxGraphemes(this.program, unionType); 1202 - if (maxGraphemes !== undefined) { 1203 - primitive.maxGraphemes = maxGraphemes; 1204 - } 1205 - 1206 - const minGraphemes = getMinGraphemes(this.program, unionType); 1207 - if (minGraphemes !== undefined) { 1208 - primitive.minGraphemes = minGraphemes; 1209 - } 1210 - 1211 - if (prop) { 1212 - const propDesc = getDoc(this.program, prop); 1213 - if (propDesc) { 1214 - primitive.description = propDesc; 1215 - } 1216 - // Check for default values 1217 - const defaultValue = (prop as any).default; 1218 - if (defaultValue && (defaultValue as any).value !== undefined) { 1219 - const value = (defaultValue as any).value; 1220 - if (typeof value === "string") { 1221 - (primitive as any).default = value; 1222 - } 1223 - } 1224 - } 1225 - return primitive; 1226 - } 1227 - 1228 - // Otherwise, emit as union of refs 1229 - if (unionRefs.length > 0) { 1230 - // Check for unsupported mixed pattern: model refs + string literals 1231 - if (stringLiterals.length > 0) { 1232 - this.program.reportDiagnostic({ 1233 - code: "union-mixed-refs-literals", 1234 - severity: "error", 1235 - message: 1236 - `Union contains both model references and string literals. Atproto unions must be either: ` + 1237 - `(1) model references only (type: "union"), or ` + 1238 - `(2) string literals + string type (type: "string" with knownValues). ` + 1239 - `Separate these into distinct fields or nested unions.`, 1240 - target: unionType, 1241 - }); 1242 - return null; 1243 - } 1244 - 1245 - const unionDef: LexiconUnion = { 1246 - type: "union", 1247 - refs: unionRefs, 1248 - }; 1249 - 1250 - // Check for @closed decorator on the union itself (not on properties) 1251 - if (isClosed(this.program, unionType)) { 1252 - // Validate that @closed is not used on open unions (with unknown/never) 1253 - if (hasUnknown) { 1254 - this.program.reportDiagnostic({ 1255 - code: "closed-open-union", 1256 - severity: "error", 1257 - message: 1258 - "@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'.", 1259 - target: unionType, 1260 - }); 1261 - } else { 1262 - unionDef.closed = true; 1263 - } 1264 - } 1265 - 1266 - if (prop) { 1267 - const propDesc = getDoc(this.program, prop); 1268 - if (propDesc) { 1269 - unionDef.description = propDesc; 1270 - } 1271 - } 1272 - return unionDef; 1273 - } 1274 - 1275 - // No refs and no string literals - empty or unsupported union 1276 - if (stringLiterals.length === 0 && !hasUnknown) { 1277 - this.program.reportDiagnostic({ 1278 - code: "union-empty", 1279 - severity: "error", 1280 - message: 1281 - `Union has no variants. Atproto unions must contain either model references or string literals.`, 1282 - target: unionType, 1283 - }); 1284 - } 1285 - 1286 - return null; 781 + return this.processUnion(unionType, prop); 1287 782 case "Intrinsic": 1288 - // Handle unknown type - return unknown definition 1289 - const unknownDef: any = { 1290 - type: "unknown", 1291 - }; 1292 - if (prop) { 1293 - const propDesc = getDoc(this.program, prop); 1294 - if (propDesc) { 1295 - unknownDef.description = propDesc; 1296 - } 1297 - } 1298 - return unknownDef; 783 + return this.addDescription({ type: "unknown" }, propDesc); 1299 784 default: 1300 785 return null; 1301 786 } ··· 1305 790 scalar: Scalar, 1306 791 prop?: ModelProperty, 1307 792 ): LexiconDefinition | null { 1308 - const primitive: LexiconPrimitive = { 1309 - type: "string", // default 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" 1310 799 }; 1311 800 1312 - switch (scalar.name) { 1313 - case "string": 1314 - primitive.type = "string"; 1315 - break; 1316 - case "boolean": 1317 - primitive.type = "boolean"; 1318 - break; 1319 - case "integer": 1320 - case "int32": 1321 - case "int64": 1322 - case "int16": 1323 - case "int8": 1324 - primitive.type = "integer"; 1325 - break; 1326 - case "float32": 1327 - case "float64": 1328 - primitive.type = "number"; 1329 - break; 1330 - case "utcDateTime": 1331 - case "offsetDateTime": 1332 - case "plainDate": 1333 - case "plainTime": 1334 - primitive.type = "string"; 1335 - primitive.format = "datetime"; 1336 - break; 1337 - // Pre-defined format scalars from @tlex/emitter 1338 - case "did": 1339 - primitive.type = "string"; 1340 - primitive.format = "did"; 1341 - break; 1342 - case "handle": 1343 - primitive.type = "string"; 1344 - primitive.format = "handle"; 1345 - break; 1346 - case "atUri": 1347 - primitive.type = "string"; 1348 - primitive.format = "at-uri"; 1349 - break; 1350 - case "datetime": 1351 - primitive.type = "string"; 1352 - primitive.format = "datetime"; 1353 - break; 1354 - case "cid": 1355 - primitive.type = "string"; 1356 - primitive.format = "cid"; 1357 - break; 1358 - case "tid": 1359 - primitive.type = "string"; 1360 - primitive.format = "tid"; 1361 - break; 1362 - case "nsid": 1363 - primitive.type = "string"; 1364 - primitive.format = "nsid"; 1365 - break; 1366 - case "recordKey": 1367 - primitive.type = "string"; 1368 - primitive.format = "record-key"; 1369 - break; 1370 - case "uri": 1371 - primitive.type = "string"; 1372 - primitive.format = "uri"; 1373 - break; 1374 - case "language": 1375 - primitive.type = "string"; 1376 - primitive.format = "language"; 1377 - break; 1378 - case "atIdentifier": 1379 - primitive.type = "string"; 1380 - primitive.format = "at-identifier"; 1381 - break; 1382 - case "bytes": 1383 - // Check if this has blob-specific decorators 1384 - if (prop) { 1385 - const accept = getBlobAccept(this.program, prop); 1386 - const maxSize = getBlobMaxSize(this.program, prop); 1387 - 1388 - // If it has blob decorators, emit as blob type 1389 - if (accept || maxSize !== undefined) { 1390 - const blobDef: LexiconBlob = { 1391 - type: "blob", 1392 - }; 1393 - if (accept) { 1394 - blobDef.accept = accept; 1395 - } 1396 - if (maxSize !== undefined) { 1397 - blobDef.maxSize = maxSize; 1398 - } 1399 - return blobDef; 1400 - } 801 + 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; 1401 810 } 1402 - 1403 - // Otherwise, emit as bytes primitive 1404 - const bytesDef: LexiconBytes = { 1405 - type: "bytes", 1406 - }; 1407 - return bytesDef; 811 + } 812 + return { type: "bytes" }; 1408 813 } 1409 814 1410 - // Check for decorators on the property or scalar 1411 - const target = prop || scalar; 815 + const primitive: any = { type: "string" }; 816 + 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"; 1412 820 1413 - const format = getLexFormat(this.program, target); 1414 - if (format) { 1415 - primitive.format = format; 1416 - } 821 + const format = FORMAT_MAP[scalar.name] || getLexFormat(this.program, prop || scalar); 822 + if (format) primitive.format = format; 1417 823 824 + const target = prop || scalar; 1418 825 const maxLength = getMaxLength(this.program, target); 1419 - if (maxLength !== undefined) { 1420 - primitive.maxLength = maxLength; 1421 - } 1422 - 826 + if (maxLength !== undefined) primitive.maxLength = maxLength; 1423 827 const minLength = getMinLength(this.program, target); 1424 - if (minLength !== undefined) { 1425 - primitive.minLength = minLength; 1426 - } 1427 - 828 + if (minLength !== undefined) primitive.minLength = minLength; 1428 829 const maxGraphemes = getMaxGraphemes(this.program, target); 1429 - if (maxGraphemes !== undefined) { 1430 - primitive.maxGraphemes = maxGraphemes; 1431 - } 1432 - 830 + if (maxGraphemes !== undefined) primitive.maxGraphemes = maxGraphemes; 1433 831 const minGraphemes = getMinGraphemes(this.program, target); 1434 - if (minGraphemes !== undefined) { 1435 - primitive.minGraphemes = minGraphemes; 1436 - } 832 + if (minGraphemes !== undefined) primitive.minGraphemes = minGraphemes; 1437 833 1438 - // The rest of the decorators only apply to properties 1439 834 if (prop) { 1440 - // Check for const value on boolean, string, or integer properties 1441 835 const constValue = getLexConst(this.program, prop); 1442 - if (constValue !== undefined) { 1443 - if (primitive.type === "boolean" && typeof constValue === "boolean") { 1444 - (primitive as any).const = constValue; 1445 - } else if ( 1446 - primitive.type === "string" && 1447 - typeof constValue === "string" 1448 - ) { 1449 - (primitive as any).const = constValue; 1450 - } else if ( 1451 - primitive.type === "integer" && 1452 - typeof constValue === "number" 1453 - ) { 1454 - (primitive as any).const = constValue; 1455 - } 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; 1456 841 } 1457 842 1458 - // Check for default values (supported on string, integer, boolean) 1459 - const defaultValue = (prop as any).default; 1460 - if (defaultValue && (defaultValue as any).value !== undefined) { 1461 - const value = (defaultValue as any).value; 1462 - if (primitive.type === "string" && typeof value === "string") { 1463 - (primitive as any).default = value; 1464 - } else if (primitive.type === "integer" && typeof value === "number") { 1465 - (primitive as any).default = value; 1466 - } else if (primitive.type === "boolean" && typeof value === "boolean") { 1467 - (primitive as any).default = value; 1468 - } 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; 1469 849 } 1470 - } 1471 850 1472 - // Add minimum constraint for integer/number types 1473 - if (prop && (primitive.type === "integer" || primitive.type === "number")) { 1474 - const minValue = getMinValue(this.program, prop); 1475 - if (minValue !== undefined) { 1476 - primitive.minimum = minValue; 1477 - } 1478 - const maxValue = getMaxValue(this.program, prop); 1479 - if (maxValue !== undefined) { 1480 - primitive.maximum = maxValue; 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; 1481 856 } 1482 857 } 1483 858 ··· 1485 860 } 1486 861 1487 862 private getModelReference(model: Model): string | null { 1488 - // Refs can be local (#defName) or global (nsid#defName) 1489 - // Per atproto spec: local refs for same lexicon, global refs for cross-lexicon 1490 - 1491 - // Anonymous/inline models don't have refs 1492 - if (!model.name || model.name === "") { 1493 - return null; 1494 - } 1495 - 1496 - if ( 1497 - !model.namespace || 1498 - model.namespace.name === "" || 1499 - model.namespace.name === "TypeSpec" 1500 - ) { 1501 - return null; 1502 - } 863 + if (!model.name || !model.namespace || model.namespace.name === "TypeSpec") return null; 1503 864 1504 865 const namespaceName = getNamespaceFullName(model.namespace); 1505 - if (!namespaceName) { 1506 - return null; 1507 - } 866 + if (!namespaceName) return null; 1508 867 1509 868 const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 1510 869 1511 - // Check if it's in the current lexicon being processed (use local ref) 1512 - if (this.currentLexiconId) { 1513 - // Check for exact match (Main model case: lexicon ID = namespace) 1514 - if (this.currentLexiconId === namespaceName) { 1515 - return `#${defName}`; 1516 - } 1517 - // Check for defs file match 1518 - if (this.currentLexiconId === `${namespaceName}.defs`) { 1519 - return `#${defName}`; 1520 - } 870 + if (this.currentLexiconId === namespaceName || this.currentLexiconId === `${namespaceName}.defs`) { 871 + return `#${defName}`; 1521 872 } 1522 873 1523 - // Different lexicon - use global ref 1524 - // If the model is named "Main", it's the main def (no #main suffix per spec) 1525 - if (model.name === "Main") { 1526 - return namespaceName; 1527 - } 1528 - 1529 - // All other defs use fragment syntax 1530 - return `${namespaceName}#${defName}`; 874 + return model.name === "Main" ? namespaceName : `${namespaceName}#${defName}`; 1531 875 } 1532 876 1533 877 private getUnionReference(union: Union): string | null { 1534 - // Check if this union has a name (is a named def) 1535 878 const unionName = (union as any).name; 1536 - if (!unionName) { 1537 - return null; 1538 - } 1539 - 1540 - // Check if union has a namespace 1541 879 const namespace = (union as any).namespace; 1542 - if (!namespace || namespace.name === "" || namespace.name === "TypeSpec") { 1543 - return null; 1544 - } 880 + if (!unionName || !namespace || namespace.name === "TypeSpec") return null; 1545 881 1546 882 const namespaceName = getNamespaceFullName(namespace); 1547 - if (!namespaceName) { 1548 - return null; 1549 - } 883 + if (!namespaceName) return null; 1550 884 1551 885 const defName = unionName.charAt(0).toLowerCase() + unionName.slice(1); 1552 886 1553 - // Check if it's in the current lexicon being processed (use local ref) 1554 - if (this.currentLexiconId) { 1555 - // Check for exact match 1556 - if (this.currentLexiconId === namespaceName) { 1557 - return `#${defName}`; 1558 - } 1559 - // Check for defs file match 1560 - if (this.currentLexiconId === `${namespaceName}.defs`) { 1561 - return `#${defName}`; 1562 - } 887 + if (this.currentLexiconId === namespaceName || this.currentLexiconId === `${namespaceName}.defs`) { 888 + return `#${defName}`; 1563 889 } 1564 890 1565 - // Different lexicon - use global ref with fragment syntax 1566 891 return `${namespaceName}#${defName}`; 1567 892 } 1568 893 ··· 1570 895 model: Model, 1571 896 prop?: ModelProperty, 1572 897 ): LexiconArray | null { 1573 - // For `is` arrays (e.g., `model Preferences is SomeUnion[]`), 1574 - // the template args are on the sourceModel, not the model itself 1575 898 const arrayModel = model.sourceModel || model; 899 + const itemType = arrayModel.templateMapper?.args?.[0]; 1576 900 1577 - // Handle TypeSpec array types 1578 - if (arrayModel.templateMapper?.args && arrayModel.templateMapper.args.length > 0) { 1579 - const itemType = arrayModel.templateMapper.args[0]; 901 + if (itemType && isType(itemType)) { 902 + const itemDef = this.typeToLexiconDefinition(itemType); 903 + if (!itemDef) return null; 1580 904 1581 - if (isType(itemType)) { 1582 - const itemDef = this.typeToLexiconDefinition(itemType); 905 + const arrayDef: LexiconArray = { type: "array", items: itemDef }; 1583 906 1584 - if (itemDef) { 1585 - const arrayDef: LexiconArray = { 1586 - type: "array", 1587 - items: itemDef, 1588 - }; 1589 - 1590 - // Add array constraints from property decorators 1591 - if (prop) { 1592 - const maxItems = getMaxItems(this.program, prop); 1593 - if (maxItems !== undefined) { 1594 - arrayDef.maxLength = maxItems; 1595 - } 907 + if (prop) { 908 + const maxItems = getMaxItems(this.program, prop); 909 + if (maxItems !== undefined) arrayDef.maxLength = maxItems; 910 + const minItems = getMinItems(this.program, prop); 911 + if (minItems !== undefined) arrayDef.minLength = minItems; 912 + } 1596 913 1597 - const minItems = getMinItems(this.program, prop); 1598 - if (minItems !== undefined) { 1599 - arrayDef.minLength = minItems; 1600 - } 1601 - } 1602 - 1603 - return arrayDef; 1604 - } 1605 - } 914 + return arrayDef; 1606 915 } 1607 916 1608 917 return null; 1609 918 } 1610 919 1611 920 private getModelLexiconId(model: Model): string | null { 1612 - if (!model.namespace || model.namespace.name === "") { 1613 - return null; 1614 - } 921 + if (!model.namespace) return null; 1615 922 1616 923 const namespaceName = getNamespaceFullName(model.namespace); 1617 - if (!namespaceName) { 1618 - return null; 1619 - } 924 + if (!namespaceName) return null; 1620 925 1621 - // If the model is named "Main", the lexicon ID is just the namespace 1622 - // Otherwise, append the lowercased model name 1623 - if (model.name === "Main") { 1624 - return namespaceName; 1625 - } 926 + if (model.name === "Main") return namespaceName; 1626 927 1627 - // Convert namespace to lexicon ID format (e.g., "xyz.statusosphere" -> "xyz.statusosphere.modelName") 1628 928 return `${namespaceName}.${model.name.charAt(0).toLowerCase() + model.name.slice(1)}`; 1629 929 } 1630 930 1631 931 private getLexiconPath(lexiconId: string): string { 1632 - // Convert lexicon ID to file path (e.g., "xyz.statusphere.status" -> "xyz/statusphere/status.json") 1633 932 const parts = lexiconId.split("."); 1634 - const fileName = parts[parts.length - 1] + ".json"; 1635 - const dirs = parts.slice(0, -1); 1636 - 1637 - return join(this.options.outputDir, ...dirs, fileName); 933 + return join(this.options.outputDir, ...parts.slice(0, -1), parts[parts.length - 1] + ".json"); 1638 934 } 1639 935 1640 936 private async writeFile(filePath: string, content: string) {