import { Program, Type, Model, ModelProperty, Scalar, Union, Namespace, StringLiteral, NumericLiteral, BooleanLiteral, IntrinsicType, ArrayValue, StringValue, IndeterminateEntity, getDoc, getNamespaceFullName, isTemplateInstance, isType, getMaxLength, getMinLength, getMinValue, getMaxValue, getMaxItems, getMinItems, isArrayModelType, serializeValueAsJson, Operation, } from "@typespec/compiler"; import { join, dirname } from "path"; import type { LexiconDoc, LexObject, LexArray, LexBlob, LexXrpcQuery, LexXrpcProcedure, LexXrpcSubscription, LexObjectProperty, LexArrayItem, LexXrpcParameterProperty, LexRefUnion, LexUserType, LexRecord, LexXrpcBody, LexXrpcParameters, LexBytes, LexCidLink, LexRefVariant, LexToken, } from "./types.js"; import { getMaxGraphemes, getMinGraphemes, getRecordKey, isRequired, isReadOnly, isToken, isClosed, isQuery, isProcedure, isSubscription, getErrors, isErrorModel, getEncoding, isInline, getMaxBytes, getMinBytes, isExternal, } from "./decorators.js"; export interface EmitterOptions { outputDir: string; } // Constants for string format scalars (type: "string" with format field) const STRING_FORMAT_MAP: Record = { did: "did", handle: "handle", atUri: "at-uri", datetime: "datetime", cid: "cid", tid: "tid", nsid: "nsid", recordKey: "record-key", uri: "uri", language: "language", atIdentifier: "at-identifier", }; export class TypelexEmitter { private lexicons = new Map(); private currentLexiconId: string | null = null; constructor( private program: Program, private options: EmitterOptions, ) {} async emit() { const globalNs = this.program.getGlobalNamespaceType(); // Process all namespaces to find models and operations this.processNamespace(globalNs); // Write all lexicon files for (const [id, lexicon] of this.lexicons) { const filePath = this.getLexiconPath(id); await this.writeFile(filePath, JSON.stringify(lexicon, null, 2) + "\n"); } } private processNamespace(ns: Namespace) { const fullName = getNamespaceFullName(ns); // Skip TypeSpec internal namespaces if (!fullName || fullName.startsWith("TypeSpec")) { for (const [_, childNs] of ns.namespaces) { this.processNamespace(childNs); } return; } // Skip external namespaces - they don't emit JSON files if (isExternal(this.program, ns)) { // Validate that all models in external namespaces are empty (stub-only) for (const [_, model] of ns.models) { if (model.properties && model.properties.size > 0) { this.program.reportDiagnostic({ code: "external-model-not-empty", severity: "error", message: `Models in @external namespaces must be empty stubs. Model '${model.name}' in namespace '${fullName}' has properties.`, target: model, }); } } return; } // Check for TypeSpec enum syntax and throw error if (ns.enums && ns.enums.size > 0) { for (const [_, enumType] of ns.enums) { this.program.reportDiagnostic({ code: "enum-not-supported", severity: "error", message: "TypeSpec enum syntax is not supported. Use @closed @inline union instead.", target: enumType, }); } } const namespaceType = this.classifyNamespace(ns); switch (namespaceType) { case "operation": this.emitOperationLexicon(ns, fullName); break; case "content": this.emitContentLexicon(ns, fullName); break; case "defs": this.emitDefsLexicon(ns, fullName); break; case "empty": // Empty namespace, skip break; } // Recursively process child namespaces for (const [_, childNs] of ns.namespaces) { this.processNamespace(childNs); } } private classifyNamespace( ns: Namespace, ): "operation" | "content" | "defs" | "empty" { const hasModels = ns.models.size > 0; const hasScalars = ns.scalars.size > 0; const hasUnions = ns.unions?.size > 0; const hasOperations = ns.operations?.size > 0; const hasContent = hasModels || hasScalars || hasUnions; if (hasOperations) { return "operation"; } if (hasContent) { return "content"; } return "empty"; } private emitContentLexicon(ns: Namespace, fullName: string) { const models = [...ns.models.values()]; const isDefsFile = fullName.endsWith(".defs"); const mainModel = isDefsFile ? null : models.find((m) => m.name === "Main"); if (!isDefsFile && !mainModel) { this.program.reportDiagnostic({ code: "missing-main-model", severity: "error", message: `Namespace "${fullName}" has models/scalars but no Main model. ` + `Either add a "model Main" or rename namespace to "${fullName}.defs" for shared definitions.`, target: ns, }); return; } this.currentLexiconId = fullName; const lexicon = this.createLexicon(fullName, ns); if (mainModel) { lexicon.defs.main = this.createMainDef(mainModel); this.addDefs( lexicon, ns, models.filter((m) => m.name !== "Main"), ); } else { this.addDefs(lexicon, ns, models); } this.lexicons.set(fullName, lexicon); this.currentLexiconId = null; } private emitDefsLexicon(ns: Namespace, fullName: string) { const lexiconId = fullName.endsWith(".defs") ? fullName : fullName + ".defs"; this.currentLexiconId = lexiconId; const lexicon = this.createLexicon(lexiconId, ns); this.addDefs(lexicon, ns, [...ns.models.values()]); this.lexicons.set(lexiconId, lexicon); this.currentLexiconId = null; } private emitOperationLexicon(ns: Namespace, fullName: string) { this.currentLexiconId = fullName; const lexicon = this.createLexicon(fullName, ns); const mainOp = [...ns.operations].find( ([name]) => name === "main" || name === "Main", )?.[1]; if (mainOp) { this.addOperationToDefs(lexicon, mainOp, "main"); } for (const [name, operation] of ns.operations) { if (name !== "main" && name !== "Main") { this.addOperationToDefs(lexicon, operation, name); } } this.addDefs( lexicon, ns, [...ns.models.values()].filter((m) => m.name !== "Main"), ); this.lexicons.set(fullName, lexicon); this.currentLexiconId = null; } private createLexicon(id: string, ns: Namespace): LexiconDoc { const description = getDoc(this.program, ns); return { lexicon: 1, id, defs: {}, ...(description && { description }), }; } private createMainDef(mainModel: Model): LexRecord | LexObject | LexToken { const modelDescription = getDoc(this.program, mainModel); // Check if this is a token type if (isToken(this.program, mainModel)) { return { type: "token", description: modelDescription, }; } const recordKey = getRecordKey(this.program, mainModel); const modelDef = this.modelToLexiconObject(mainModel, !!modelDescription); if (recordKey) { const recordDef: LexRecord = { type: "record", key: recordKey, record: modelDef, }; if (modelDescription) { recordDef.description = modelDescription; delete modelDef.description; } return recordDef; } return modelDef; } private addDefs(lexicon: LexiconDoc, ns: Namespace, models: Model[]) { for (const model of models) { this.addModelToDefs(lexicon, model); } for (const [_, scalar] of ns.scalars) { this.addScalarToDefs(lexicon, scalar); } if (ns.unions) { for (const [_, union] of ns.unions) { this.addUnionToDefs(lexicon, union); } } } private addModelToDefs(lexicon: LexiconDoc, model: Model) { if (model.name[0] !== model.name[0].toUpperCase()) { this.program.reportDiagnostic({ code: "invalid-model-name", severity: "error", message: `Model name "${model.name}" must use PascalCase. Did you mean "${model.name[0].toUpperCase() + model.name.slice(1)}"?`, target: model, }); return; } if (isErrorModel(this.program, model)) return; if (isInline(this.program, model)) return; const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); const description = getDoc(this.program, model); if (isToken(this.program, model)) { lexicon.defs[defName] = { type: "token", description }; return; } if (isArrayModelType(this.program, model)) { const arrayDef = this.modelToLexiconArray(model); if (arrayDef) { lexicon.defs[defName] = { ...arrayDef, description }; return; } } const modelDef = this.modelToLexiconObject(model); lexicon.defs[defName] = { ...modelDef, description }; } private addScalarToDefs(lexicon: LexiconDoc, scalar: Scalar) { if (scalar.namespace?.name === "TypeSpec") return; if (scalar.baseScalar?.namespace?.name === "TypeSpec") return; // Skip @inline scalars - they should be inlined, not defined separately if (isInline(this.program, scalar)) { return; } const defName = scalar.name.charAt(0).toLowerCase() + scalar.name.slice(1); const scalarDef = this.scalarToLexiconPrimitive(scalar, undefined); if (scalarDef) { const description = getDoc(this.program, scalar); lexicon.defs[defName] = { ...scalarDef, description } as LexUserType; } } private addUnionToDefs(lexicon: LexiconDoc, union: Union) { const name = union.name; if (!name) return; // Skip @inline unions - they should be inlined, not defined separately if (isInline(this.program, union)) { return; } const unionDef = this.typeToLexiconDefinition(union, undefined, true); if (!unionDef) { return; } // Only string enums (including token refs) can be added as defs // Union refs (type: "union") must be inlined at usage sites if (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum)) { const defName = name.charAt(0).toLowerCase() + name.slice(1); const description = getDoc(this.program, union); lexicon.defs[defName] = { ...unionDef, description }; } else if (unionDef.type === "union") { this.program.reportDiagnostic({ code: "union-refs-not-allowed-as-def", severity: "error", message: `Named unions of non-token model references cannot be defined as standalone defs. ` + `Use @inline to inline them at usage sites, use @token models for known values, or use string literals.`, target: union, }); } } private isBlob(model: Model): boolean { // Check if model itself is named Blob if (model.name === "Blob") { return true; } // Check if it's a template instance of Blob if (isTemplateInstance(model) && model.sourceModel?.name === "Blob") { return true; } // Check base model (model ImageBlob extends Blob<...>) if (model.baseModel) { return this.isBlob(model.baseModel); } return false; } private createBlobDef(model: Model): LexBlob { const blobDef: LexBlob = { type: "blob" }; if (!isTemplateInstance(model)) { return blobDef; } const args = model.templateMapper?.args; if (!args?.length) { return blobDef; } // First arg: accept types (array of mime type strings) if (args.length >= 1) { const acceptArg = args[0]; if ( isType(acceptArg) || (acceptArg as ArrayValue).valueKind !== "ArrayValue" ) { throw new Error( "Blob template first argument must be an array of mime types", ); } const arrayValue = acceptArg as ArrayValue; const acceptTypes = arrayValue.values.map((v) => { if ((v as StringValue).valueKind !== "StringValue") { throw new Error("Blob accept types must be strings"); } return (v as StringValue).value; }); if (acceptTypes.length > 0) { blobDef.accept = acceptTypes; } } // Second arg: maxSize (numeric literal) if (args.length >= 2) { const maxSizeArg = args[1] as IndeterminateEntity; if (!isType(maxSizeArg.type) || maxSizeArg.type.kind !== "Number") { throw new Error( "Blob template second argument must be a numeric literal", ); } const maxSize = (maxSizeArg.type as NumericLiteral).value; if (maxSize > 0) { blobDef.maxSize = maxSize; } } return blobDef; } private unionToLexiconProperty( unionType: Union, prop?: ModelProperty, isDefining?: boolean, ): LexObjectProperty | null { const variants = this.parseUnionVariants(unionType); // Boolean literal unions are not supported in Lexicon if (variants.booleanLiterals.length > 0) { this.program.reportDiagnostic({ code: "boolean-literals-not-supported", severity: "error", message: "Boolean literal unions are not supported in Lexicon. Use boolean type with const or default instead.", target: unionType, }); return null; } // Integer enum (@closed only) if ( variants.numericLiterals.length > 0 && variants.unionRefs.length === 0 && isClosed(this.program, unionType) ) { const propDesc = prop ? getDoc(this.program, prop) : undefined; const defaultValue = prop?.defaultValue ? serializeValueAsJson(this.program, prop.defaultValue, prop) : undefined; return { type: "integer", enum: variants.numericLiterals, ...(propDesc && { description: propDesc }), ...(defaultValue !== undefined && typeof defaultValue === "number" && { default: defaultValue }), }; } // String enum (string literals with or without string type) // isStringEnum: has literals + string type + no refs // Closed enum: has literals + no string type + no refs + @closed if ( variants.isStringEnum || (variants.stringLiterals.length > 0 && !variants.hasStringType && variants.unionRefs.length === 0 && variants.knownValueRefs.length === 0 && isClosed(this.program, unionType)) ) { const isClosedUnion = isClosed(this.program, unionType); const propDesc = prop ? getDoc(this.program, prop) : undefined; const defaultValue = prop?.defaultValue ? serializeValueAsJson(this.program, prop.defaultValue, prop) : undefined; const maxLength = getMaxLength(this.program, unionType); const minLength = getMinLength(this.program, unionType); const maxGraphemes = getMaxGraphemes(this.program, unionType); const minGraphemes = getMinGraphemes(this.program, unionType); // Combine string literals and token refs for known values const allKnownValues = [ ...variants.stringLiterals, ...variants.knownValueRefs, ]; return { type: "string", [isClosedUnion ? "enum" : "knownValues"]: allKnownValues, ...(propDesc && { description: propDesc }), ...(defaultValue !== undefined && typeof defaultValue === "string" && { default: defaultValue }), ...(maxLength !== undefined && { maxLength }), ...(minLength !== undefined && { minLength }), ...(maxGraphemes !== undefined && { maxGraphemes }), ...(minGraphemes !== undefined && { minGraphemes }), }; } // Model reference union (including empty union with unknown) if (variants.unionRefs.length > 0 || variants.hasUnknown) { if ( variants.stringLiterals.length > 0 || variants.knownValueRefs.length > 0 ) { this.program.reportDiagnostic({ code: "union-mixed-refs-literals", severity: "error", message: `Union contains both non-token model references and string literals/token refs. Lexicon unions must be either: ` + `(1) non-token model references only (type: "union"), ` + `(2) token refs + string literals + string type (type: "string" with knownValues), or ` + `(3) integer literals + integer type (type: "integer" with knownValues). ` + `Separate these into distinct fields or nested unions.`, target: unionType, }); return null; } const isClosedUnion = isClosed(this.program, unionType); if (isClosedUnion && variants.hasUnknown) { this.program.reportDiagnostic({ code: "closed-open-union", severity: "error", message: "@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`.", target: unionType, }); } const propDesc = prop ? getDoc(this.program, prop) : undefined; return { type: "union", refs: variants.unionRefs, ...(propDesc && { description: propDesc }), ...(isClosedUnion && !variants.hasUnknown && { closed: true }), }; } // Empty union without unknown if ( variants.stringLiterals.length === 0 && variants.numericLiterals.length === 0 && variants.booleanLiterals.length === 0 ) { this.program.reportDiagnostic({ code: "union-empty", severity: "error", message: `Union has no variants. Lexicon unions must contain either model references or literals.`, target: unionType, }); return null; } // Invalid string literal union (has literals but no string type and not @closed) if (variants.stringLiterals.length > 0 && !variants.hasStringType) { this.program.reportDiagnostic({ code: "string-literal-union-invalid", severity: "error", message: 'Open string unions must include "| string" to allow unknown values. ' + "Use @closed decorator if this is intentionally a closed enum.", target: unionType, }); return null; } // Unexpected case this.program.reportDiagnostic({ code: "union-unexpected-type", severity: "error", message: "Unexpected union type: neither string enum nor model refs nor empty.", target: unionType, }); return null; } private parseUnionVariants(unionType: Union) { const unionRefs: string[] = []; const stringLiterals: string[] = []; const numericLiterals: number[] = []; const booleanLiterals: boolean[] = []; const tokenModels: Model[] = []; let hasStringType = false; let hasUnknown = false; for (const variant of unionType.variants.values()) { switch (variant.type.kind) { case "Model": const model = variant.type as Model; // Collect token models separately - they're treated differently based on hasStringType if (isToken(this.program, model)) { tokenModels.push(model); } else { const ref = this.getModelReference(model); if (ref) unionRefs.push(ref); } break; case "String": stringLiterals.push((variant.type as StringLiteral).value); break; case "Number": numericLiterals.push((variant.type as NumericLiteral).value); break; case "Boolean": booleanLiterals.push((variant.type as BooleanLiteral).value); break; case "Scalar": if ((variant.type as Scalar).name === "string") { hasStringType = true; } break; case "Intrinsic": const intrinsicName = (variant.type as IntrinsicType).name; if (intrinsicName === "unknown" || intrinsicName === "never") { hasUnknown = true; } break; } } // Validate: tokens must appear with | string // Per Lexicon spec line 240: "unions can not reference token" if (tokenModels.length > 0 && !hasStringType) { this.program.reportDiagnostic({ code: "tokens-require-string", severity: "error", message: "Tokens must be used with | string. Per Lexicon spec, tokens encode as string values and cannot appear in union refs.", target: unionType, }); } // Token models become "known values" (always fully qualified refs) const knownValueRefs = tokenModels .map((m) => this.getModelReference(m, true)) .filter((ref): ref is string => ref !== null); const isStringEnum = (stringLiterals.length > 0 || knownValueRefs.length > 0) && hasStringType && unionRefs.length === 0; return { unionRefs, stringLiterals, numericLiterals, booleanLiterals, knownValueRefs, hasStringType, hasUnknown, isStringEnum, }; } private addOperationToDefs( lexicon: LexiconDoc, operation: Operation, defName: string, ) { const description = getDoc(this.program, operation); const errors = getErrors(this.program, operation); if (isQuery(this.program, operation)) { const parameters = this.buildParameters(operation); const output = this.buildOutput(operation); lexicon.defs[defName] = { type: "query", ...(description && { description }), ...(parameters && { parameters }), ...(output && { output }), ...(errors?.length && { errors }), } as LexXrpcQuery; } else if (isProcedure(this.program, operation)) { const { input, parameters } = this.buildProcedureParams(operation); const output = this.buildOutput(operation); lexicon.defs[defName] = { type: "procedure", ...(description && { description }), ...(input && { input }), ...(parameters && { parameters }), ...(output && { output }), ...(errors?.length && { errors }), } as LexXrpcProcedure; } else if (isSubscription(this.program, operation)) { const parameters = this.buildParameters(operation); const message = this.buildSubscriptionMessage(operation); lexicon.defs[defName] = { type: "subscription", ...(description && { description }), ...(parameters && { parameters }), ...(message && { message }), ...(errors?.length && { errors }), } as LexXrpcSubscription; } } private buildParameters(operation: Operation): LexXrpcParameters | undefined { if (!operation.parameters?.properties?.size) return undefined; const properties: Record = {}; const required: string[] = []; for (const [paramName, param] of operation.parameters.properties) { // Check for conflicting @required on optional property if (param.optional && isRequired(this.program, param)) { this.program.reportDiagnostic({ code: "required-on-optional", message: `Parameter "${paramName}" has conflicting markers: @required decorator with optional "?". ` + `Either remove @required to make it optional (preferred), or remove the "?".`, target: param, severity: "error", }); } if (!param.optional) { if (!isRequired(this.program, param)) { this.program.reportDiagnostic({ code: "parameter-missing-required", message: `Required parameter "${paramName}" must be explicitly marked with @required decorator. ` + `In atproto, required fields are discouraged and must be intentional. ` + `Either add @required to the parameter or make it optional with "?".`, target: param, severity: "error", }); } required.push(paramName); } const paramDef = this.typeToLexiconDefinition(param.type, param); if (paramDef && this.isXrpcParameterProperty(paramDef)) { properties[paramName] = paramDef; } } return { type: "params" as const, properties, ...(required.length && { required }), }; } private isXrpcParameterProperty( type: LexObjectProperty, ): type is LexXrpcParameterProperty { // XRPC parameters can only be primitives or arrays of primitives if (type.type === "array") { const arrayType = type as LexArray; return ( arrayType.items.type === "boolean" || arrayType.items.type === "integer" || arrayType.items.type === "string" || arrayType.items.type === "unknown" ); } return ( type.type === "boolean" || type.type === "integer" || type.type === "string" || type.type === "unknown" ); } private buildProcedureParams(operation: Operation): { input?: LexXrpcBody; parameters?: LexXrpcParameters; } { if (!operation.parameters?.properties?.size) { return {}; } const params = Array.from(operation.parameters.properties) as [ string, ModelProperty, ][]; if (params.length === 0) { return {}; } if (params.length > 2) { this.program.reportDiagnostic({ code: "procedure-too-many-params", severity: "error", message: "Procedures can have at most 2 parameters (input and/or parameters)", target: operation, }); return {}; } // Single parameter: must be named "input" if (params.length === 1) { const [paramName, param] = params[0]; if (paramName !== "input") { this.program.reportDiagnostic({ code: "procedure-invalid-param-name", severity: "error", message: `Procedure parameter must be named "input", got "${paramName}"`, target: param, }); return {}; } const input = this.buildInput(param); if (!input) { return {}; } return { input }; } // Two parameters: must be "input" and "parameters" const [param1Name, param1] = params[0]; const [param2Name, param2] = params[1]; if (param1Name !== "input") { this.program.reportDiagnostic({ code: "procedure-invalid-first-param", severity: "error", message: `First parameter must be named "input", got "${param1Name}"`, target: param1, }); } if (param2Name !== "parameters") { this.program.reportDiagnostic({ code: "procedure-invalid-second-param", severity: "error", message: `Second parameter must be named "parameters", got "${param2Name}"`, target: param2, }); } if (param2.type.kind !== "Model" || (param2.type as Model).name) { this.program.reportDiagnostic({ code: "procedure-parameters-not-object", severity: "error", message: "The 'parameters' parameter must be a plain object, not a model reference", target: param2, }); } const input = this.buildInput(param1); const parameters = this.buildParametersFromModel(param2.type as Model); const result: { input?: LexXrpcBody; parameters?: LexXrpcParameters } = {}; if (input) { result.input = input; } if (parameters) { result.parameters = parameters; } return result; } private buildParametersFromModel( parametersModel: Model, ): LexXrpcParameters | undefined { if (parametersModel.kind !== "Model" || !parametersModel.properties) { return undefined; } const properties: Record = {}; const required: string[] = []; for (const [propName, prop] of parametersModel.properties) { // Check for conflicting @required on optional property if (prop.optional && isRequired(this.program, prop)) { this.program.reportDiagnostic({ code: "required-on-optional", message: `Parameter "${propName}" has conflicting markers: @required decorator with optional "?". ` + `Either remove @required to make it optional (preferred), or remove the "?".`, target: prop, severity: "error", }); } if (!prop.optional) { if (!isRequired(this.program, prop)) { this.program.reportDiagnostic({ code: "parameter-missing-required", message: `Required parameter "${propName}" must be explicitly marked with @required decorator. ` + `In atproto, required fields are discouraged and must be intentional. ` + `Either add @required to the parameter or make it optional with "?".`, target: prop, severity: "error", }); } required.push(propName); } const propDef = this.typeToLexiconDefinition(prop.type, prop); if (propDef && this.isXrpcParameterProperty(propDef)) { properties[propName] = propDef; } } return { type: "params" as const, properties, ...(required.length > 0 && { required }), }; } private buildInput(param: ModelProperty): LexXrpcBody | undefined { const encoding = getEncoding(this.program, param); if (param.type?.kind === "Intrinsic") { return encoding ? { encoding } : undefined; } const inputSchema = this.typeToLexiconDefinition(param.type); if (!inputSchema) { return undefined; } const validSchema = this.toValidBodySchema(inputSchema); if (!validSchema) { return undefined; } return { encoding: encoding || "application/json", schema: validSchema, }; } private buildOutput(operation: Operation): LexXrpcBody | undefined { const encoding = getEncoding(this.program, operation); if (operation.returnType?.kind === "Intrinsic") { return encoding ? { encoding } : undefined; } const schema = this.typeToLexiconDefinition(operation.returnType); if (!schema) { return undefined; } const validSchema = this.toValidBodySchema(schema); if (!validSchema) { return undefined; } return { encoding: encoding || "application/json", schema: validSchema, }; } private toValidBodySchema( schema: LexObjectProperty, ): LexRefVariant | LexObject | null { if ( schema.type === "ref" || schema.type === "union" || schema.type === "object" ) { return schema as LexRefVariant | LexObject; } return null; } private buildSubscriptionMessage( operation: Operation, ): { schema: LexRefUnion } | undefined { if (operation.returnType?.kind === "Union") { const messageSchema = this.typeToLexiconDefinition(operation.returnType); if (messageSchema && messageSchema.type === "union") { return { schema: messageSchema }; } } else if (operation.returnType?.kind !== "Intrinsic") { this.program.reportDiagnostic({ code: "subscription-return-not-union", severity: "error", message: "Subscription return type must be a union", target: operation, }); } return undefined; } private modelToLexiconObject( model: Model, includeModelDescription: boolean = true, ): LexObject { const required: string[] = []; const nullable: string[] = []; const properties: Record = {}; for (const [name, prop] of model.properties) { // Check for conflicting @required on optional property if (prop.optional && isRequired(this.program, prop)) { this.program.reportDiagnostic({ code: "required-on-optional", message: `Property "${name}" has conflicting markers: @required decorator with optional "?". ` + `Either remove @required to make it optional (preferred), or remove the "?".`, target: prop, severity: "error", }); } if (!prop.optional) { if (!isRequired(this.program, prop)) { this.program.reportDiagnostic({ code: "closed-open-union-inline", message: `Required field "${name}" in model "${model.name}" must be explicitly marked with @required decorator. ` + `In atproto, required fields are discouraged and must be intentional. ` + `Either add @required to the field or make it optional with "?".`, target: model, severity: "error", }); } required.push(name); } let typeToProcess = prop.type; if (prop.type.kind === "Union") { const variants = Array.from((prop.type as Union).variants.values()); const hasNull = variants.some( (v) => v.type.kind === "Intrinsic" && (v.type as IntrinsicType).name === "null", ); if (hasNull) { nullable.push(name); const nonNullVariant = variants.find( (v) => !( v.type.kind === "Intrinsic" && (v.type as IntrinsicType).name === "null" ), ); if (nonNullVariant) typeToProcess = nonNullVariant.type; } } const propDef = this.typeToLexiconDefinition(typeToProcess, prop); if (propDef) properties[name] = propDef; } const description = includeModelDescription ? getDoc(this.program, model) : undefined; return { type: "object", properties, ...(description && { description }), ...(required.length && { required }), ...(nullable.length && { nullable }), }; } private typeToLexiconDefinition( type: Type, prop?: ModelProperty, isDefining?: boolean, ): LexObjectProperty | null { const propDesc = prop ? getDoc(this.program, prop) : undefined; switch (type.kind) { case "Scalar": return this.handleScalarType(type as Scalar, prop, propDesc); case "Model": return this.handleModelType(type as Model, prop, propDesc); case "Union": return this.handleUnionType(type as Union, prop, isDefining, propDesc); case "Intrinsic": const intrinsicType = type as IntrinsicType; if (intrinsicType.name === "null") { return { type: "null" as const, description: propDesc }; } return { type: "unknown" as const, description: propDesc }; default: // Unhandled type kind this.program.reportDiagnostic({ code: "unhandled-type-kind", severity: "error", message: `Unhandled type kind "${type.kind}" in typeToLexiconDefinition`, target: type, }); return null; } } private handleScalarType( scalar: Scalar, prop?: ModelProperty, propDesc?: string, ): LexObjectProperty | null { const primitive = this.scalarToLexiconPrimitive(scalar, prop); if (!primitive) return null; // Determine description: prop description, or inherited scalar description for custom scalars let description = propDesc; if ( !description && scalar.baseScalar && scalar.namespace?.name !== "TypeSpec" ) { // Don't inherit description for built-in scalars (formats, bytes, cidLink) const isBuiltInScalar = STRING_FORMAT_MAP[scalar.name] || this.isScalarOfType(scalar, "bytes") || this.isScalarOfType(scalar, "cidLink"); if (!isBuiltInScalar) { description = getDoc(this.program, scalar); } } return { ...primitive, description }; } private handleModelType( model: Model, prop?: ModelProperty, propDesc?: string, ): LexObjectProperty | null { // 1. Check for Blob type if (this.isBlob(model)) { return { ...this.createBlobDef(model), description: propDesc }; } // 2. Check for model reference (named models) const modelRef = this.getModelReference(model); // Tokens must be referenced, not inlined if (isToken(this.program, model)) { if (!modelRef) { this.program.reportDiagnostic({ code: "token-must-be-named", severity: "error", message: "Token types must be named and referenced, not used inline", target: model, }); return null; } return { type: "ref" as const, ref: modelRef, description: propDesc }; } if (modelRef) { return { type: "ref" as const, ref: modelRef, description: propDesc }; } // 3. Check for array type if (isArrayModelType(this.program, model)) { const arrayDef = this.modelToLexiconArray(model, prop); if (!arrayDef) { this.program.reportDiagnostic({ code: "array-conversion-failed", severity: "error", message: "Array type conversion failed - array must have a valid item type", target: model, }); return null; } return { ...arrayDef, description: propDesc }; } // 4. Inline object const objDef = this.modelToLexiconObject(model); // Only add propDesc if the object doesn't already have a description return propDesc && !objDef.description ? { ...objDef, description: propDesc } : objDef; } private handleUnionType( unionType: Union, prop?: ModelProperty, isDefining?: boolean, propDesc?: string, ): LexObjectProperty | null { // Check if this is a named union that should be referenced if (!isDefining) { const unionRef = this.getUnionReference(unionType); if (unionRef) { return { type: "ref" as const, ref: unionRef, description: propDesc }; } } const unionDef = this.unionToLexiconProperty(unionType, prop, isDefining); if (!unionDef) return null; // Inherit description from union if no prop description and union is @inline if (!propDesc && isInline(this.program, unionType)) { const unionDesc = getDoc(this.program, unionType); if (unionDesc) { return { ...unionDef, description: unionDesc }; } } return unionDef; } private scalarToLexiconPrimitive( scalar: Scalar, prop?: ModelProperty, ): LexObjectProperty | null { // Check if this scalar (or its base) is bytes type if (this.isScalarOfType(scalar, "bytes")) { const byteDef: LexBytes = { type: "bytes" }; const target = prop || scalar; const minLength = getMinBytes(this.program, target); if (minLength !== undefined) { byteDef.minLength = minLength; } const maxLength = getMaxBytes(this.program, target); if (maxLength !== undefined) { byteDef.maxLength = maxLength; } if (prop) { return this.applyPropertyMetadata(byteDef, prop); } return byteDef; } // Check if this scalar (or its base) is cidLink type if (this.isScalarOfType(scalar, "cidLink")) { const cidLinkDef: LexCidLink = { type: "cid-link" }; if (prop) { return this.applyPropertyMetadata(cidLinkDef, prop); } return cidLinkDef; } // Build primitive with constraints and metadata let primitive = this.getBasePrimitiveType(scalar); if (!primitive) return null; // Apply format if applicable - check the scalar chain for format const format = this.getScalarFormat(scalar); if (format && primitive.type === "string") { primitive = { ...primitive, format }; } // Apply string constraints if (primitive.type === "string") { const target = prop || scalar; const maxLength = getMaxLength(this.program, target); if (maxLength !== undefined) { primitive.maxLength = maxLength; } const minLength = getMinLength(this.program, target); if (minLength !== undefined) { primitive.minLength = minLength; } const maxGraphemes = getMaxGraphemes(this.program, target); if (maxGraphemes !== undefined) { primitive.maxGraphemes = maxGraphemes; } const minGraphemes = getMinGraphemes(this.program, target); if (minGraphemes !== undefined) { primitive.minGraphemes = minGraphemes; } } // Apply numeric constraints if (prop && primitive.type === "integer") { const minValue = getMinValue(this.program, prop); if (minValue !== undefined) { primitive.minimum = minValue; } const maxValue = getMaxValue(this.program, prop); if (maxValue !== undefined) { primitive.maximum = maxValue; } } // Apply property-specific metadata if (prop) { primitive = this.applyPropertyMetadata(primitive, prop); } return primitive; } private isScalarOfType(scalar: Scalar, typeName: string): boolean { if (scalar.name === typeName) { return true; } if (scalar.baseScalar && this.isScalarOfType(scalar.baseScalar, typeName)) { return true; } return false; } private getScalarFormat(scalar: Scalar): string | undefined { // Check if this scalar has a format const format = STRING_FORMAT_MAP[scalar.name]; if (format) { return format; } // Check base scalar if (scalar.baseScalar) { return this.getScalarFormat(scalar.baseScalar); } return undefined; } private getBasePrimitiveType(scalar: Scalar): LexObjectProperty | null { // Custom scalars extending valid primitives (like did, atUri, etc. extending string) if (scalar.baseScalar) { return this.getBasePrimitiveType(scalar.baseScalar); } // Valid Lexicon primitive types switch (scalar.name) { case "boolean": return { type: "boolean" }; case "string": return { type: "string" }; case "numeric": // TODO: Any way to narrow it down? return { type: "integer" }; } this.program.reportDiagnostic({ code: "unknown-scalar-type", severity: "error", message: `Scalar type "${scalar.name}" is not a valid Lexicon primitive. Valid types: boolean, integer, string`, target: scalar, }); return null; } private applyPropertyMetadata( primitive: T, prop: ModelProperty, ): T { let defaultValue; if (prop.defaultValue !== undefined) { defaultValue = serializeValueAsJson( this.program, prop.defaultValue, prop, ); } if (defaultValue !== undefined) { this.assertValidValueForType(primitive.type, defaultValue, prop); } if (isReadOnly(this.program, prop)) { if (defaultValue === undefined) { this.program.reportDiagnostic({ code: "readonly-missing-default", severity: "error", message: "@readOnly requires a default value assignment", target: prop, }); return primitive; } return { ...primitive, const: defaultValue } as T; } else if (defaultValue !== undefined) { return { ...primitive, default: defaultValue } as T; } return primitive; } private assertValidValueForType( primitiveType: string, value: unknown, prop: ModelProperty, ): void { const valid = (primitiveType === "boolean" && typeof value === "boolean") || (primitiveType === "string" && typeof value === "string") || (primitiveType === "integer" && typeof value === "number"); if (!valid) { this.program.reportDiagnostic({ code: "invalid-default-value-type", severity: "error", message: `Default value type mismatch: expected ${primitiveType}, got ${typeof value}`, target: prop, }); } } private getReference( entity: Model | Union, name: string | undefined, namespace: Namespace | undefined, fullyQualified = false, ): string | null { if (!name || !namespace || namespace.name === "TypeSpec") return null; // If entity is marked as @inline, don't create a reference - inline it instead if (isInline(this.program, entity)) { return null; } const defName = name.charAt(0).toLowerCase() + name.slice(1); const namespaceName = getNamespaceFullName(namespace); if (!namespaceName) { this.program.reportDiagnostic({ code: "no-namespace", severity: "error", message: `Missing namespace definition`, target: entity, }); } // For knownValues (fullyQualified=true), always use fully qualified refs if (fullyQualified) { return `${namespaceName}#${defName}`; } // Local reference (same namespace) - use short ref if ( this.currentLexiconId === namespaceName || this.currentLexiconId === `${namespaceName}.defs` ) { return `#${defName}`; } // Cross-namespace reference: Main models reference just the namespace if (entity.kind === "Model" && name === "Main") { return namespaceName; } // All other refs use fully qualified format return `${namespaceName}#${defName}`; } private getModelReference( model: Model, fullyQualified = false, ): string | null { return this.getReference( model, model.name, model.namespace, fullyQualified, ); } private getUnionReference(union: Union): string | null { return this.getReference(union, union.name, union.namespace); } private modelToLexiconArray( model: Model, prop?: ModelProperty, ): LexArray | null { const arrayModel = model.sourceModel || model; const itemType = arrayModel.templateMapper?.args?.[0]; if (itemType && isType(itemType)) { const itemDef = this.typeToLexiconDefinition(itemType); if (!itemDef) { this.program.reportDiagnostic({ code: "array-item-conversion-failed", severity: "error", message: "Failed to convert array item type to lexicon definition", target: model, }); return null; } const arrayDef: LexArray = { type: "array", items: itemDef as LexArrayItem, }; if (prop) { const maxItems = getMaxItems(this.program, prop); if (maxItems !== undefined) arrayDef.maxLength = maxItems; const minItems = getMinItems(this.program, prop); if (minItems !== undefined) arrayDef.minLength = minItems; } return arrayDef; } this.program.reportDiagnostic({ code: "array-missing-item-type", severity: "error", message: "Array type must have a valid item type argument", target: model, }); return null; } private getLexiconPath(lexiconId: string): string { const parts = lexiconId.split("."); return join( this.options.outputDir, ...parts.slice(0, -1), parts[parts.length - 1] + ".json", ); } private async writeFile(filePath: string, content: string) { const dir = dirname(filePath); await this.program.host.mkdirp(dir); await this.program.host.writeFile(filePath, content); } }