An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

Select the types of activity you want to include in your feed.

wip

+219 -56
+104 -5
typelex-emitter/src/emitter.ts
··· 8 8 getNamespaceFullName, 9 9 isTemplateInstance, 10 10 isType, 11 + getMaxLength, 11 12 } from "@typespec/compiler"; 12 13 import { mkdir, writeFile } from "fs/promises"; 13 14 import { join, dirname } from "path"; 14 - import type { 15 - LexiconDocument, 16 - LexiconDefinition, 15 + import type { 16 + LexiconDocument, 17 + LexiconDefinition, 17 18 LexiconObject, 18 19 LexiconPrimitive, 19 20 LexiconArray, 21 + LexiconRef, 22 + LexiconUnion, 20 23 } from "./types.js"; 24 + import { getLexFormat, getRef, getUnionRefs, getArrayItems } from "./decorators.js"; 21 25 22 26 export interface EmitterOptions { 23 27 outputDir: string; ··· 191 195 } 192 196 193 197 private typeToLexiconDefinition(type: Type, prop?: ModelProperty): LexiconDefinition | null { 198 + // Check for decorators on the property first 199 + if (prop) { 200 + // Check for @ref decorator 201 + const ref = getRef(this.program, prop); 202 + if (ref) { 203 + const refDef: LexiconRef = { 204 + type: "ref", 205 + ref: ref, 206 + }; 207 + const propDesc = getDoc(this.program, prop); 208 + if (propDesc) { 209 + refDef.description = propDesc; 210 + } 211 + return refDef; 212 + } 213 + 214 + // Check for @unionRefs decorator 215 + const unionRefs = getUnionRefs(this.program, prop); 216 + if (unionRefs) { 217 + const unionDef: LexiconUnion = { 218 + type: "union", 219 + refs: unionRefs, 220 + }; 221 + const propDesc = getDoc(this.program, prop); 222 + if (propDesc) { 223 + unionDef.description = propDesc; 224 + } 225 + return unionDef; 226 + } 227 + 228 + // Check for @arrayItems decorator 229 + const arrayItemRef = getArrayItems(this.program, prop); 230 + if (arrayItemRef) { 231 + const arrayDef: LexiconArray = { 232 + type: "array", 233 + items: { 234 + type: "ref", 235 + ref: arrayItemRef, 236 + }, 237 + }; 238 + const propDesc = getDoc(this.program, prop); 239 + if (propDesc) { 240 + arrayDef.description = propDesc; 241 + } 242 + return arrayDef; 243 + } 244 + } 245 + 194 246 switch (type.kind) { 195 247 case "Scalar": 196 - const primitive = this.scalarToLexiconPrimitive(type as Scalar); 248 + const primitive = this.scalarToLexiconPrimitive(type as Scalar, prop); 197 249 if (prop && primitive) { 198 250 const propDesc = getDoc(this.program, prop); 199 251 if (propDesc) { ··· 212 264 } 213 265 return array; 214 266 } 267 + // Check if this is a reference to another model 268 + const modelRef = this.getModelReference(type as Model); 269 + if (modelRef) { 270 + return { 271 + type: "ref", 272 + ref: modelRef, 273 + }; 274 + } 215 275 const obj = this.modelToLexiconObject(type as Model); 216 276 if (prop) { 217 277 const propDesc = getDoc(this.program, prop); ··· 220 280 } 221 281 } 222 282 return obj; 283 + case "Intrinsic": 284 + // Handle unknown type - return unknown definition 285 + return { 286 + type: "unknown", 287 + }; 223 288 default: 224 289 return null; 225 290 } 226 291 } 227 292 228 - private scalarToLexiconPrimitive(scalar: Scalar): LexiconPrimitive { 293 + private scalarToLexiconPrimitive(scalar: Scalar, prop?: ModelProperty): LexiconPrimitive { 229 294 const primitive: LexiconPrimitive = { 230 295 type: "string", // default 231 296 }; ··· 260 325 }; 261 326 } 262 327 328 + // Check for @lexFormat decorator on the property 329 + if (prop) { 330 + const format = getLexFormat(this.program, prop); 331 + if (format) { 332 + primitive.format = format; 333 + } 334 + 335 + // Check for @maxLength decorator 336 + const maxLength = getMaxLength(this.program, prop); 337 + if (maxLength !== undefined) { 338 + primitive.maxLength = maxLength; 339 + } 340 + } 341 + 263 342 return primitive; 264 343 } 265 344 266 345 private isArrayType(model: Model): boolean { 267 346 return model.name === "Array" && model.namespace?.name === "TypeSpec"; 347 + } 348 + 349 + private getModelReference(model: Model): string | null { 350 + // If the model is in the same namespace, use local ref (#modelName) 351 + // Otherwise use fully qualified ref (namespace.modelName) 352 + if (!model.namespace || model.namespace.name === "" || model.namespace.name === "TypeSpec") { 353 + return null; 354 + } 355 + 356 + const namespaceName = getNamespaceFullName(model.namespace); 357 + if (!namespaceName) { 358 + return null; 359 + } 360 + 361 + const defName = model.name.charAt(0).toLowerCase() + model.name.slice(1); 362 + 363 + // Check if it's in the current namespace being processed 364 + // For now, always return fully qualified refs 365 + // TODO: optimize for same-namespace refs 366 + return `${namespaceName}.defs#${defName}`; 268 367 } 269 368 270 369 private modelToLexiconArray(model: Model): LexiconArray | null {
+6 -3
typelex-emitter/src/index.ts
··· 9 9 export async function $onEmit(context: EmitContext<TypeLexEmitterOptions>) { 10 10 // If user specified output-dir in options, use that directly 11 11 // Otherwise use TypeSpec's default emitterOutputDir 12 - const outputDir = context.options["output-dir"] 12 + const outputDir = context.options["output-dir"] 13 13 ? resolvePath(context.options["output-dir"]) 14 14 : context.emitterOutputDir; 15 - 15 + 16 16 const emitter = new TypeLexEmitter(context.program, { 17 17 outputDir: outputDir, 18 18 }); 19 19 20 20 await emitter.emit(); 21 - } 21 + } 22 + 23 + // Export decorators 24 + export { $lexFormat, $ref, $unionRefs, $arrayItems } from "./decorators.js";
+36 -41
typelex-emitter/test/fixtures.test.ts
··· 4 4 import { fileURLToPath } from "url"; 5 5 import { compile, NodeHost, resolvePath } from "@typespec/compiler"; 6 6 import { TypeLexEmitter } from "../src/emitter.js"; 7 + import { decoratorProgram } from "../src/decorators.js"; 7 8 8 9 const __filename = fileURLToPath(import.meta.url); 9 10 const __dirname = dirname(__filename); ··· 34 35 return files; 35 36 } 36 37 37 - describe("Fixtures Tests", { timeout: 30000 }, () => { 38 - it("should compile all TypeSpec fixtures correctly", async () => { 39 - const tspFiles = await findAllTspFiles(inputDir); 38 + describe("Fixtures Tests", { timeout: 30000 }, async () => { 39 + const tspFiles = await findAllTspFiles(inputDir); 40 40 41 - for (const tspFile of tspFiles) { 42 - // Get relative path for test name 43 - const relativePath = relative(inputDir, tspFile); 44 - const testName = relativePath.replace(/\.tsp$/, ''); 41 + for (const tspFile of tspFiles) { 42 + // Get relative path for test name 43 + const relativePath = relative(inputDir, tspFile); 44 + const testName = relativePath.replace(/\.tsp$/, ''); 45 45 46 - // Corresponding JSON file 47 - const expectedJsonFile = join(outputDir, relativePath.replace(/\.tsp$/, '.json')); 46 + // Corresponding JSON file 47 + const expectedJsonFile = join(outputDir, relativePath.replace(/\.tsp$/, '.json')); 48 48 49 - // Skip if expected file doesn't exist 50 - try { 51 - await stat(expectedJsonFile); 52 - } catch { 53 - console.log(`Skipping ${testName} - no expected output file`); 54 - continue; 55 - } 49 + // Skip if expected file doesn't exist 50 + try { 51 + await stat(expectedJsonFile); 52 + } catch { 53 + continue; 54 + } 56 55 57 - console.log(`Testing ${testName}...`); 56 + it(testName, async () => { 57 + // Create an in-memory output collector 58 + const outputs = new Map<string, string>(); 58 59 59 - // Compile in-memory using TypeSpec programmatic API 60 + // Compile with emitter registered 60 61 const host = NodeHost; 61 62 const program = await compile(host, tspFile, { 62 63 noEmit: false, 63 - emitters: {}, 64 + emitters: { 65 + "@typelex/emitter": { 66 + "output-dir": "output", 67 + }, 68 + }, 69 + workingDir: dirname(tspFile), 64 70 }); 65 71 66 72 // Check for compilation errors 67 - if (program.diagnostics.length > 0) { 68 - const errors = program.diagnostics.filter(d => d.severity === "error"); 69 - if (errors.length > 0) { 70 - throw new Error(`Compilation failed with errors for ${testName}: ${JSON.stringify(errors)}`); 71 - } 73 + const errors = program.diagnostics.filter(d => d.severity === "error"); 74 + if (errors.length > 0) { 75 + const errorMessages = errors.map(e => `${e.code}: ${e.message}`).join('\n'); 76 + throw new Error(`Compilation failed with errors:\n${errorMessages}`); 72 77 } 73 78 74 - // Create an in-memory output collector 75 - const outputs = new Map<string, string>(); 76 - 77 - // Create emitter with in-memory file writer 78 - const emitter = new TypeLexEmitter(program, { 79 + // The emitter should use the program that was used during decorator execution 80 + // because that's where the decorator state is stored 81 + const emitter = new TypeLexEmitter(decoratorProgram || program, { 79 82 outputDir: "output", 80 83 }); 81 84 ··· 100 103 const expectedJson = JSON.parse(await readFile(expectedJsonFile, "utf-8")); 101 104 const parsedGenerated = JSON.parse(generatedJson); 102 105 103 - // Compare with better error messages 104 - try { 105 - expect(parsedGenerated).toEqual(expectedJson); 106 - console.log(`✅ ${testName} passed`); 107 - } catch (error) { 108 - console.error(`❌ ${testName} failed`); 109 - console.error("Generated:", JSON.stringify(parsedGenerated, null, 2)); 110 - console.error("Expected:", JSON.stringify(expectedJson, null, 2)); 111 - throw error; 112 - } 113 - } 114 - }); 106 + // Compare 107 + expect(parsedGenerated).toEqual(expectedJson); 108 + }); 109 + } 115 110 });
+73 -7
typelex-emitter/test/fixtures/input/app/bsky/feed/defs.tsp
··· 1 + import "../../../../../../lib/main.tsp"; 2 + 3 + using ATProto; 4 + 1 5 namespace app.bsky.feed; 2 6 3 7 model PostView { 8 + @lexFormat("at-uri") 4 9 uri: string; 10 + 11 + @lexFormat("cid") 5 12 cid: string; 6 - author: unknown; // ref to app.bsky.actor.defs#profileViewBasic 13 + 14 + @ref("app.bsky.actor.defs#profileViewBasic") 15 + author: unknown; 16 + 7 17 record: unknown; 8 - embed?: unknown; // union type 18 + 19 + @unionRefs("app.bsky.embed.images#view,app.bsky.embed.video#view,app.bsky.embed.external#view,app.bsky.embed.record#view,app.bsky.embed.recordWithMedia#view") 20 + embed?: unknown; 21 + 9 22 bookmarkCount?: int32; 10 23 replyCount?: int32; 11 24 repostCount?: int32; 12 25 likeCount?: int32; 13 26 quoteCount?: int32; 14 27 indexedAt: utcDateTime; 15 - viewer?: ViewerState; 16 - labels?: unknown[]; // ref to com.atproto.label.defs#label 17 - threadgate?: unknown; // ref to #threadgateView 28 + 29 + @ref("#viewerState") 30 + viewer?: unknown; 31 + 32 + @arrayItems("com.atproto.label.defs#label") 33 + labels?: unknown[]; 34 + 35 + @ref("#threadgateView") 36 + threadgate?: unknown; 18 37 } 19 38 20 39 @doc("Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.") 21 40 model ViewerState { 41 + @lexFormat("at-uri") 22 42 repost?: string; 43 + 44 + @lexFormat("at-uri") 23 45 like?: string; 46 + 24 47 bookmarked?: boolean; 25 48 threadMuted?: boolean; 26 49 replyDisabled?: boolean; ··· 30 53 31 54 @doc("Metadata about this post within the context of the thread it is in.") 32 55 model ThreadContext { 56 + @lexFormat("at-uri") 33 57 rootAuthorLike?: string; 34 58 } 35 59 36 60 model FeedViewPost { 37 - post: PostView; 38 - reason?: unknown; // union type 61 + @ref("#postView") 62 + post: unknown; 63 + 64 + @ref("#replyRef") 65 + reply?: unknown; 66 + 67 + @unionRefs("#reasonRepost,#reasonPin") 68 + reason?: unknown; 69 + 70 + @maxLength(2000) 71 + @doc("Context provided by feed generator that may be passed back alongside interactions.") 39 72 feedContext?: string; 73 + 74 + @maxLength(100) 75 + @doc("Unique identifier per request that may be passed back alongside interactions.") 76 + reqId?: string; 77 + } 78 + 79 + model ReplyRef { 80 + @unionRefs("#postView,#notFoundPost,#blockedPost") 81 + root: unknown; 82 + 83 + @unionRefs("#postView,#notFoundPost,#blockedPost") 84 + parent: unknown; 85 + 86 + @ref("app.bsky.actor.defs#profileViewBasic") 87 + @doc("When parent is a reply to another post, this is the author of that post.") 88 + grandparentAuthor?: unknown; 89 + } 90 + 91 + model ReasonRepost { 92 + @ref("app.bsky.actor.defs#profileViewBasic") 93 + by: unknown; 94 + 95 + @lexFormat("at-uri") 96 + uri?: string; 97 + 98 + @lexFormat("cid") 99 + cid?: string; 100 + 101 + indexedAt: utcDateTime; 102 + } 103 + 104 + model ReasonPin { 105 + // Empty model 40 106 }