An experimental TypeSpec syntax for Lexicon

fix union with tokens

Changed files
+61 -71
packages
emitter
src
test
spec
basic
input
com
output
com
+61 -15
packages/emitter/src/emitter.ts
··· 369 369 return; 370 370 } 371 371 372 - // Only string enums can be added as defs 372 + // Only string enums (including token refs) can be added as defs 373 373 // Union refs (type: "union") must be inlined at usage sites 374 374 if (unionDef.type === "string" && (unionDef.knownValues || unionDef.enum)) { 375 375 const defName = name.charAt(0).toLowerCase() + name.slice(1); ··· 380 380 code: "union-refs-not-allowed-as-def", 381 381 severity: "error", 382 382 message: 383 - `Named unions of model references cannot be defined as standalone defs. ` + 384 - `Use @inline to inline them at usage sites, or use string enums instead.`, 383 + `Named unions of non-token model references cannot be defined as standalone defs. ` + 384 + `Use @inline to inline them at usage sites, use @token models for known values, or use string literals.`, 385 385 target: union, 386 386 }); 387 387 } ··· 461 461 private unionToLexiconProperty( 462 462 unionType: Union, 463 463 prop?: ModelProperty, 464 + isDefining?: boolean, 464 465 ): LexObjectProperty | null { 465 466 const variants = this.parseUnionVariants(unionType); 466 467 ··· 503 504 (variants.stringLiterals.length > 0 && 504 505 !variants.hasStringType && 505 506 variants.unionRefs.length === 0 && 507 + variants.knownValueRefs.length === 0 && 506 508 isClosed(this.program, unionType)) 507 509 ) { 508 510 const isClosedUnion = isClosed(this.program, unionType); ··· 514 516 const minLength = getMinLength(this.program, unionType); 515 517 const maxGraphemes = getMaxGraphemes(this.program, unionType); 516 518 const minGraphemes = getMinGraphemes(this.program, unionType); 519 + 520 + // Combine string literals and token refs for known values 521 + const allKnownValues = [ 522 + ...variants.stringLiterals, 523 + ...variants.knownValueRefs, 524 + ]; 525 + 517 526 return { 518 527 type: "string", 519 - [isClosedUnion ? "enum" : "knownValues"]: variants.stringLiterals, 528 + [isClosedUnion ? "enum" : "knownValues"]: allKnownValues, 520 529 ...(propDesc && { description: propDesc }), 521 530 ...(defaultValue !== undefined && 522 531 typeof defaultValue === "string" && { default: defaultValue }), ··· 529 538 530 539 // Model reference union (including empty union with unknown) 531 540 if (variants.unionRefs.length > 0 || variants.hasUnknown) { 532 - if (variants.stringLiterals.length > 0) { 541 + if (variants.stringLiterals.length > 0 || variants.knownValueRefs.length > 0) { 533 542 this.program.reportDiagnostic({ 534 543 code: "union-mixed-refs-literals", 535 544 severity: "error", 536 545 message: 537 - `Union contains both model references and string literals. Lexicon unions must be either: ` + 538 - `(1) model references only (type: "union"), ` + 539 - `(2) string literals + string type (type: "string" with knownValues), or ` + 546 + `Union contains both non-token model references and string literals/token refs. Lexicon unions must be either: ` + 547 + `(1) non-token model references only (type: "union"), ` + 548 + `(2) token refs + string literals + string type (type: "string" with knownValues), or ` + 540 549 `(3) integer literals + integer type (type: "integer" with knownValues). ` + 541 550 `Separate these into distinct fields or nested unions.`, 542 551 target: unionType, ··· 609 618 const stringLiterals: string[] = []; 610 619 const numericLiterals: number[] = []; 611 620 const booleanLiterals: boolean[] = []; 621 + const tokenModels: Model[] = []; 612 622 let hasStringType = false; 613 623 let hasUnknown = false; 614 624 615 625 for (const variant of unionType.variants.values()) { 616 626 switch (variant.type.kind) { 617 627 case "Model": 618 - const ref = this.getModelReference(variant.type as Model); 619 - if (ref) unionRefs.push(ref); 628 + const model = variant.type as Model; 629 + // Collect token models separately - they're treated differently based on hasStringType 630 + if (isToken(this.program, model)) { 631 + tokenModels.push(model); 632 + } else { 633 + const ref = this.getModelReference(model); 634 + if (ref) unionRefs.push(ref); 635 + } 620 636 break; 621 637 case "String": 622 638 stringLiterals.push((variant.type as StringLiteral).value); ··· 641 657 } 642 658 } 643 659 660 + // Validate: tokens must appear with | string 661 + // Per Lexicon spec line 240: "unions can not reference token" 662 + if (tokenModels.length > 0 && !hasStringType) { 663 + this.program.reportDiagnostic({ 664 + code: "tokens-require-string", 665 + severity: "error", 666 + message: 667 + "Tokens must be used with | string. Per Lexicon spec, tokens encode as string values and cannot appear in union refs.", 668 + target: unionType, 669 + }); 670 + } 671 + 672 + // Token models become "known values" (always fully qualified refs) 673 + const knownValueRefs = tokenModels 674 + .map((m) => this.getModelReference(m, true)) 675 + .filter((ref): ref is string => ref !== null); 676 + 644 677 const isStringEnum = 645 - stringLiterals.length > 0 && hasStringType && unionRefs.length === 0; 678 + (stringLiterals.length > 0 || knownValueRefs.length > 0) && 679 + hasStringType && 680 + unionRefs.length === 0; 646 681 647 682 return { 648 683 unionRefs, 649 684 stringLiterals, 650 685 numericLiterals, 651 686 booleanLiterals, 687 + knownValueRefs, 652 688 hasStringType, 653 689 hasUnknown, 654 690 isStringEnum, ··· 1194 1230 } 1195 1231 } 1196 1232 1197 - const unionDef = this.unionToLexiconProperty(unionType, prop); 1233 + const unionDef = this.unionToLexiconProperty(unionType, prop, isDefining); 1198 1234 if (!unionDef) return null; 1199 1235 1200 1236 // Inherit description from union if no prop description and union is @inline ··· 1395 1431 entity: Model | Union, 1396 1432 name: string | undefined, 1397 1433 namespace: Namespace | undefined, 1434 + fullyQualified = false, 1398 1435 ): string | null { 1399 1436 if (!name || !namespace || namespace.name === "TypeSpec") return null; 1400 1437 ··· 1414 1451 }); 1415 1452 } 1416 1453 1417 - // Local reference (same namespace) 1454 + // For knownValues (fullyQualified=true), always use fully qualified refs 1455 + if (fullyQualified) { 1456 + return `${namespaceName}#${defName}`; 1457 + } 1458 + 1459 + // Local reference (same namespace) - use short ref 1418 1460 if ( 1419 1461 this.currentLexiconId === namespaceName || 1420 1462 this.currentLexiconId === `${namespaceName}.defs` ··· 1427 1469 return namespaceName; 1428 1470 } 1429 1471 1472 + // All other refs use fully qualified format 1430 1473 return `${namespaceName}#${defName}`; 1431 1474 } 1432 1475 1433 - private getModelReference(model: Model): string | null { 1434 - return this.getReference(model, model.name, model.namespace); 1476 + private getModelReference( 1477 + model: Model, 1478 + fullyQualified = false, 1479 + ): string | null { 1480 + return this.getReference(model, model.name, model.namespace, fullyQualified); 1435 1481 } 1436 1482 1437 1483 private getUnionReference(union: Union): string | null {
-22
packages/emitter/test/spec/basic/input/com/example/unionWithTokens.tsp
··· 1 - import "@typelex/emitter"; 2 - 3 - namespace com.example.unionWithTokens { 4 - /** Tests union with token references */ 5 - model Main { 6 - /** Reason can be a known token or unknown type */ 7 - @required 8 - reason: (ReasonSpam | ReasonViolation | ReasonMisleading | unknown); 9 - } 10 - 11 - /** Spam: frequent unwanted promotion */ 12 - @token 13 - model ReasonSpam {} 14 - 15 - /** Direct violation of rules */ 16 - @token 17 - model ReasonViolation {} 18 - 19 - /** Misleading or deceptive content */ 20 - @token 21 - model ReasonMisleading {} 22 - }
-34
packages/emitter/test/spec/basic/output/com/example/unionWithTokens.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "com.example.unionWithTokens", 4 - "defs": { 5 - "main": { 6 - "type": "object", 7 - "description": "Tests union with token references", 8 - "required": ["reason"], 9 - "properties": { 10 - "reason": { 11 - "type": "union", 12 - "description": "Reason can be a known token or unknown type", 13 - "refs": [ 14 - "#reasonSpam", 15 - "#reasonViolation", 16 - "#reasonMisleading" 17 - ] 18 - } 19 - } 20 - }, 21 - "reasonSpam": { 22 - "type": "token", 23 - "description": "Spam: frequent unwanted promotion" 24 - }, 25 - "reasonViolation": { 26 - "type": "token", 27 - "description": "Direct violation of rules" 28 - }, 29 - "reasonMisleading": { 30 - "type": "token", 31 - "description": "Misleading or deceptive content" 32 - } 33 - } 34 - }