An experimental TypeSpec syntax for Lexicon

fix reserved words when codegening

Changed files
+162 -3
packages
cli
src
test
scenarios
reserved-keywords
expected
lexicons
app
bsky
feed
com
atproto
server
pub
leaflet
example
typelex
project
lexicons
app
bsky
feed
com
atproto
server
+4
CHANGELOG.md
··· 1 + ### 0.3.1 2 + 3 + - Escape reserved keywords when generating code 4 + 1 5 ### 0.3.0 2 6 3 7 - New package `@typelex/cli`
+3 -1
packages/cli/src/commands/init.ts
··· 4 4 import { createInterface } from "readline"; 5 5 import pc from "picocolors"; 6 6 import { generateExternalsFile } from "../utils/externals-generator.js"; 7 + import { escapeTypeSpecKeywords } from "../utils/escape-keywords.js"; 7 8 8 9 function gradientText(text: string): string { 9 10 const colors = [ ··· 29 30 } 30 31 31 32 function createMainTemplate(namespace: string): string { 33 + const escapedNamespace = escapeTypeSpecKeywords(namespace); 32 34 return `import "@typelex/emitter"; 33 35 import "./externals.tsp"; 34 36 35 - namespace ${namespace}.example.profile { 37 + namespace ${escapedNamespace}.example.profile { 36 38 /** My profile. */ 37 39 @rec("literal:self") 38 40 model Main {
+28
packages/cli/src/utils/escape-keywords.ts
··· 1 + /** 2 + * Complete list of TypeSpec reserved keywords (67 total) 3 + * Source: @typespec/compiler/src/core/scanner.ts 4 + */ 5 + const TYPESPEC_KEYWORDS = new Set([ 6 + // Active keywords 7 + "import", "model", "scalar", "namespace", "using", "op", "enum", "alias", 8 + "is", "interface", "union", "projection", "else", "if", "dec", "fn", 9 + "const", "init", "extern", "extends", "true", "false", "return", "void", 10 + "never", "unknown", "valueof", "typeof", 11 + // Reserved keywords 12 + "statemachine", "macro", "package", "metadata", "env", "arg", "declare", 13 + "array", "struct", "record", "module", "mod", "sym", "context", "prop", 14 + "property", "scenario", "pub", "sub", "typeref", "trait", "this", "self", 15 + "super", "keyof", "with", "implements", "impl", "satisfies", "flag", "auto", 16 + "partial", "private", "public", "protected", "internal", "sealed", "local", 17 + "async" 18 + ]); 19 + 20 + /** 21 + * Escape TypeSpec reserved keywords in a namespace identifier 22 + * Example: "pub.leaflet.example" -> "`pub`.leaflet.example" 23 + */ 24 + export function escapeTypeSpecKeywords(nsid: string): string { 25 + return nsid.split('.').map(part => 26 + TYPESPEC_KEYWORDS.has(part) ? `\`${part}\`` : part 27 + ).join('.'); 28 + }
+3 -2
packages/cli/src/utils/externals-generator.ts
··· 1 1 import { resolve } from "path"; 2 2 import { writeFile, mkdir } from "fs/promises"; 3 3 import { findExternalLexicons, LexiconDoc, isTokenDef, isModelDef } from "./lexicon.js"; 4 + import { escapeTypeSpecKeywords } from "./escape-keywords.js"; 4 5 5 6 /** 6 7 * Convert camelCase to PascalCase ··· 38 39 39 40 for (const [nsid, lexicon] of sortedNamespaces) { 40 41 lines.push("@external"); 41 - // Escape reserved keywords in namespace (like 'record') 42 - const escapedNsid = nsid.replace(/\b(record|union|enum|interface|namespace|model|op|import|using|extends|is|scalar|alias|if|else|return|void|never|unknown|any|true|false|null)\b/g, '`$1`'); 42 + // Escape reserved keywords in namespace 43 + const escapedNsid = escapeTypeSpecKeywords(nsid); 43 44 lines.push(`namespace ${escapedNsid} {`); 44 45 45 46 // Sort definitions for consistent output
+14
packages/cli/test/scenarios/reserved-keywords/expected/lexicons/app/bsky/feed/post/record.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post.record", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "text": { 9 + "type": "string" 10 + } 11 + } 12 + } 13 + } 14 + }
+14
packages/cli/test/scenarios/reserved-keywords/expected/lexicons/com/atproto/server/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.server.defs", 4 + "defs": { 5 + "inviteCode": { 6 + "type": "object", 7 + "properties": { 8 + "code": { 9 + "type": "string" 10 + } 11 + } 12 + } 13 + } 14 + }
+21
packages/cli/test/scenarios/reserved-keywords/expected/lexicons/pub/leaflet/example/profile.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "pub.leaflet.example.profile", 4 + "defs": { 5 + "main": { 6 + "type": "record", 7 + "key": "literal:self", 8 + "record": { 9 + "type": "object", 10 + "properties": { 11 + "description": { 12 + "type": "string", 13 + "maxGraphemes": 256, 14 + "description": "Free-form profile description." 15 + } 16 + } 17 + }, 18 + "description": "My profile." 19 + } 20 + } 21 + }
+7
packages/cli/test/scenarios/reserved-keywords/expected/package.json
··· 1 + { 2 + "name": "reserved-keywords-test", 3 + "type": "module", 4 + "scripts": { 5 + "build:typelex": "typelex compile pub.leaflet.*" 6 + } 7 + }
+14
packages/cli/test/scenarios/reserved-keywords/expected/typelex/externals.tsp
··· 1 + import "@typelex/emitter"; 2 + 3 + // Generated by typelex from ./lexicons (excluding pub.leaflet.*) 4 + // This file is auto-generated. Do not edit manually. 5 + 6 + @external 7 + namespace app.bsky.feed.post.`record` { 8 + model Main { } 9 + } 10 + 11 + @external 12 + namespace com.atproto.server.defs { 13 + model InviteCode { } 14 + }
+12
packages/cli/test/scenarios/reserved-keywords/expected/typelex/main.tsp
··· 1 + import "@typelex/emitter"; 2 + import "./externals.tsp"; 3 + 4 + namespace `pub`.leaflet.example.profile { 5 + /** My profile. */ 6 + @rec("literal:self") 7 + model Main { 8 + /** Free-form profile description.*/ 9 + @maxGraphemes(256) 10 + description?: string; 11 + } 12 + }
+14
packages/cli/test/scenarios/reserved-keywords/project/lexicons/app/bsky/feed/post/record.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "app.bsky.feed.post.record", 4 + "defs": { 5 + "main": { 6 + "type": "object", 7 + "properties": { 8 + "text": { 9 + "type": "string" 10 + } 11 + } 12 + } 13 + } 14 + }
+14
packages/cli/test/scenarios/reserved-keywords/project/lexicons/com/atproto/server/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "com.atproto.server.defs", 4 + "defs": { 5 + "inviteCode": { 6 + "type": "object", 7 + "properties": { 8 + "code": { 9 + "type": "string" 10 + } 11 + } 12 + } 13 + } 14 + }
+4
packages/cli/test/scenarios/reserved-keywords/project/package.json
··· 1 + { 2 + "name": "reserved-keywords-test", 3 + "type": "module" 4 + }
+10
packages/cli/test/scenarios/reserved-keywords/test.ts
··· 1 + export async function run(project) { 2 + await project.init("pub.leaflet.*"); 3 + 4 + await project.runBuildScript(); 5 + await project.compareTo("expected"); 6 + 7 + // Second build - verify idempotency 8 + await project.runBuildScript(); 9 + await project.compareTo("expected"); 10 + }