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