diff --git a/packages/emitter/src/emitter.ts b/packages/emitter/src/emitter.ts index 9f08de4..52e2155 100644 --- a/packages/emitter/src/emitter.ts +++ b/packages/emitter/src/emitter.ts @@ -53,6 +53,44 @@ export interface EmitterOptions { outputDir: string; } +// Constants for atproto format scalars +const FORMAT_SCALARS = new Set([ + "datetime", + "did", + "handle", + "atUri", + "cid", + "tid", + "nsid", + "recordKey", + "uri", + "language", + "atIdentifier", + "bytes", + "utcDateTime", + "offsetDateTime", + "plainDate", + "plainTime", +]); + +const 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", + utcDateTime: "datetime", + offsetDateTime: "datetime", + plainDate: "datetime", + plainTime: "datetime", +}; + export class TlexEmitter { private lexicons = new Map(); private currentLexiconId: string | null = null; @@ -78,28 +116,62 @@ export class TlexEmitter { private processNamespace(ns: any) { const fullName = getNamespaceFullName(ns); - if (fullName && !fullName.startsWith("TypeSpec")) { - 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 hasChildNamespaces = ns.namespaces.size > 0; - const hasContent = hasModels || hasScalars || hasUnions; + // Skip TypeSpec internal namespaces + if (!fullName || fullName.startsWith("TypeSpec")) { + for (const [_, childNs] of ns.namespaces) { + this.processNamespace(childNs); + } + return; + } + + const namespaceType = this.classifyNamespace(ns); - if (hasOperations) { + switch (namespaceType) { + case "operation": this.emitOperationLexicon(ns, fullName); - } else if (hasContent && !hasChildNamespaces) { + break; + case "content": this.emitContentLexicon(ns, fullName); - } else if (hasContent && hasChildNamespaces) { + 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: any, + ): "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 hasChildNamespaces = ns.namespaces.size > 0; + const hasContent = hasModels || hasScalars || hasUnions; + + if (hasOperations) { + return "operation"; + } + + if (hasContent && hasChildNamespaces) { + return "defs"; + } + + if (hasContent && !hasChildNamespaces) { + return "content"; + } + + return "empty"; + } + private emitContentLexicon(ns: any, fullName: string) { const models = [...ns.models.values()]; const isDefsFile = fullName.endsWith(".defs"); @@ -117,7 +189,11 @@ export class TlexEmitter { if (mainModel) { lexicon.defs.main = this.createMainDef(mainModel); - this.addDefs(lexicon, ns, models.filter((m) => m.name !== "Main")); + this.addDefs( + lexicon, + ns, + models.filter((m) => m.name !== "Main"), + ); } else { this.addDefs(lexicon, ns, models); } @@ -127,7 +203,9 @@ export class TlexEmitter { } private emitDefsLexicon(ns: any, fullName: string) { - const lexiconId = fullName.endsWith(".defs") ? fullName : fullName + ".defs"; + const lexiconId = fullName.endsWith(".defs") + ? fullName + : fullName + ".defs"; this.currentLexiconId = lexiconId; const lexicon = this.createLexicon(lexiconId, ns); this.addDefs(lexicon, ns, [...ns.models.values()]); @@ -140,7 +218,7 @@ export class TlexEmitter { const lexicon = this.createLexicon(fullName, ns); const mainOp = [...ns.operations].find( - ([name]) => name === "main" || name === "Main" + ([name]) => name === "main" || name === "Main", )?.[1]; if (mainOp) { @@ -153,7 +231,11 @@ export class TlexEmitter { } } - this.addDefs(lexicon, ns, [...ns.models.values()].filter((m) => m.name !== "Main")); + this.addDefs( + lexicon, + ns, + [...ns.models.values()].filter((m) => m.name !== "Main"), + ); this.lexicons.set(fullName, lexicon); this.currentLexiconId = null; } @@ -171,7 +253,11 @@ export class TlexEmitter { const modelDef = this.modelToLexiconObject(mainModel, !!modelDescription); if (recordKey) { - const recordDef: any = { type: "record", key: recordKey, record: modelDef }; + const recordDef: any = { + type: "record", + key: recordKey, + record: modelDef, + }; if (modelDescription) { recordDef.description = modelDescription; delete modelDef.description; @@ -213,7 +299,10 @@ export class TlexEmitter { const description = getDoc(this.program, model); if (isToken(this.program, model)) { - lexicon.defs[defName] = this.addDescription({ type: "token" }, description); + lexicon.defs[defName] = this.addDescription( + { type: "token" }, + description, + ); return; } @@ -246,7 +335,10 @@ export class TlexEmitter { const unionDef: any = this.typeToLexiconDefinition(union, undefined, true); if (!unionDef) return; - if (unionDef.type === "union" || (unionDef.type === "string" && unionDef.knownValues)) { + if ( + unionDef.type === "union" || + (unionDef.type === "string" && unionDef.knownValues) + ) { const defName = name.charAt(0).toLowerCase() + name.slice(1); const description = getDoc(this.program, union); lexicon.defs[defName] = this.addDescription(unionDef, description); @@ -260,6 +352,26 @@ export class TlexEmitter { return obj; } + private isBlob(model: Model): boolean { + return !!( + isBlob(this.program, model) || + (isTemplateInstance(model) && + model.templateNode && + isBlob(this.program, model.templateNode as any)) || + (model.baseModel && isBlob(this.program, model.baseModel)) + ); + } + + private isClosedUnionTemplate(model: Model): boolean { + return !!( + model.name === "Closed" || + (model.node && (model.node as any).symbol?.name === "Closed") || + (isTemplateInstance(model) && + model.node && + (model.node as any).symbol?.name === "Closed") + ); + } + private createBlobDef(model: Model): LexiconBlob { const blobDef: LexiconBlob = { type: "blob" }; @@ -278,14 +390,20 @@ export class TlexEmitter { if (!acceptTypes.length) acceptTypes = undefined; } } else if (acceptArg && Array.isArray(acceptArg.value)) { - const values = acceptArg.value.filter((v: any) => typeof v === "string"); + const values = acceptArg.value.filter( + (v: any) => typeof v === "string", + ); if (values.length) acceptTypes = values; } if (acceptTypes) blobDef.accept = acceptTypes; const maxSizeArg = templateArgs[1] as any; - const maxSize = maxSizeArg?.value ?? (maxSizeArg?.type?.kind === "Number" ? Number(maxSizeArg.type.value) : undefined); + const maxSize = + maxSizeArg?.value ?? + (maxSizeArg?.type?.kind === "Number" + ? Number(maxSizeArg.type.value) + : undefined); if (maxSize !== undefined && maxSize !== 0) blobDef.maxSize = maxSize; } } @@ -293,100 +411,154 @@ export class TlexEmitter { return blobDef; } - private processUnion(unionType: Union, prop?: ModelProperty): LexiconDefinition | null { + private processUnion( + unionType: Union, + prop?: ModelProperty, + ): LexiconDefinition | null { + // Parse union variants + const variants = this.parseUnionVariants(unionType); + + // Case 1: String enum with known values (string literals + string type) + if (variants.isStringEnum) { + return this.createStringEnumDef(unionType, variants.stringLiterals, prop); + } + + // Case 2: Model reference union + if (variants.unionRefs.length > 0) { + return this.createUnionRefDef(unionType, variants, prop); + } + + // Case 3: Empty or invalid union + if (variants.stringLiterals.length === 0 && !variants.hasUnknown) { + this.program.reportDiagnostic({ + code: "union-empty", + severity: "error", + message: `Union has no variants. Atproto unions must contain either model references or string literals.`, + target: unionType, + }); + } + + return null; + } + + private parseUnionVariants(unionType: Union) { const unionRefs: string[] = []; const stringLiterals: string[] = []; let hasStringType = false; let hasUnknown = false; for (const variant of unionType.variants.values()) { - if (variant.type.kind === "Model") { - const ref = this.getModelReference(variant.type as Model); - if (ref) unionRefs.push(ref); - } else if (variant.type.kind === "String") { - stringLiterals.push((variant.type as any).value); - } else if (variant.type.kind === "Scalar" && (variant.type as Scalar).name === "string") { - hasStringType = true; - } else if (variant.type.kind === "Intrinsic") { - const intrinsicName = (variant.type as any).name; - if (intrinsicName === "unknown" || intrinsicName === "never") { - hasUnknown = true; - } + switch (variant.type.kind) { + case "Model": + const ref = this.getModelReference(variant.type as Model); + if (ref) unionRefs.push(ref); + break; + case "String": + stringLiterals.push((variant.type as any).value); + break; + case "Scalar": + if ((variant.type as Scalar).name === "string") { + hasStringType = true; + } + break; + case "Intrinsic": + const intrinsicName = (variant.type as any).name; + if (intrinsicName === "unknown" || intrinsicName === "never") { + hasUnknown = true; + } + break; } } - if (stringLiterals.length && hasStringType && !unionRefs.length) { - const primitive: any = { type: "string", knownValues: stringLiterals }; + const isStringEnum = + stringLiterals.length > 0 && hasStringType && unionRefs.length === 0; + + return { + unionRefs, + stringLiterals, + hasStringType, + hasUnknown, + isStringEnum, + }; + } - const maxLength = getMaxLength(this.program, unionType); - if (maxLength !== undefined) primitive.maxLength = maxLength; + private createStringEnumDef( + unionType: Union, + stringLiterals: string[], + prop?: ModelProperty, + ): LexiconDefinition { + const primitive: any = { type: "string", knownValues: stringLiterals }; - const minLength = getMinLength(this.program, unionType); - if (minLength !== undefined) primitive.minLength = minLength; + // Apply constraints + const maxLength = getMaxLength(this.program, unionType); + if (maxLength !== undefined) primitive.maxLength = maxLength; - const maxGraphemes = getMaxGraphemes(this.program, unionType); - if (maxGraphemes !== undefined) primitive.maxGraphemes = maxGraphemes; + const minLength = getMinLength(this.program, unionType); + if (minLength !== undefined) primitive.minLength = minLength; - const minGraphemes = getMinGraphemes(this.program, unionType); - if (minGraphemes !== undefined) primitive.minGraphemes = minGraphemes; + const maxGraphemes = getMaxGraphemes(this.program, unionType); + if (maxGraphemes !== undefined) primitive.maxGraphemes = maxGraphemes; - if (prop) { - const propDesc = getDoc(this.program, prop); - if (propDesc) primitive.description = propDesc; + const minGraphemes = getMinGraphemes(this.program, unionType); + if (minGraphemes !== undefined) primitive.minGraphemes = minGraphemes; - const defaultValue = (prop as any).default; - if (defaultValue?.value !== undefined && typeof defaultValue.value === "string") { - primitive.default = defaultValue.value; - } + // Add property-specific metadata + if (prop) { + const propDesc = getDoc(this.program, prop); + if (propDesc) primitive.description = propDesc; + + const defaultValue = (prop as any).default; + if ( + defaultValue?.value !== undefined && + typeof defaultValue.value === "string" + ) { + primitive.default = defaultValue.value; } - return primitive; } - if (unionRefs.length) { - if (stringLiterals.length) { + return primitive; + } + + private createUnionRefDef( + unionType: Union, + variants: ReturnType, + prop?: ModelProperty, + ): LexiconDefinition | null { + // Validate: cannot mix refs and string literals + if (variants.stringLiterals.length > 0) { + this.program.reportDiagnostic({ + code: "union-mixed-refs-literals", + severity: "error", + message: + `Union contains both model references and string literals. Atproto unions must be either: ` + + `(1) model references only (type: "union"), or ` + + `(2) string literals + string type (type: "string" with knownValues). ` + + `Separate these into distinct fields or nested unions.`, + target: unionType, + }); + return null; + } + + const unionDef: any = { type: "union", refs: variants.unionRefs }; + + // Handle closed unions + if (isClosed(this.program, unionType)) { + if (variants.hasUnknown) { this.program.reportDiagnostic({ - code: "union-mixed-refs-literals", + code: "closed-open-union", severity: "error", message: - `Union contains both model references and string literals. Atproto unions must be either: ` + - `(1) model references only (type: "union"), or ` + - `(2) string literals + string type (type: "string" with knownValues). ` + - `Separate these into distinct fields or nested unions.`, + "@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, }); - return null; - } - - const unionDef: any = { type: "union", refs: unionRefs }; - - if (isClosed(this.program, unionType)) { - if (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, - }); - } else { - unionDef.closed = true; - } + } else { + unionDef.closed = true; } - - const propDesc = prop ? getDoc(this.program, prop) : undefined; - return this.addDescription(unionDef, propDesc); - } - - if (!stringLiterals.length && !hasUnknown) { - this.program.reportDiagnostic({ - code: "union-empty", - severity: "error", - message: `Union has no variants. Atproto unions must contain either model references or string literals.`, - target: unionType, - }); } - return null; + const propDesc = prop ? getDoc(this.program, prop) : undefined; + return this.addDescription(unionDef, propDesc); } private addOperationToDefs( @@ -441,80 +613,121 @@ export class TlexEmitter { private addProcedureParams(def: any, operation: any) { if (!operation.parameters?.properties?.size) return; - const params = Array.from(operation.parameters.properties) as [string, any][]; + const params = Array.from(operation.parameters.properties) as [ + string, + any, + ][]; const paramCount = params.length; + // Validate parameter count if (paramCount > 2) { this.program.reportDiagnostic({ code: "procedure-too-many-params", severity: "error", - message: "Procedures can have at most 2 parameters (input and/or parameters)", + message: + "Procedures can have at most 2 parameters (input and/or parameters)", target: operation, }); return; } - if (paramCount === 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, - }); - } - this.addInput(def, param); - } else if (paramCount === 2) { - const [param1Name, param1] = params[0]; - const [param2Name, param2] = params[1]; + // Handle parameter count cases + switch (paramCount) { + case 1: + this.handleSingleProcedureParam(def, params[0], operation); + break; + case 2: + this.handleTwoProcedureParams(def, params[0], params[1], operation); + break; + } + } - if (param1Name !== "input") { - this.program.reportDiagnostic({ - code: "procedure-invalid-first-param", - severity: "error", - message: `First parameter must be named "input", got "${param1Name}"`, - target: param1, - }); - } + private handleSingleProcedureParam( + def: any, + [paramName, param]: [string, any], + operation: any, + ) { + // Validate parameter name + 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; + } - if (param2Name !== "parameters") { - this.program.reportDiagnostic({ - code: "procedure-invalid-second-param", - severity: "error", - message: `Second parameter must be named "parameters", got "${param2Name}"`, - target: param2, - }); - } + this.addInput(def, param); + } - if (param2.type.kind !== "Model" || (param2.type as any).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, - }); - } + private handleTwoProcedureParams( + def: any, + [param1Name, param1]: [string, any], + [param2Name, param2]: [string, any], + operation: any, + ) { + // Validate first parameter (input) + if (param1Name !== "input") { + this.program.reportDiagnostic({ + code: "procedure-invalid-first-param", + severity: "error", + message: `First parameter must be named "input", got "${param1Name}"`, + target: param1, + }); + } + + // Validate second parameter (parameters) + if (param2Name !== "parameters") { + this.program.reportDiagnostic({ + code: "procedure-invalid-second-param", + severity: "error", + message: `Second parameter must be named "parameters", got "${param2Name}"`, + target: param2, + }); + } - this.addInput(def, param1); + // Validate that parameters is a plain object + if (param2.type.kind !== "Model" || (param2.type as any).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 parametersModel = param2.type as any; - if (parametersModel.kind === "Model" && parametersModel.properties) { - const paramsObj: any = { type: "params", properties: {} }; - const required: string[] = []; + // Add input + this.addInput(def, param1); - for (const [propName, prop] of parametersModel.properties) { - const propDef = this.typeToLexiconDefinition(prop.type, prop); - if (propDef) { - paramsObj.properties[propName] = propDef; - if (!prop.optional) required.push(propName); - } - } + // Add parameters + this.addParametersFromModel(def, param2.type as any); + } - if (required.length) paramsObj.required = required; - def.parameters = paramsObj; + private addParametersFromModel(def: any, parametersModel: any) { + if (parametersModel.kind !== "Model" || !parametersModel.properties) { + return; + } + + const paramsObj: any = { type: "params", properties: {} }; + const required: string[] = []; + + for (const [propName, prop] of parametersModel.properties) { + const propDef = this.typeToLexiconDefinition(prop.type, prop); + if (propDef) { + paramsObj.properties[propName] = propDef; + if (!prop.optional) { + required.push(propName); + } } } + + if (required.length > 0) { + paramsObj.required = required; + } + + def.parameters = paramsObj; } private addInput(def: any, param: any) { @@ -558,72 +771,6 @@ export class TlexEmitter { if (errors?.length) def.errors = errors; } - private visitModel(model: Model) { - // Skip template models - if (model.templateMapper || isTemplateInstance(model)) { - return; - } - - // Get the lexicon ID from the namespace and model name - const namespace = model.namespace; - if (!namespace || namespace.name === "") { - return; - } - - const lexiconId = this.getModelLexiconId(model); - if (!lexiconId) { - return; - } - - // Create or get the lexicon document - let lexicon = this.lexicons.get(lexiconId); - if (!lexicon) { - lexicon = { - lexicon: 1, - id: lexiconId, - defs: {}, - }; - this.lexicons.set(lexiconId, lexicon); - } - - // Add the model as a definition - const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); - const modelDef = this.modelToLexiconObject(model); - - // Check if this is a defs file (ends with .defs) - if (lexiconId.endsWith(".defs")) { - // For defs files, all models go directly into defs object - const description = getDoc(this.program, model); - if (description && !modelDef.description) { - modelDef.description = description; - } - lexicon.defs[defName] = modelDef; - } else { - // For non-defs files, treat the first model as the main record - if (Object.keys(lexicon.defs).length === 0) { - // Check if this is the lexicon schema special case - const key = lexiconId === "com.atproto.lexicon.schema" ? "nsid" : "tid"; - - const recordDef: any = { - type: "record", - key: key, - record: modelDef, - }; - - // Move description from record object to record def - const description = getDoc(this.program, model); - if (description) { - recordDef.description = description; - delete modelDef.description; - } - - lexicon.defs.main = recordDef; - } else { - lexicon.defs[defName] = modelDef; - } - } - } - private modelToLexiconObject( model: Model, includeModelDescription: boolean = true, @@ -658,7 +805,8 @@ export class TlexEmitter { if (hasNull) { nullable.push(name); const nonNullVariant = variants.find( - (v) => !(v.type.kind === "Intrinsic" && (v.type as any).name === "null"), + (v) => + !(v.type.kind === "Intrinsic" && (v.type as any).name === "null"), ); if (nonNullVariant) typeToProcess = nonNullVariant.type; } @@ -669,7 +817,9 @@ export class TlexEmitter { } const obj: any = { type: "object" }; - const description = includeModelDescription ? getDoc(this.program, model) : undefined; + const description = includeModelDescription + ? getDoc(this.program, model) + : undefined; if (description) obj.description = description; if (required.length) obj.required = required; if (nullable.length) obj.nullable = nullable; @@ -685,193 +835,301 @@ export class TlexEmitter { const propDesc = prop ? getDoc(this.program, prop) : undefined; switch (type.kind) { - case "Namespace": { - const mainModel = (type as any).models?.get("Main"); - if (mainModel) { - const ref = this.getModelReference(mainModel); - if (ref) return this.addDescription({ type: "ref", ref }, propDesc); - } - return null; - } - case "Enum": { - const members = Array.from((type as any).members?.values?.() || []); - const values = members.map((m: any) => m.value); - const firstValue = values[0]; - - if (typeof firstValue === "string") { - return this.addDescription({ type: "string", enum: values }, propDesc); - } else if (typeof firstValue === "number" && Number.isInteger(firstValue)) { - return this.addDescription({ type: "integer", enum: values }, propDesc); - } - return null; - } - case "Boolean": { - return this.addDescription({ - type: "boolean", - const: (type as any).value - }, propDesc); - } + case "Namespace": + return this.handleNamespaceType(type as any, propDesc); + case "Enum": + return this.handleEnumType(type as any, propDesc); + case "Boolean": + return this.handleBooleanType(type as any, propDesc); case "Scalar": - const scalar = type as Scalar; - const primitive = this.scalarToLexiconPrimitive(scalar, prop); - if (!primitive) return null; - - if (propDesc) { - primitive.description = propDesc; - } else if (scalar.baseScalar && scalar.namespace?.name !== "TypeSpec") { - const FORMAT_SCALARS = new Set([ - "datetime", "did", "handle", "atUri", "cid", "tid", "nsid", - "recordKey", "uri", "language", "atIdentifier", "bytes", - "utcDateTime", "offsetDateTime", "plainDate", "plainTime", - ]); - if (!FORMAT_SCALARS.has(scalar.name)) { - const scalarDesc = getDoc(this.program, scalar); - if (scalarDesc) primitive.description = scalarDesc; - } - } - return primitive; + return this.handleScalarType(type as Scalar, prop, propDesc); case "Model": - const model = type as Model; + return this.handleModelType(type as Model, prop, propDesc); + case "Union": + return this.handleUnionType(type as Union, prop, isDefining, propDesc); + case "Intrinsic": + return this.addDescription({ type: "unknown" }, propDesc); + default: + // Unhandled type kind + return null; + } + } - const isBlobModel = - isBlob(this.program, model) || - (isTemplateInstance(model) && model.templateNode && isBlob(this.program, model.templateNode as any)) || - (model.baseModel && isBlob(this.program, model.baseModel)); + private handleNamespaceType( + ns: any, + propDesc?: string, + ): LexiconDefinition | null { + const mainModel = ns.models?.get("Main"); + if (mainModel) { + const ref = this.getModelReference(mainModel); + if (ref) { + return this.addDescription({ type: "ref", ref }, propDesc); + } + } + return null; + } - if (isBlobModel) { - return this.addDescription(this.createBlobDef(model), propDesc); - } + private handleEnumType( + enumType: any, + propDesc?: string, + ): LexiconDefinition | null { + const members = Array.from(enumType.members?.values?.() || []); - const isClosedModel = - model.name === "Closed" || - (model.node && (model.node as any).symbol?.name === "Closed") || - (isTemplateInstance(model) && model.node && (model.node as any).symbol?.name === "Closed"); - - if (isClosedModel && isTemplateInstance(model)) { - const unionArg = model.templateMapper?.args?.[0]; - if (unionArg && isType(unionArg) && unionArg.kind === "Union") { - const unionDef = this.typeToLexiconDefinition(unionArg, prop); - if (unionDef && unionDef.type === "union") { - (unionDef as LexiconUnion).closed = true; - return unionDef; - } - } - } + if (members.length === 0) { + // TODO: Should we error on empty enum? + return null; + } - const modelRef = this.getModelReference(model); - if (modelRef) { - return this.addDescription({ type: "ref", ref: modelRef }, propDesc); - } + const values = members.map((m: any) => m.value); + const firstValue = values[0]; - if (isArrayModelType(this.program, model)) { - return this.addDescription(this.modelToLexiconArray(model, prop), propDesc); - } + if (typeof firstValue === "string") { + return this.addDescription({ type: "string", enum: values }, propDesc); + } else if (typeof firstValue === "number" && Number.isInteger(firstValue)) { + return this.addDescription({ type: "integer", enum: values }, propDesc); + } - return this.addDescription(this.modelToLexiconObject(model), propDesc); - case "Union": - const unionType = type as Union; + // TODO: Handle mixed-type enums or float enums + return null; + } - if (!isDefining) { - const unionRef = this.getUnionReference(unionType); - if (unionRef) { - return this.addDescription({ type: "ref", ref: unionRef }, propDesc); - } + private handleBooleanType( + boolType: any, + propDesc?: string, + ): LexiconDefinition { + return this.addDescription( + { type: "boolean", const: boolType.value }, + propDesc, + ); + } + + private handleScalarType( + scalar: Scalar, + prop?: ModelProperty, + propDesc?: string, + ): LexiconDefinition | null { + const primitive = this.scalarToLexiconPrimitive(scalar, prop); + if (!primitive) return null; + + if (propDesc) { + primitive.description = propDesc; + } else if (scalar.baseScalar && scalar.namespace?.name !== "TypeSpec") { + // For custom scalars that extend base types, inherit description if not a format scalar + if (!FORMAT_SCALARS.has(scalar.name)) { + const scalarDesc = getDoc(this.program, scalar); + if (scalarDesc) primitive.description = scalarDesc; + } + } + return primitive; + } + + private handleModelType( + model: Model, + prop?: ModelProperty, + propDesc?: string, + ): LexiconDefinition | null { + // 1. Check for Blob type + if (this.isBlob(model)) { + return this.addDescription(this.createBlobDef(model), propDesc); + } + + // 2. Check for Closed template + if (this.isClosedUnionTemplate(model)) { + const unionArg = model.templateMapper?.args?.[0]; + if (unionArg && isType(unionArg) && unionArg.kind === "Union") { + const unionDef = this.typeToLexiconDefinition(unionArg, prop); + if (unionDef && unionDef.type === "union") { + (unionDef as LexiconUnion).closed = true; + return unionDef; } + } + // TODO: Handle Closed<> with non-union argument + return null; + } - return this.processUnion(unionType, prop); - case "Intrinsic": - return this.addDescription({ type: "unknown" }, propDesc); - default: + // 3. Check for model reference (named models from other namespaces) + const modelRef = this.getModelReference(model); + if (modelRef) { + return this.addDescription({ type: "ref", ref: modelRef }, propDesc); + } + + // 4. Check for array type + if (isArrayModelType(this.program, model)) { + const arrayDef = this.modelToLexiconArray(model, prop); + if (!arrayDef) { + // TODO: Handle array conversion failure return null; + } + return this.addDescription(arrayDef, propDesc); } + + // 5. Inline object + return this.addDescription(this.modelToLexiconObject(model), propDesc); + } + + private handleUnionType( + unionType: Union, + prop?: ModelProperty, + isDefining?: boolean, + propDesc?: string, + ): LexiconDefinition | null { + // Check if this is a named union that should be referenced + if (!isDefining) { + const unionRef = this.getUnionReference(unionType); + if (unionRef) { + return this.addDescription({ type: "ref", ref: unionRef }, propDesc); + } + } + + return this.processUnion(unionType, prop); } private scalarToLexiconPrimitive( scalar: Scalar, prop?: ModelProperty, ): LexiconDefinition | null { - const 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", - utcDateTime: "datetime", offsetDateTime: "datetime", - plainDate: "datetime", plainTime: "datetime" - }; - + // Special case: bytes type can be either blob or bytes depending on decorators if (scalar.name === "bytes") { - if (prop) { - const accept = getBlobAccept(this.program, prop); - const maxSize = getBlobMaxSize(this.program, prop); - if (accept || maxSize !== undefined) { - const blobDef: LexiconBlob = { type: "blob" }; - if (accept) blobDef.accept = accept; - if (maxSize !== undefined) blobDef.maxSize = maxSize; - return blobDef; - } - } - return { type: "bytes" }; + return this.handleBytesScalar(prop); } - const primitive: any = { type: "string" }; + // Determine base primitive type + const primitive: any = this.getBasePrimitiveType(scalar); - if (scalar.name === "boolean") primitive.type = "boolean"; - else if (["integer", "int32", "int64", "int16", "int8"].includes(scalar.name)) primitive.type = "integer"; - else if (["float32", "float64"].includes(scalar.name)) primitive.type = "number"; - - const format = FORMAT_MAP[scalar.name] || getLexFormat(this.program, prop || scalar); + // Apply format if applicable + const format = + FORMAT_MAP[scalar.name] || getLexFormat(this.program, prop || scalar); if (format) primitive.format = format; - const target = prop || scalar; + // Apply constraints + this.applyStringConstraints(primitive, prop || scalar); + this.applyNumericConstraints(primitive, prop); + + // Apply property-specific metadata + if (prop) { + this.applyPropertyMetadata(primitive, prop); + } + + return primitive; + } + + private handleBytesScalar(prop?: ModelProperty): LexiconDefinition { + if (prop) { + const accept = getBlobAccept(this.program, prop); + const maxSize = getBlobMaxSize(this.program, prop); + if (accept || maxSize !== undefined) { + const blobDef: LexiconBlob = { type: "blob" }; + if (accept) blobDef.accept = accept; + if (maxSize !== undefined) blobDef.maxSize = maxSize; + return blobDef; + } + } + return { type: "bytes" }; + } + + private getBasePrimitiveType(scalar: Scalar): any { + if (scalar.name === "boolean") { + return { type: "boolean" }; + } else if ( + ["integer", "int32", "int64", "int16", "int8"].includes(scalar.name) + ) { + return { type: "integer" }; + } else if (["float32", "float64"].includes(scalar.name)) { + return { type: "number" }; + } + return { type: "string" }; + } + + private applyStringConstraints( + primitive: any, + target: Scalar | ModelProperty, + ) { 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; + } - if (prop) { - const constValue = getLexConst(this.program, prop); - if (constValue !== undefined && - (primitive.type === "boolean" && typeof constValue === "boolean" || - primitive.type === "string" && typeof constValue === "string" || - primitive.type === "integer" && typeof constValue === "number")) { - primitive.const = constValue; - } + private applyNumericConstraints(primitive: any, prop?: ModelProperty) { + if ( + !prop || + (primitive.type !== "integer" && primitive.type !== "number") + ) { + return; + } - const defaultValue = (prop as any).default?.value; - if (defaultValue !== undefined && - (primitive.type === "string" && typeof defaultValue === "string" || - primitive.type === "integer" && typeof defaultValue === "number" || - primitive.type === "boolean" && typeof defaultValue === "boolean")) { - primitive.default = defaultValue; - } + const minValue = getMinValue(this.program, prop); + if (minValue !== undefined) primitive.minimum = minValue; - if (primitive.type === "integer" || primitive.type === "number") { - const minValue = getMinValue(this.program, prop); - if (minValue !== undefined) primitive.minimum = minValue; - const maxValue = getMaxValue(this.program, prop); - if (maxValue !== undefined) primitive.maximum = maxValue; - } + const maxValue = getMaxValue(this.program, prop); + if (maxValue !== undefined) primitive.maximum = maxValue; + } + + private applyPropertyMetadata(primitive: any, prop: ModelProperty) { + // Apply const value + const constValue = getLexConst(this.program, prop); + if ( + constValue !== undefined && + this.isValidConstForType(primitive.type, constValue) + ) { + primitive.const = constValue; } - return primitive; + // Apply default value + const defaultValue = (prop as any).default?.value; + if ( + defaultValue !== undefined && + this.isValidDefaultForType(primitive.type, defaultValue) + ) { + primitive.default = defaultValue; + } + } + + private isValidConstForType(primitiveType: string, constValue: any): boolean { + return ( + (primitiveType === "boolean" && typeof constValue === "boolean") || + (primitiveType === "string" && typeof constValue === "string") || + (primitiveType === "integer" && typeof constValue === "number") + ); + } + + private isValidDefaultForType( + primitiveType: string, + defaultValue: any, + ): boolean { + return ( + (primitiveType === "string" && typeof defaultValue === "string") || + (primitiveType === "integer" && typeof defaultValue === "number") || + (primitiveType === "boolean" && typeof defaultValue === "boolean") + ); } private getModelReference(model: Model): string | null { - if (!model.name || !model.namespace || model.namespace.name === "TypeSpec") return null; + if (!model.name || !model.namespace || model.namespace.name === "TypeSpec") + return null; const namespaceName = getNamespaceFullName(model.namespace); if (!namespaceName) return null; const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); - if (this.currentLexiconId === namespaceName || this.currentLexiconId === `${namespaceName}.defs`) { + if ( + this.currentLexiconId === namespaceName || + this.currentLexiconId === `${namespaceName}.defs` + ) { return `#${defName}`; } - return model.name === "Main" ? namespaceName : `${namespaceName}#${defName}`; + return model.name === "Main" + ? namespaceName + : `${namespaceName}#${defName}`; } private getUnionReference(union: Union): string | null { @@ -884,7 +1142,10 @@ export class TlexEmitter { const defName = unionName.charAt(0).toLowerCase() + unionName.slice(1); - if (this.currentLexiconId === namespaceName || this.currentLexiconId === `${namespaceName}.defs`) { + if ( + this.currentLexiconId === namespaceName || + this.currentLexiconId === `${namespaceName}.defs` + ) { return `#${defName}`; } @@ -917,20 +1178,13 @@ export class TlexEmitter { return null; } - private getModelLexiconId(model: Model): string | null { - if (!model.namespace) return null; - - const namespaceName = getNamespaceFullName(model.namespace); - if (!namespaceName) return null; - - if (model.name === "Main") return namespaceName; - - return `${namespaceName}.${model.name.charAt(0).toLowerCase() + model.name.slice(1)}`; - } - private getLexiconPath(lexiconId: string): string { const parts = lexiconId.split("."); - return join(this.options.outputDir, ...parts.slice(0, -1), parts[parts.length - 1] + ".json"); + return join( + this.options.outputDir, + ...parts.slice(0, -1), + parts[parts.length - 1] + ".json", + ); } private async writeFile(filePath: string, content: string) { diff --git a/packages/example/src/lexicons.ts b/packages/example/src/lexicons.ts index 6ac07c1..5084573 100644 --- a/packages/example/src/lexicons.ts +++ b/packages/example/src/lexicons.ts @@ -69,6 +69,11 @@ export const schemaDict = { }, }, }, + notificationType: { + type: 'string', + knownValues: ['like', 'repost', 'follow', 'mention', 'reply'], + description: 'Type of notification', + }, }, }, AppExampleFollow: { diff --git a/packages/example/src/types/app/example/defs.ts b/packages/example/src/types/app/example/defs.ts index c27c6e6..8156acc 100644 --- a/packages/example/src/types/app/example/defs.ts +++ b/packages/example/src/types/app/example/defs.ts @@ -68,3 +68,12 @@ export function isEntity(v: V) { export function validateEntity(v: V) { return validate(v, id, hashEntity) } + +/** Type of notification */ +export type NotificationType = + | 'like' + | 'repost' + | 'follow' + | 'mention' + | 'reply' + | (string & {})