An experimental TypeSpec syntax for Lexicon
56
fork

Configure Feed

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

fixture2

+256 -40
+21
typelex-emitter/lib/decorators.tsp
··· 1 + import "../dist/decorators.js"; 2 + 3 + /** 4 + * Specifies the lexicon format of a string field (e.g., "at-uri", "cid", "did", "datetime") 5 + */ 6 + extern dec lexFormat(target: unknown, format: valueof string); 7 + 8 + /** 9 + * Specifies a reference to another type 10 + */ 11 + extern dec ref(target: unknown, ref: valueof string); 12 + 13 + /** 14 + * Specifies a union of referenced types (comma-separated string) 15 + */ 16 + extern dec unionRefs(target: unknown, refs: valueof string); 17 + 18 + /** 19 + * Specifies the ref for array items 20 + */ 21 + extern dec arrayItems(target: unknown, itemRef: valueof string);
+3
typelex-emitter/lib/main.tsp
··· 1 + import "./decorators.tsp"; 2 + 3 + namespace ATProto;
+16 -11
typelex-emitter/src/decorators.ts
··· 1 - import { DecoratorContext, Program, Type } from "@typespec/compiler"; 1 + import { DecoratorContext, Program, Type, getNamespaceFullName } from "@typespec/compiler"; 2 + 3 + const formatKey = Symbol("lexFormat"); 2 4 3 5 // LexFormat decorator for string formats like "at-uri", "cid", "did", "datetime" 4 - export function $lexFormat(context: DecoratorContext, target: Type, format: string) { 6 + export function $lexFormat(context: DecoratorContext, target: Type, format: Type) { 5 7 decoratorProgram = context.program; 6 - context.program.stateMap(formatKey).set(target, format); 8 + // Extract the value from the Type if it's a string literal 9 + const formatValue = (format as any).kind === "String" ? (format as any).value : format; 10 + context.program.stateMap(formatKey).set(target, formatValue); 7 11 } 8 12 9 13 export function getLexFormat(program: Program, target: Type): string | undefined { 10 14 return program.stateMap(formatKey).get(target); 11 15 } 12 16 13 - const formatKey = Symbol("lexFormat"); 14 - 15 17 // Ref decorator for references to other types 16 18 export let decoratorProgram: any = null; 17 - export function $ref(context: DecoratorContext, target: Type, ref: string) { 19 + export function $ref(context: DecoratorContext, target: Type, ref: Type) { 18 20 decoratorProgram = context.program; 19 - context.program.stateMap(refKey).set(target, ref); 21 + const refValue = (ref as any).kind === "String" ? (ref as any).value : ref; 22 + context.program.stateMap(refKey).set(target, refValue); 20 23 } 21 24 22 25 export function getRef(program: Program, target: Type): string | undefined { ··· 26 29 const refKey = Symbol("ref"); 27 30 28 31 // UnionRefs decorator for union types 29 - export function $unionRefs(context: DecoratorContext, target: Type, refs: string) { 32 + export function $unionRefs(context: DecoratorContext, target: Type, refs: Type) { 30 33 decoratorProgram = context.program; 34 + const refsValue = (refs as any).kind === "String" ? (refs as any).value : refs; 31 35 // Parse comma-separated string into array 32 - const refsArray = refs.split(',').map(r => r.trim()); 36 + const refsArray = refsValue.split(',').map((r: string) => r.trim()); 33 37 context.program.stateMap(unionKey).set(target, refsArray); 34 38 } 35 39 ··· 40 44 const unionKey = Symbol("unionRefs"); 41 45 42 46 // ArrayItems decorator for array item refs 43 - export function $arrayItems(context: DecoratorContext, target: Type, itemRef: string) { 47 + export function $arrayItems(context: DecoratorContext, target: Type, itemRef: Type) { 44 48 decoratorProgram = context.program; 45 - context.program.stateMap(arrayItemsKey).set(target, itemRef); 49 + const itemRefValue = (itemRef as any).kind === "String" ? (itemRef as any).value : itemRef; 50 + context.program.stateMap(arrayItemsKey).set(target, itemRefValue); 46 51 } 47 52 48 53 export function getArrayItems(program: Program, target: Type): string | undefined {
+27 -17
typelex-emitter/src/emitter.ts
··· 10 10 isType, 11 11 getMaxLength, 12 12 } from "@typespec/compiler"; 13 - import { mkdir, writeFile } from "fs/promises"; 14 13 import { join, dirname } from "path"; 15 14 import type { 16 15 LexiconDocument, ··· 44 43 // Write all lexicon files 45 44 for (const [id, lexicon] of this.lexicons) { 46 45 const filePath = this.getLexiconPath(id); 47 - await this.writeFile(filePath, JSON.stringify(lexicon, null, 2)); 46 + await this.writeFile(filePath, JSON.stringify(lexicon, null, 2) + "\n"); 48 47 } 49 48 } 50 49 ··· 165 164 } 166 165 167 166 private modelToLexiconObject(model: Model): LexiconObject { 168 - const obj: LexiconObject = { 169 - type: "object", 170 - properties: {}, 171 - }; 172 - 167 + const description = getDoc(this.program, model); 173 168 const required: string[] = []; 174 - const description = getDoc(this.program, model); 175 - if (description) { 176 - obj.description = description; 177 - } 169 + const properties: any = {}; 178 170 179 171 for (const [name, prop] of model.properties) { 180 172 if (prop.optional !== true) { 181 173 required.push(name); 182 174 } 183 - 175 + 184 176 const propDef = this.typeToLexiconDefinition(prop.type, prop); 185 - if (propDef && obj.properties) { 186 - obj.properties[name] = propDef; 177 + if (propDef) { 178 + properties[name] = propDef; 187 179 } 188 180 } 189 181 182 + // Build object with correct key order 183 + const obj: any = { 184 + type: "object", 185 + }; 186 + 187 + if (description) { 188 + obj.description = description; 189 + } 190 + 190 191 if (required.length > 0) { 191 192 obj.required = required; 192 193 } 194 + 195 + obj.properties = properties; 193 196 194 197 return obj; 195 198 } ··· 282 285 return obj; 283 286 case "Intrinsic": 284 287 // Handle unknown type - return unknown definition 285 - return { 288 + const unknownDef: any = { 286 289 type: "unknown", 287 290 }; 291 + if (prop) { 292 + const propDesc = getDoc(this.program, prop); 293 + if (propDesc) { 294 + unknownDef.description = propDesc; 295 + } 296 + } 297 + return unknownDef; 288 298 default: 289 299 return null; 290 300 } ··· 411 421 412 422 private async writeFile(filePath: string, content: string) { 413 423 const dir = dirname(filePath); 414 - await mkdir(dir, { recursive: true }); 415 - await writeFile(filePath, content); 424 + await this.program.host.mkdirp(dir); 425 + await this.program.host.writeFile(filePath, content); 416 426 } 417 427 }
+1 -7
typelex-emitter/src/index.ts
··· 7 7 } 8 8 9 9 export async function $onEmit(context: EmitContext<TypeLexEmitterOptions>) { 10 - // If user specified output-dir in options, use that directly 11 - // Otherwise use TypeSpec's default emitterOutputDir 12 - const outputDir = context.options["output-dir"] 13 - ? resolvePath(context.options["output-dir"]) 14 - : context.emitterOutputDir; 15 - 16 10 const emitter = new TypeLexEmitter(context.program, { 17 - outputDir: outputDir, 11 + outputDir: context.emitterOutputDir, 18 12 }); 19 13 20 14 await emitter.emit();
+2 -3
typelex-emitter/test/fixtures.test.ts
··· 71 71 throw new Error(`Compilation failed with errors:\n${errorMessages}`); 72 72 } 73 73 74 - // The emitter should use the program that was used during decorator execution 75 - // because that's where the decorator state is stored 76 - const emitter = new TypeLexEmitter(decoratorProgram || program, { 74 + // Use program - but need to figure out why types are different instances 75 + const emitter = new TypeLexEmitter(program, { 77 76 outputDir: "output", 78 77 }); 79 78
+8 -2
typelex-emitter/test/fixtures/input/com/atproto/identity/defs.tsp
··· 1 + import "../../../../../../lib/main.tsp"; 2 + 3 + using ATProto; 4 + 1 5 namespace com.atproto.identity; 2 6 3 7 model IdentityInfo { 8 + @lexFormat("did") 4 9 did: string; 5 - 10 + 6 11 @doc("The validated handle of the account; or 'handle.invalid' if the handle did not bi-directionally match the DID document.") 12 + @lexFormat("handle") 7 13 handle: string; 8 - 14 + 9 15 @doc("The complete DID document for the identity.") 10 16 didDoc: unknown; 11 17 }
+134
typelex-emitter/test/scenarios.test.ts
··· 1 + import assert from "assert"; 2 + import path from "path"; 3 + import { describe, it } from "vitest"; 4 + import { formatDiagnostic, resolvePath } from "@typespec/compiler"; 5 + import { 6 + TypeSpecTestLibrary, 7 + createTestHost, 8 + findTestPackageRoot, 9 + resolveVirtualPath, 10 + } from "@typespec/compiler/testing"; 11 + import { readdirSync, statSync } from "fs"; 12 + import { readFile, readdir, stat } from "fs/promises"; 13 + 14 + const pkgRoot = await findTestPackageRoot(import.meta.url); 15 + const SCENARIOS_DIRECTORY = resolvePath(pkgRoot, "test/scenarios"); 16 + 17 + const TypeLexTestLibrary: TypeSpecTestLibrary = { 18 + name: "@typelex/emitter", 19 + packageRoot: await findTestPackageRoot(import.meta.url), 20 + files: [ 21 + { realDir: "", pattern: "package.json", virtualPath: "./node_modules/@typelex/emitter" }, 22 + { 23 + realDir: "dist", 24 + pattern: "*.js", 25 + virtualPath: "./node_modules/@typelex/emitter/dist", 26 + }, 27 + { realDir: "lib/", pattern: "*.tsp", virtualPath: "./node_modules/@typelex/emitter/lib" }, 28 + ], 29 + }; 30 + 31 + describe("lexicon scenarios", function () { 32 + const scenarios = readdirSync(SCENARIOS_DIRECTORY) 33 + .map((dn) => path.join(SCENARIOS_DIRECTORY, dn)) 34 + .filter((dn) => statSync(dn).isDirectory()); 35 + 36 + for (const scenario of scenarios) { 37 + const scenarioName = path.basename(scenario); 38 + 39 + it(scenarioName, async function () { 40 + const inputFiles = await readdirRecursive(path.join(scenario, "input")); 41 + 42 + const emitResult = await doEmit(inputFiles); 43 + const expectationDirectory = path.resolve(scenario, "output"); 44 + const expectedFiles = await readdirRecursive(expectationDirectory); 45 + 46 + assertFilesAsExpected(emitResult.files, expectedFiles); 47 + }); 48 + } 49 + }); 50 + 51 + interface EmitResult { 52 + files: Record<string, string>; 53 + diagnostics: string[]; 54 + } 55 + 56 + async function doEmit(files: Record<string, string>): Promise<EmitResult> { 57 + const baseOutputPath = resolveVirtualPath("test-output/"); 58 + 59 + const host = await createTestHost({ 60 + libraries: [TypeLexTestLibrary], 61 + }); 62 + 63 + for (const [fileName, content] of Object.entries(files)) { 64 + host.addTypeSpecFile(fileName, content); 65 + } 66 + 67 + const [, diagnostics] = await host.compileAndDiagnose("main.tsp", { 68 + outputDir: baseOutputPath, 69 + noEmit: false, 70 + emit: ["@typelex/emitter"], 71 + }); 72 + 73 + const outputFiles = Object.fromEntries( 74 + [...host.fs.entries()] 75 + .filter(([name]) => name.startsWith(baseOutputPath)) 76 + .map(([name, value]) => { 77 + let relativePath = name.replace(baseOutputPath, ""); 78 + // Strip the @typelex/emitter/ prefix if present 79 + if (relativePath.startsWith("@typelex/emitter/")) { 80 + relativePath = relativePath.replace("@typelex/emitter/", ""); 81 + } 82 + return [relativePath, value]; 83 + }), 84 + ); 85 + 86 + return { 87 + files: outputFiles, 88 + diagnostics: diagnostics.map((x) => formatDiagnostic(x)), 89 + }; 90 + } 91 + 92 + function assertFilesAsExpected( 93 + outputFiles: Record<string, string>, 94 + expectedFiles: Record<string, string>, 95 + ) { 96 + for (const fn of Object.keys(expectedFiles)) { 97 + assert.ok( 98 + Object.prototype.hasOwnProperty.call(outputFiles, fn), 99 + `expected file ${fn} was not produced`, 100 + ); 101 + } 102 + 103 + for (const [fn, content] of Object.entries(outputFiles)) { 104 + const expectedContent = expectedFiles[fn]; 105 + 106 + assert.ok(expectedContent, `output file ${fn} has no corresponding expectation`); 107 + 108 + assert.strictEqual(content, expectedContent); 109 + } 110 + } 111 + 112 + async function readdirRecursive(dir: string): Promise<Record<string, string>> { 113 + const result: Record<string, string> = {}; 114 + 115 + async function walk(currentDir: string, relativePath: string) { 116 + const entries = await readdir(currentDir); 117 + 118 + for (const entry of entries) { 119 + const fullPath = path.join(currentDir, entry); 120 + const stats = await stat(fullPath); 121 + 122 + if (stats.isDirectory()) { 123 + await walk(fullPath, path.join(relativePath, entry)); 124 + } else { 125 + const content = await readFile(fullPath, "utf-8"); 126 + const key = path.join(relativePath, entry); 127 + result[key] = content; 128 + } 129 + } 130 + } 131 + 132 + await walk(dir, ""); 133 + return result; 134 + }
+15
typelex-emitter/test/scenarios/identity-defs/input/main.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + namespace com.atproto.identity; 4 + 5 + model IdentityInfo { 6 + @lexFormat("did") 7 + did: string; 8 + 9 + @doc("The validated handle of the account; or 'handle.invalid' if the handle did not bi-directionally match the DID document.") 10 + @lexFormat("handle") 11 + handle: string; 12 + 13 + @doc("The complete DID document for the identity.") 14 + didDoc: unknown; 15 + }
+29
typelex-emitter/test/scenarios/identity-defs/output/com/atproto/identity/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.identity.defs", 4 + "defs": { 5 + "identityInfo": { 6 + "type": "object", 7 + "required": [ 8 + "did", 9 + "handle", 10 + "didDoc" 11 + ], 12 + "properties": { 13 + "did": { 14 + "type": "string", 15 + "format": "did" 16 + }, 17 + "handle": { 18 + "type": "string", 19 + "format": "handle", 20 + "description": "The validated handle of the account; or 'handle.invalid' if the handle did not bi-directionally match the DID document." 21 + }, 22 + "didDoc": { 23 + "type": "unknown", 24 + "description": "The complete DID document for the identity." 25 + } 26 + } 27 + } 28 + } 29 + }