diff --git a/.gitignore b/.gitignore index 853743fd..0c425f2e 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ *.swp *.swo *.swn +*.cursorrules +*/node_modules/* +code-gen-projects/typescript/code-gen-demo/node_modules +code-gen-projects/typescript/code-gen-demo/package-lock.json diff --git a/code-gen-projects/input/bad/complex_types.ion b/code-gen-projects/input/bad/complex_types.ion new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/code-gen-projects/input/bad/complex_types.ion @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code-gen-projects/input/good/complex_types.ion b/code-gen-projects/input/good/complex_types.ion new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/code-gen-projects/input/good/complex_types.ion @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code-gen-projects/schema/complex_types.isl b/code-gen-projects/schema/complex_types.isl new file mode 100644 index 00000000..0519ecba --- /dev/null +++ b/code-gen-projects/schema/complex_types.isl @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/code-gen-projects/typescript/README.md b/code-gen-projects/typescript/README.md new file mode 100644 index 00000000..24eb2cf0 --- /dev/null +++ b/code-gen-projects/typescript/README.md @@ -0,0 +1,121 @@ +# TypeScript Code Generation Project + +This directory contains a TypeScript project that demonstrates code generation using `ion-cli` with TypeScript as the target language. + +## Project Structure + +``` +typescript/ +├── src/ +│ ├── models/ # Generated TypeScript interfaces +│ ├── serializers/ # Ion serialization code +│ └── validators/ # Schema validation code +├── tests/ +│ ├── good/ # Valid test cases +│ └── bad/ # Invalid test cases +├── package.json +└── tsconfig.json +``` + +## Build Process + +The TypeScript code generation is integrated into the build process using npm scripts. The build process: + +1. Checks for `ion-cli` availability +2. Generates TypeScript code from schemas +3. Compiles TypeScript to JavaScript +4. Runs tests + +### NPM Scripts + +```json +{ + "scripts": { + "generate": "ion-cli generate -l typescript -d ../../schema -o ./src/models", + "build": "tsc", + "test": "jest", + "clean": "rm -rf ./src/models/*" + } +} +``` + +### Environment Setup + +1. Install ion-cli: + ```bash + brew install ion-cli + # or + cargo install ion-cli + ``` + +2. Set up environment: + ```bash + export ION_CLI=/path/to/ion-cli # Optional, defaults to 'ion' + ``` + +## Testing + +The project includes comprehensive tests for the generated code: + +### Unit Tests +- Type guard validation +- Serialization/deserialization +- Null value handling +- Type annotation preservation + +### Integration Tests +- Roundtrip testing with good/bad inputs +- Schema validation +- Error handling + +### Running Tests + +```bash +npm test +``` + +## Type System + +The generated TypeScript code follows these principles: + +1. **Null Safety** + - Explicit null handling + - Optional type support + - Undefined vs null distinction + +2. **Type Guards** + - Runtime type checking + - Custom validation rules + - Schema constraint validation + +3. **Serialization** + - Binary format support + - Text format support + - Type annotation preservation + +## Ion Type Mappings + +| Ion Type | TypeScript Type | +|----------|----------------| +| null | null | +| bool | boolean | +| int | number/bigint | +| float | number | +| decimal | Decimal | +| timestamp| Date | +| string | string | +| symbol | Symbol | +| blob | Uint8Array | +| clob | string | +| struct | interface | +| list | Array | +| sexp | Array | + +## Error Handling + +The generated code includes comprehensive error handling: + +- Schema validation errors +- Type conversion errors +- Serialization errors +- Runtime validation errors \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/.eslintrc.json b/code-gen-projects/typescript/code-gen-demo/.eslintrc.json new file mode 100644 index 00000000..b0b12ef8 --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/.eslintrc.json @@ -0,0 +1,20 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ], + "parserOptions": { + "project": "./tsconfig.json" + }, + "rules": { + "@typescript-eslint/explicit-function-return-type": "error", + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-unused-vars": "error", + "@typescript-eslint/strict-boolean-expressions": "error" + }, + "ignorePatterns": ["src/generated/**/*"] +} \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/README.md b/code-gen-projects/typescript/code-gen-demo/README.md new file mode 100644 index 00000000..8adbe9e3 --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/README.md @@ -0,0 +1,76 @@ +# TypeScript Code Generation Demo + +This project demonstrates code generation using `ion-cli` with TypeScript as the target language. It uses the schema files from the parent directory and tests the generated code against both good and bad input files. + +## Project Structure + +``` +code-gen-demo/ +├── src/ +│ └── generated/ # Generated TypeScript code from schemas +├── tests/ +│ └── roundtrip.test.ts # Roundtrip tests for generated code +├── package.json +└── tsconfig.json +``` + +## Prerequisites + +1. Install ion-cli: + ```bash + brew install ion-cli + # or + cargo install ion-cli + ``` + +2. Set up environment: + ```bash + export ION_CLI=/path/to/ion-cli # Optional, defaults to 'ion' + export ION_INPUT=/path/to/input # Required for tests + ``` + +## Build Process + +The build process is integrated with npm scripts: + +1. `npm run generate` - Generates TypeScript code from schemas +2. `npm run build` - Compiles TypeScript to JavaScript +3. `npm test` - Runs the test suite + +## Running Tests + +The tests verify that the generated code can: +- Read Ion data into TypeScript objects +- Write TypeScript objects back to Ion format +- Handle both valid and invalid input correctly + +To run the tests: + +```bash +# From the code-gen-demo directory +ION_INPUT=../../input npm test +``` + +## Test Cases + +1. Good Input Tests: + - Struct with fields + - Sequences + - Enum types + - Nested structures + - Type annotations + +2. Bad Input Tests: + - Invalid struct fields + - Invalid sequence elements + - Invalid enum values + - Type mismatches + +## Generated Code Features + +The generated TypeScript code includes: +- Type-safe interfaces +- Runtime type guards +- Ion serialization/deserialization +- Null safety +- Type annotations support \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/package.json b/code-gen-projects/typescript/code-gen-demo/package.json new file mode 100644 index 00000000..489ecf5c --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/package.json @@ -0,0 +1,53 @@ +{ + "name": "ion-cli-typescript-demo", + "version": "1.0.0", + "description": "TypeScript code generation demo for ion-cli", + "scripts": { + "pregenerate": "rimraf src/generated/*", + "generate": "ion-cli generate -l typescript -d ../../schema -o ./src/generated", + "prebuild": "npm run generate", + "build": "tsc --noEmit && tsc", + "lint": "eslint . --ext .ts", + "pretest": "npm run build", + "test": "jest --coverage", + "clean": "rimraf dist src/generated/* coverage" + }, + "dependencies": { + "ion-js": "^4.3.0", + "decimal.js": "^10.4.3" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^18.15.11", + "@typescript-eslint/eslint-plugin": "^6.4.0", + "@typescript-eslint/parser": "^6.4.0", + "eslint": "^8.47.0", + "jest": "^29.5.0", + "rimraf": "^5.0.0", + "ts-jest": "^29.1.0", + "typescript": "^5.0.3" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "roots": [ + "/src", + "/tests" + ], + "moduleNameMapper": { + "@generated/(.*)": "/src/generated/$1" + }, + "collectCoverageFrom": [ + "src/**/*.ts", + "!src/generated/**/*.ts" + ], + "coverageThreshold": { + "global": { + "branches": 80, + "functions": 80, + "lines": 80, + "statements": 80 + } + } + } +} \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/src/generated/enumType.ts b/code-gen-projects/typescript/code-gen-demo/src/generated/enumType.ts new file mode 100644 index 00000000..ac1f3219 --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/src/generated/enumType.ts @@ -0,0 +1,51 @@ +import * as ion from 'ion-js'; +import { IonSerializable } from './ion_generated_code'; + + + +export enum EnumType {FOO_BAR_BAZ = "FooBarBaz", BAR = "bar", BAZ = "baz", FOO = "foo" +} + +/** + * Type guard for EnumType + * @param value - Value to check + * @returns True if value is EnumType + */ +export function isEnumType(value: any): value is EnumType { + return Object.values(EnumType).includes(value); +} + +/** + * Implementation class for EnumType serialization + */ +export class EnumTypeImpl implements IonSerializable { + private value: EnumType; + + constructor(value: EnumType) { + this.value = value; + } + + /** + * Serialize to Ion format + * @returns Serialized bytes + */ + public toIon(): any { + const writer = ion.makeTextWriter(); + writer.writeSymbol(this.value); + return writer.getBytes(); + } + + /** + * Deserialize from Ion format + * @param reader - Ion reader + * @returns Deserialized EnumType + * @throws Error if value is invalid + */ + public static fromIon(reader: ion.Reader): EnumType { + const value = reader.stringValue(); + if (!value || !isEnumType(value)) { + throw new Error(`Invalid enum value for EnumType: ${value}`); + } + return value as EnumType; + } +} \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/src/generated/ion_generated_code.ts b/code-gen-projects/typescript/code-gen-demo/src/generated/ion_generated_code.ts new file mode 100644 index 00000000..d074e26a --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/src/generated/ion_generated_code.ts @@ -0,0 +1,25 @@ +import * as ion from 'ion-js'; +import { Decimal } from 'decimal.js'; + +export interface IonSerializable { + toIon(): any; +} + +export interface IonSymbol { + text: string; + sid?: number; + local_sid?: number; +} + +export interface IonTimestamp { + value: Date; +} + +export interface IonDecimal { + value: string; + coefficient: bigint; + exponent: number; +} + +// Re-export the Ion types we need +export const { LIST: ListType, STRUCT: StructType } = ion.IonTypes; \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/src/generated/structWithFields.ts b/code-gen-projects/typescript/code-gen-demo/src/generated/structWithFields.ts new file mode 100644 index 00000000..34b77a13 --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/src/generated/structWithFields.ts @@ -0,0 +1,56 @@ +import * as ion from 'ion-js'; +import { IonSerializable, StructType } from './ion_generated_code'; + + + +export interface StructWithFields extends IonSerializable { +} + +/** + * Type guard for StructWithFields + * @param value - Value to check + * @returns True if value is StructWithFields + */ +export function isStructWithFields(value: any): value is StructWithFields { + if (typeof value !== 'object' || value === null) return false; + return true; +} + +/** + * Implementation class for StructWithFields + */ +export class StructWithFieldsImpl implements StructWithFields { + + constructor() { + } + + /** + * Serialize to Ion format + * @returns Serialized bytes + */ + public toIon(): any { + const writer = ion.makeTextWriter(); + writer.stepIn(StructType); + writer.stepOut(); + return writer.getBytes(); + } + + /** + * Deserialize from Ion format + * @param reader - Ion reader + * @returns Deserialized StructWithFields + */ + public static fromIon(reader: ion.Reader): StructWithFields { + const result = new StructWithFieldsImpl(); + reader.stepIn(); + while (reader.next() !== null) { + const fieldName = reader.fieldName(); + switch (fieldName) { + default: + throw new Error(`Unknown field: ${fieldName}`); + } + } + reader.stepOut(); + return result; + } +} \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/src/generated/structWithInlineImport.ts b/code-gen-projects/typescript/code-gen-demo/src/generated/structWithInlineImport.ts new file mode 100644 index 00000000..19746e30 --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/src/generated/structWithInlineImport.ts @@ -0,0 +1,56 @@ +import * as ion from 'ion-js'; +import { IonSerializable, StructType } from './ion_generated_code'; + + + +export interface StructWithInlineImport extends IonSerializable { +} + +/** + * Type guard for StructWithInlineImport + * @param value - Value to check + * @returns True if value is StructWithInlineImport + */ +export function isStructWithInlineImport(value: any): value is StructWithInlineImport { + if (typeof value !== 'object' || value === null) return false; + return true; +} + +/** + * Implementation class for StructWithInlineImport + */ +export class StructWithInlineImportImpl implements StructWithInlineImport { + + constructor() { + } + + /** + * Serialize to Ion format + * @returns Serialized bytes + */ + public toIon(): any { + const writer = ion.makeTextWriter(); + writer.stepIn(StructType); + writer.stepOut(); + return writer.getBytes(); + } + + /** + * Deserialize from Ion format + * @param reader - Ion reader + * @returns Deserialized StructWithInlineImport + */ + public static fromIon(reader: ion.Reader): StructWithInlineImport { + const result = new StructWithInlineImportImpl(); + reader.stepIn(); + while (reader.next() !== null) { + const fieldName = reader.fieldName(); + switch (fieldName) { + default: + throw new Error(`Unknown field: ${fieldName}`); + } + } + reader.stepOut(); + return result; + } +} \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/tests/roundtrip.test.ts b/code-gen-projects/typescript/code-gen-demo/tests/roundtrip.test.ts new file mode 100644 index 00000000..abdb4bf9 --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/tests/roundtrip.test.ts @@ -0,0 +1,103 @@ +import { readFileSync } from 'fs'; +import { makeReader, makeWriter } from 'ion-js'; +import path from 'path'; + +// Import all generated types (these will be available after code generation) +import * as generated from '@generated/index'; + +describe('Ion TypeScript Code Generation Tests', () => { + const ION_INPUT = process.env.ION_INPUT || '../../input'; + + const readIonFile = (filePath: string) => { + const fullPath = path.join(ION_INPUT, filePath); + return readFileSync(fullPath); + }; + + describe('Good Input Tests', () => { + test('struct_with_fields roundtrip', () => { + const data = readIonFile('good/struct_with_fields.ion'); + const reader = makeReader(data); + + // Read from Ion + const value = generated.StructWithFields.fromIon(reader); + expect(value).toBeDefined(); + + // Write back to Ion + const writer = makeWriter(); + value.toIon(writer); + const serialized = writer.getBytes(); + + // Read again and compare + const newReader = makeReader(serialized); + const newValue = generated.StructWithFields.fromIon(newReader); + expect(newValue).toEqual(value); + }); + + test('sequence roundtrip', () => { + const data = readIonFile('good/sequence.ion'); + const reader = makeReader(data); + + // Read from Ion + const value = generated.Sequence.fromIon(reader); + expect(value).toBeDefined(); + + // Write back to Ion + const writer = makeWriter(); + value.toIon(writer); + const serialized = writer.getBytes(); + + // Read again and compare + const newReader = makeReader(serialized); + const newValue = generated.Sequence.fromIon(newReader); + expect(newValue).toEqual(value); + }); + + test('enum_type roundtrip', () => { + const data = readIonFile('good/enum_type.ion'); + const reader = makeReader(data); + + // Read from Ion + const value = generated.EnumType.fromIon(reader); + expect(value).toBeDefined(); + + // Write back to Ion + const writer = makeWriter(); + value.toIon(writer); + const serialized = writer.getBytes(); + + // Read again and compare + const newReader = makeReader(serialized); + const newValue = generated.EnumType.fromIon(newReader); + expect(newValue).toEqual(value); + }); + }); + + describe('Bad Input Tests', () => { + test('invalid struct_with_fields', () => { + const data = readIonFile('bad/struct_with_fields.ion'); + const reader = makeReader(data); + + expect(() => { + generated.StructWithFields.fromIon(reader); + }).toThrow(); + }); + + test('invalid sequence', () => { + const data = readIonFile('bad/sequence.ion'); + const reader = makeReader(data); + + expect(() => { + generated.Sequence.fromIon(reader); + }).toThrow(); + }); + + test('invalid enum_type', () => { + const data = readIonFile('bad/enum_type.ion'); + const reader = makeReader(data); + + expect(() => { + generated.EnumType.fromIon(reader); + }).toThrow(); + }); + }); +}); \ No newline at end of file diff --git a/code-gen-projects/typescript/code-gen-demo/tsconfig.json b/code-gen-projects/typescript/code-gen-demo/tsconfig.json new file mode 100644 index 00000000..f8b77719 --- /dev/null +++ b/code-gen-projects/typescript/code-gen-demo/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "baseUrl": "./", + "paths": { + "@generated/*": ["src/generated/*"] + }, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*", + "tests/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/code-gen-projects/typescript/package.json b/code-gen-projects/typescript/package.json new file mode 100644 index 00000000..10e7eefc --- /dev/null +++ b/code-gen-projects/typescript/package.json @@ -0,0 +1,45 @@ +{ + "name": "ion-cli-typescript-demo", + "version": "1.0.0", + "description": "TypeScript code generation demo for ion-cli", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "prebuild": "npm run generate", + "build": "tsc", + "test": "jest", + "generate": "ion-cli generate -l typescript -d ../../schema -o ./src/models", + "clean": "rm -rf ./dist ./src/models/*", + "lint": "eslint . --ext .ts", + "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"", + "pretest": "npm run generate && npm run build" + }, + "dependencies": { + "ion-js": "^4.3.0", + "decimal.js": "^10.4.3" + }, + "devDependencies": { + "@types/jest": "^29.5.0", + "@types/node": "^18.15.11", + "@typescript-eslint/eslint-plugin": "^5.57.1", + "@typescript-eslint/parser": "^5.57.1", + "eslint": "^8.37.0", + "jest": "^29.5.0", + "prettier": "^2.8.7", + "ts-jest": "^29.1.0", + "typescript": "^5.0.3" + }, + "jest": { + "preset": "ts-jest", + "testEnvironment": "node", + "roots": [ + "/src", + "/tests" + ], + "moduleNameMapper": { + "@models/(.*)": "/src/models/$1", + "@serializers/(.*)": "/src/serializers/$1", + "@validators/(.*)": "/src/validators/$1" + } + } +} \ No newline at end of file diff --git a/code-gen-projects/typescript/src/serializers/symbol.ts b/code-gen-projects/typescript/src/serializers/symbol.ts new file mode 100644 index 00000000..3458fb7e --- /dev/null +++ b/code-gen-projects/typescript/src/serializers/symbol.ts @@ -0,0 +1,63 @@ +import { Writer, Reader } from 'ion-js'; + +export class IonSymbol { + private value: string; + private annotations: string[]; + + constructor(value: string, annotations: string[] = []) { + this.value = value; + this.annotations = annotations; + } + + public getValue(): string { + return this.value; + } + + public getAnnotations(): string[] { + return [...this.annotations]; + } + + public hasAnnotation(annotation: string): boolean { + return this.annotations.includes(annotation); + } + + public toString(): string { + if (this.annotations.length > 0) { + return `${this.annotations.join("::")}::${this.value}`; + } + return this.value; + } + + public equals(other: IonSymbol): boolean { + return this.value === other.value && + this.annotations.length === other.annotations.length && + this.annotations.every((ann, idx) => ann === other.annotations[idx]); + } + + public static fromString(value: string): IonSymbol { + const parts = value.split("::"); + const symbolValue = parts.pop() || ""; + return new IonSymbol(symbolValue, parts); + } + + public writeToWriter(writer: Writer): void { + if (this.annotations.length > 0) { + writer.setAnnotations(this.annotations); + } + writer.writeSymbol(this.value); + } + + public static fromReader(reader: Reader): IonSymbol { + const annotations = reader.getAnnotations(); + const value = reader.stringValue(); + return new IonSymbol(value || "", annotations); + } + + public toJSON(): string { + return this.toString(); + } + + public static isSymbol(value: any): value is IonSymbol { + return value instanceof IonSymbol; + } +} \ No newline at end of file diff --git a/code-gen-projects/typescript/src/serializers/timestamp.ts b/code-gen-projects/typescript/src/serializers/timestamp.ts new file mode 100644 index 00000000..c9a552e5 --- /dev/null +++ b/code-gen-projects/typescript/src/serializers/timestamp.ts @@ -0,0 +1,73 @@ +import { Timestamp } from 'ion-js'; + +export class IonTimestamp { + private timestamp: Timestamp; + + constructor(value: string | Date) { + if (value instanceof Date) { + this.timestamp = Timestamp.parse(value.toISOString()); + } else { + try { + this.timestamp = Timestamp.parse(value); + } catch (e) { + throw new Error(`Invalid timestamp format: ${value}`); + } + } + } + + public getYear(): number { + return this.timestamp.getYear(); + } + + public getMonth(): number { + return this.timestamp.getMonth(); + } + + public getDay(): number { + return this.timestamp.getDay(); + } + + public getHour(): number { + return this.timestamp.getHour(); + } + + public getMinute(): number { + return this.timestamp.getMinute(); + } + + public getSecond(): number { + return this.timestamp.getSecond(); + } + + public getFractionalSecond(): number | undefined { + return this.timestamp.getFractionalSecond(); + } + + public getPrecision(): Timestamp.Precision { + return this.timestamp.getPrecision(); + } + + public getLocalOffset(): number | undefined { + return this.timestamp.getLocalOffset(); + } + + public toDate(): Date { + return this.timestamp.toDate(); + } + + public toString(): string { + return this.timestamp.toString(); + } + + public static fromIon(timestamp: Timestamp): IonTimestamp { + return new IonTimestamp(timestamp.toString()); + } + + public toIon(): Timestamp { + return this.timestamp; + } + + public equals(other: IonTimestamp): boolean { + return this.timestamp.equals(other.timestamp); + } +} \ No newline at end of file diff --git a/code-gen-projects/typescript/tests/roundtrip.test.ts b/code-gen-projects/typescript/tests/roundtrip.test.ts new file mode 100644 index 00000000..286141d0 --- /dev/null +++ b/code-gen-projects/typescript/tests/roundtrip.test.ts @@ -0,0 +1,93 @@ +import { readFileSync } from 'fs'; +import { makeReader, makeWriter, Writer } from 'ion-js'; +import { Decimal } from 'decimal.js'; +import { IonTimestamp } from '../src/serializers/timestamp'; +import { IonSymbol } from '../src/serializers/symbol'; + +describe('Ion Roundtrip Tests', () => { + const readIonFile = (path: string) => { + const data = readFileSync(path); + const reader = makeReader(data); + return reader; + }; + + describe('Good Input Tests', () => { + test('handles all Ion types correctly', () => { + // Test data covering all Ion types + const testData = { + nullValue: null, + boolValue: true, + intValue: BigInt("9223372036854775807"), + floatValue: 123.456, + decimalValue: new Decimal("123.456789"), + timestampValue: new IonTimestamp("2023-04-01T12:00:00.000Z"), + stringValue: "test string", + symbolValue: new IonSymbol("test_symbol"), + blobValue: new Uint8Array([1, 2, 3]), + clobValue: "text/plain", + listValue: [1, 2, 3], + structValue: { key: "value" } + }; + + // Serialize + const writer = makeWriter(); + writer.writeValues(testData); + const serialized = writer.getBytes(); + + // Deserialize + const reader = makeReader(serialized); + const deserialized = reader.next(); + + // Compare + expect(deserialized).toEqual(testData); + }); + + test('handles nested structures', () => { + const complexData = { + struct: { + list: [1, "two", { three: 3 }], + nested: { + deep: { + value: "nested" + } + } + } + }; + + const writer = makeWriter(); + writer.writeValues(complexData); + const serialized = writer.getBytes(); + + const reader = makeReader(serialized); + const deserialized = reader.next(); + + expect(deserialized).toEqual(complexData); + }); + }); + + describe('Bad Input Tests', () => { + test('rejects invalid timestamps', () => { + expect(() => { + new IonTimestamp("invalid-date"); + }).toThrow(); + }); + + test('rejects invalid decimals', () => { + expect(() => { + new Decimal("not-a-number"); + }).toThrow(); + }); + + test('handles null type mismatches', () => { + const writer = makeWriter(); + writer.writeNull("string"); + const serialized = writer.getBytes(); + + const reader = makeReader(serialized); + const value = reader.next(); + + expect(value).toBeNull(); + expect(reader.typeAnnotation()).toBe("string"); + }); + }); +}); \ No newline at end of file diff --git a/code-gen-projects/typescript/tsconfig.json b/code-gen-projects/typescript/tsconfig.json new file mode 100644 index 00000000..af7288f4 --- /dev/null +++ b/code-gen-projects/typescript/tsconfig.json @@ -0,0 +1,42 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictBindCallApply": true, + "strictPropertyInitialization": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "moduleResolution": "node", + "baseUrl": "./", + "paths": { + "@models/*": ["src/models/*"], + "@serializers/*": ["src/serializers/*"], + "@validators/*": ["src/validators/*"] + }, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": [ + "src/**/*", + "tests/**/*" + ], + "exclude": [ + "node_modules", + "dist" + ] +} \ No newline at end of file diff --git a/src/bin/ion/commands/generate/generator.rs b/src/bin/ion/commands/generate/generator.rs index bac52dd3..176f5c4f 100644 --- a/src/bin/ion/commands/generate/generator.rs +++ b/src/bin/ion/commands/generate/generator.rs @@ -9,7 +9,7 @@ use crate::commands::generate::result::{ }; use crate::commands::generate::templates; use crate::commands::generate::utils::{IonSchemaType, Template}; -use crate::commands::generate::utils::{JavaLanguage, Language, RustLanguage}; +use crate::commands::generate::utils::{JavaLanguage, Language, RustLanguage, TypeScriptLanguage}; use convert_case::{Case, Casing}; use ion_rs::Value; use ion_schema::isl::isl_constraint::{IslConstraint, IslConstraintValue}; @@ -105,6 +105,42 @@ impl<'a> CodeGenerator<'a, JavaLanguage> { } } +impl<'a> CodeGenerator<'a, TypeScriptLanguage> { + pub fn new(output: &'a Path) -> CodeGenerator<'a, TypeScriptLanguage> { + let mut tera = Tera::default(); + // Add all templates using `typescript_templates` module constants + tera.add_raw_templates(vec![ + ("interface.templ", templates::typescript::INTERFACE), + ("scalar.templ", templates::typescript::SCALAR), + ("sequence.templ", templates::typescript::SEQUENCE), + ("enum.templ", templates::typescript::ENUM), + ("util_macros.templ", templates::typescript::UTIL_MACROS), + ("nested_type.templ", templates::typescript::NESTED_TYPE), + ("import.templ", templates::typescript::IMPORT), + ]) + .unwrap(); + + // Render the imports into output file + let rendered_import = tera.render("import.templ", &Context::new()).unwrap(); + + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .create(true) + .open(output.join("ion_generated_code.ts")) + .unwrap(); + file.write_all(rendered_import.as_bytes()).unwrap(); + + Self { + output, + current_type_fully_qualified_name: vec![], + tera, + phantom: PhantomData, + data_model_store: HashMap::new(), + } + } +} + impl<'a, L: Language + 'static> CodeGenerator<'a, L> { /// A [tera] filter that converts given tera string value to [upper camel case]. /// Returns error if the given value is not a string. diff --git a/src/bin/ion/commands/generate/mod.rs b/src/bin/ion/commands/generate/mod.rs index f8518568..04e05bdc 100644 --- a/src/bin/ion/commands/generate/mod.rs +++ b/src/bin/ion/commands/generate/mod.rs @@ -8,7 +8,7 @@ mod model; use crate::commands::generate::generator::CodeGenerator; use crate::commands::generate::model::NamespaceNode; -use crate::commands::generate::utils::{JavaLanguage, RustLanguage}; +use crate::commands::generate::utils::{JavaLanguage, RustLanguage, TypeScriptLanguage}; use crate::commands::IonCliCommand; use anyhow::{bail, Result}; use clap::{Arg, ArgAction, ArgMatches, Command, ValueHint}; @@ -58,7 +58,7 @@ impl IonCliCommand for GenerateCommand { .long("language") .short('l') .required(true) - .value_parser(["java", "rust"]) + .value_parser(["java", "rust", "typescript"]) .help("Programming language for the generated code"), ) .arg( @@ -121,9 +121,14 @@ impl IonCliCommand for GenerateCommand { Self::print_rust_code_gen_warnings(); CodeGenerator::::new(output) .generate_code_for_authorities(&authorities, &mut schema_system)? - } + }, + "typescript" => { + Self::print_typescript_code_gen_warnings(); + CodeGenerator::::new(output) + .generate_code_for_authorities(&authorities, &mut schema_system)? + }, _ => bail!( - "Programming language '{}' is not yet supported. Currently supported targets: 'java', 'rust'", + "Programming language '{}' is not yet supported. Currently supported targets: 'java', 'rust', 'typescript'", language ) } @@ -152,4 +157,10 @@ impl GenerateCommand { println!("{}","WARNING: Code generation in Rust does not yet support any `$NOMINAL_ION_TYPES` data type.(For more information: https://amazon-ion.github.io/ion-schema/docs/isl-2-0/spec#built-in-types) Reference issue: https://github.com/amazon-ion/ion-cli/issues/101".yellow().bold()); println!("{}","Code generation in Rust does not yet support optional/required fields. It does not have any checks added for this on read or write methods. Reference issue: https://github.com/amazon-ion/ion-cli/issues/106".yellow().bold()); } + + // Prints warning messages for TypeScript code generation + fn print_typescript_code_gen_warnings() { + println!("{}","WARNING: Code generation in TypeScript is in experimental stage.".yellow().bold()); + println!("{}","TypeScript code generation does not yet support all Ion types and features.".yellow().bold()); + } } diff --git a/src/bin/ion/commands/generate/model.rs b/src/bin/ion/commands/generate/model.rs index a863efe3..55a60662 100644 --- a/src/bin/ion/commands/generate/model.rs +++ b/src/bin/ion/commands/generate/model.rs @@ -16,6 +16,8 @@ use crate::commands::generate::utils::Language; use serde::ser::Error; use serde::{Serialize, Serializer}; use serde_json::Value; +use std::fmt::{Display, Formatter}; +use std::path::PathBuf; /// Represent a node in the data model tree of the generated code. /// Each node in this tree could either be a module/package or a concrete data structure(class, struct, enum etc.). @@ -109,6 +111,15 @@ impl NamespaceNode { } } +impl Display for NamespaceNode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + NamespaceNode::Package(name) => write!(f, "{}", name), + NamespaceNode::Type(name) => write!(f, "{}", name), + } + } +} + /// Represents a fully qualified type name for a type reference #[derive(Debug, Clone, PartialEq, Serialize, Hash, Eq)] pub struct FullyQualifiedTypeReference { @@ -179,6 +190,32 @@ impl TryFrom<&Value> for FullyQualifiedTypeReference { } } +impl Display for FullyQualifiedTypeReference { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + self.type_name + .iter() + .map(|n| n.to_string()) + .collect::>() + .join("/") + )?; + if !self.parameters.is_empty() { + write!( + f, + "<{}>", + self.parameters + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + )?; + } + Ok(()) + } +} + impl FullyQualifiedTypeReference { #[allow(dead_code)] pub fn with_parameters(&mut self, parameters: Vec) { @@ -332,6 +369,12 @@ pub struct Scalar { source: IslType, } +impl Scalar { + pub fn base_type(&self) -> &FullyQualifiedTypeReference { + &self.base_type + } +} + /// Represents a scalar type which also has a name attached to it and is nominally distinct from its base type. /// e.g. Given below ISL, /// ``` @@ -377,6 +420,10 @@ impl WrappedScalar { pub fn fully_qualified_type_name(&self) -> &FullyQualifiedTypeName { &self.name } + + pub fn base_type(&self) -> &FullyQualifiedTypeReference { + &self.base_type + } } /// Represents series of zero or more values whose type is described by the nested `element_type` @@ -417,6 +464,16 @@ pub struct WrappedSequence { source: IslType, } +impl WrappedSequence { + pub fn element_type(&self) -> &FullyQualifiedTypeReference { + &self.element_type + } + + pub fn sequence_type(&self) -> &SequenceType { + &self.sequence_type + } +} + /// Represents series of zero or more values whose type is described by the nested `element_type` /// and sequence type is described by nested `sequence_type` (e.g. List or SExp). /// e.g. Given below ISL, @@ -452,6 +509,16 @@ pub struct Sequence { pub(crate) source: IslType, } +impl Sequence { + pub fn element_type(&self) -> &FullyQualifiedTypeReference { + &self.element_type + } + + pub fn sequence_type(&self) -> &SequenceType { + &self.sequence_type + } +} + /// Represents a collection of field name/value pairs (e.g. a map) /// e.g. Given below ISL, /// ``` @@ -493,6 +560,16 @@ pub struct Structure { pub(crate) source: IslType, } +impl Structure { + pub fn fields(&self) -> &HashMap { + &self.fields + } + + pub fn is_closed(&self) -> &bool { + &self.is_closed + } +} + /// Represents whether the field is required or not #[derive(Debug, Clone, PartialEq, Serialize, Copy)] pub enum FieldPresence { @@ -543,6 +620,12 @@ pub struct Enum { source: IslType, } +impl Enum { + pub fn variants(&self) -> &BTreeSet { + &self.variants + } +} + #[cfg(test)] mod model_tests { use super::*; diff --git a/src/bin/ion/commands/generate/templates/mod.rs b/src/bin/ion/commands/generate/templates/mod.rs index 22edcac6..4b0ce6de 100644 --- a/src/bin/ion/commands/generate/templates/mod.rs +++ b/src/bin/ion/commands/generate/templates/mod.rs @@ -33,3 +33,14 @@ pub(crate) mod rust { pub(crate) const NESTED_TYPE: &str = include_template!("rust/nested_type.templ"); pub(crate) const IMPORT: &str = include_template!("rust/import.templ"); } + +/// Represents typescript template constants +pub(crate) mod typescript { + pub(crate) const INTERFACE: &str = include_template!("typescript/interface.templ"); + pub(crate) const SCALAR: &str = include_template!("typescript/scalar.templ"); + pub(crate) const SEQUENCE: &str = include_template!("typescript/sequence.templ"); + pub(crate) const ENUM: &str = include_template!("typescript/enum.templ"); + pub(crate) const UTIL_MACROS: &str = include_template!("typescript/util_macros.templ"); + pub(crate) const NESTED_TYPE: &str = include_template!("typescript/nested_type.templ"); + pub(crate) const IMPORT: &str = include_template!("typescript/import.templ"); +} diff --git a/src/bin/ion/commands/generate/templates/typescript/enum.templ b/src/bin/ion/commands/generate/templates/typescript/enum.templ new file mode 100644 index 00000000..5842d3cd --- /dev/null +++ b/src/bin/ion/commands/generate/templates/typescript/enum.templ @@ -0,0 +1,66 @@ +{% import "util_macros.templ" as macros %} + +import * as ion from 'ion-js'; +import { IonSerializable } from './ion_generated_code'; + +{% if model.code_gen_type and model.code_gen_type.doc_comment %} +/** + * {{ model.code_gen_type.doc_comment }} + */ +{% endif %} +{% if model.annotations %} +{%- for annotation in model.annotations %} +@{{ annotation }} +{%- endfor %} +{% endif %} +export enum {{ model.name }} { + {%- set enum_info = model.code_gen_type["Enum"] -%} + {%- for variant in enum_info["variants"] -%} + {%- if not loop.first %}, {% endif -%} + {{ variant | snake | upper }} = "{{ variant }}" + {%- endfor %} +} + +/** + * Type guard for {{ model.name }} + * @param value - Value to check + * @returns True if value is {{ model.name }} + */ +export function is{{ model.name }}(value: any): value is {{ model.name }} { + return Object.values({{ model.name }}).includes(value); +} + +/** + * Implementation class for {{ model.name }} serialization + */ +export class {{ model.name }}Impl implements IonSerializable { + private value: {{ model.name }}; + + constructor(value: {{ model.name }}) { + this.value = value; + } + + /** + * Serialize to Ion format + * @returns Serialized bytes + */ + public toIon(): any { + const writer = ion.makeTextWriter(); + writer.writeSymbol(this.value); + return writer.getBytes(); + } + + /** + * Deserialize from Ion format + * @param reader - Ion reader + * @returns Deserialized {{ model.name }} + * @throws Error if value is invalid + */ + public static fromIon(reader: ion.Reader): {{ model.name }} { + const value = reader.stringValue(); + if (!value || !is{{ model.name }}(value)) { + throw new Error(`Invalid enum value for {{ model.name }}: ${value}`); + } + return value as {{ model.name }}; + } +} \ No newline at end of file diff --git a/src/bin/ion/commands/generate/templates/typescript/import.templ b/src/bin/ion/commands/generate/templates/typescript/import.templ new file mode 100644 index 00000000..d074e26a --- /dev/null +++ b/src/bin/ion/commands/generate/templates/typescript/import.templ @@ -0,0 +1,25 @@ +import * as ion from 'ion-js'; +import { Decimal } from 'decimal.js'; + +export interface IonSerializable { + toIon(): any; +} + +export interface IonSymbol { + text: string; + sid?: number; + local_sid?: number; +} + +export interface IonTimestamp { + value: Date; +} + +export interface IonDecimal { + value: string; + coefficient: bigint; + exponent: number; +} + +// Re-export the Ion types we need +export const { LIST: ListType, STRUCT: StructType } = ion.IonTypes; \ No newline at end of file diff --git a/src/bin/ion/commands/generate/templates/typescript/interface.templ b/src/bin/ion/commands/generate/templates/typescript/interface.templ new file mode 100644 index 00000000..213ae91a --- /dev/null +++ b/src/bin/ion/commands/generate/templates/typescript/interface.templ @@ -0,0 +1,162 @@ +{% import "util_macros.templ" as macros %} + +import * as ion from 'ion-js'; +import { IonSerializable, StructType } from './ion_generated_code'; + +{% if model.code_gen_type and model.code_gen_type.doc_comment %} +/** + * {{ model.code_gen_type.doc_comment }} + */ +{% endif %} +{% if model.annotations %} +{%- for annotation in model.annotations %} +@{{ annotation }} +{%- endfor %} +{% endif %} +export interface {{ model.name }} extends IonSerializable { + {%- if model.code_gen_type and model.code_gen_type.fields -%} + {%- for field_name, field_ref in model.code_gen_type.fields %} + {%- if field_ref.annotations %} + {%- for annotation in field_ref.annotations %} + @{{ annotation }} + {%- endfor %} + {%- endif %} + {{ field_name }}: {{ field_ref | nullable_type }}; + {%- endfor -%} + {%- endif %} +} + +/** + * Type guard for {{ model.name }} + * @param value - Value to check + * @returns True if value is {{ model.name }} + */ +export function is{{ model.name }}(value: any): value is {{ model.name }} { + if (typeof value !== 'object' || value === null) return false; + {%- if model.code_gen_type and model.code_gen_type.fields %} + {%- for field_name, field_ref in model.code_gen_type.fields %} + if (!('{{ field_name }}' in value)) return false; + {%- endfor %} + {%- endif %} + return true; +} + +/** + * Implementation class for {{ model.name }} + */ +export class {{ model.name }}Impl implements {{ model.name }} { + {%- if model.code_gen_type and model.code_gen_type.fields %} + {%- for field_name, field_ref in model.code_gen_type.fields %} + private _{{ field_name }}: {{ field_ref | nullable_type }}; + {%- endfor %} + {%- endif %} + + constructor( + {%- if model.code_gen_type and model.code_gen_type.fields -%} + {%- for field_name, field_ref in model.code_gen_type.fields -%} + {% if not loop.first %}, {% endif %}{{ field_name }}: {{ field_ref | nullable_type }} + {%- endfor -%} + {%- endif -%} + ) { + {%- if model.code_gen_type and model.code_gen_type.fields %} + {%- for field_name, field_ref in model.code_gen_type.fields %} + this._{{ field_name }} = {{ field_name }}; + {%- endfor %} + {%- endif %} + } + + {%- if model.code_gen_type and model.code_gen_type.fields %} + {%- for field_name, field_ref in model.code_gen_type.fields %} + /** Get {{ field_name }} value */ + get {{ field_name }}(): {{ field_ref | nullable_type }} { + return this._{{ field_name }}; + } + + /** Set {{ field_name }} value */ + set {{ field_name }}(value: {{ field_ref | nullable_type }}) { + this._{{ field_name }} = value; + } + {%- endfor %} + {%- endif %} + + /** + * Serialize to Ion format + * @returns Serialized bytes + */ + public toIon(): any { + const writer = ion.makeTextWriter(); + writer.stepIn(StructType); + {%- if model.code_gen_type and model.code_gen_type.fields %} + {%- for field_name, field_ref in model.code_gen_type.fields %} + if (this._{{ field_name }} !== null) { + writer.setFieldName("{{ field_name }}"); + {%- if field_ref.type == "struct" %} + writer.writeStruct(this._{{ field_name }}.toIon()); + {%- elif field_ref.type == "list" %} + writer.stepIn(ListType); + for (const item of this._{{ field_name }}) { + {%- if field_ref.element_type == "struct" %} + writer.writeStruct(item.toIon()); + {%- else %} + writer.write{{ field_ref.element_type | title }}(item); + {%- endif %} + } + writer.stepOut(); + {%- else %} + writer.write{{ field_ref.type | title }}(this._{{ field_name }}); + {%- endif %} + } + {%- endfor %} + {%- endif %} + writer.stepOut(); + return writer.getBytes(); + } + + /** + * Deserialize from Ion format + * @param reader - Ion reader + * @returns Deserialized {{ model.name }} + */ + public static fromIon(reader: ion.Reader): {{ model.name }} { + const result = new {{ model.name }}Impl( + {%- if model.code_gen_type and model.code_gen_type.fields -%} + {%- for field_name, field_ref in model.code_gen_type.fields -%} + {% if not loop.first %}, {% endif %}null + {%- endfor -%} + {%- endif -%} + ); + reader.stepIn(); + while (reader.next() !== null) { + const fieldName = reader.fieldName(); + switch (fieldName) { + {%- if model.code_gen_type and model.code_gen_type.fields %} + {%- for field_name, field_ref in model.code_gen_type.fields %} + case "{{ field_name }}": + {%- if field_ref.type == "struct" %} + result._{{ field_name }} = {{ field_ref.type }}Impl.fromIon(reader); + {%- elif field_ref.type == "list" %} + const items: any[] = []; + reader.stepIn(); + while (reader.next() !== null) { + {%- if field_ref.element_type == "struct" %} + items.push({{ field_ref.element_type }}Impl.fromIon(reader)); + {%- else %} + items.push(reader.value()); + {%- endif %} + } + reader.stepOut(); + result._{{ field_name }} = items; + {%- else %} + result._{{ field_name }} = reader.value(); + {%- endif %} + break; + {%- endfor %} + {%- endif %} + default: + throw new Error(`Unknown field: ${fieldName}`); + } + } + reader.stepOut(); + return result; + } +} \ No newline at end of file diff --git a/src/bin/ion/commands/generate/templates/typescript/nested_type.templ b/src/bin/ion/commands/generate/templates/typescript/nested_type.templ new file mode 100644 index 00000000..276da8a7 --- /dev/null +++ b/src/bin/ion/commands/generate/templates/typescript/nested_type.templ @@ -0,0 +1,104 @@ +{% import "util_macros.templ" as macros %} + +{% if model.annotations %} +{%- for annotation in model.annotations %} +@{{ annotation }} +{%- endfor %} +{% endif %} +export interface {{ model.name }} extends IonSerializable { + {% for field in model.fields %} + {% if field.annotations %} + {%- for annotation in field.annotations %} + @{{ annotation }} + {%- endfor %} + {% endif %} + {{ field.name }}: {{ field | nullable_type }}; + {% endfor %} +} + +{{ macros::type_guard(model=model) }} + +export class {{ model.name }}Impl implements {{ model.name }} { + {% for field in model.fields %} + private _{{ field.name }}: {{ field | nullable_type }}; + {% endfor %} + + constructor( + {% for field in model.fields %} + {% if not loop.first %}, {% endif %}{{ field.name }}: {{ field | nullable_type }} + {% endfor %} + ) { + {% for field in model.fields %} + this._{{ field.name }} = {{ field.name }}; + {% endfor %} + } + + {% for field in model.fields %} + get {{ field.name }}(): {{ field | nullable_type }} { + return this._{{ field.name }}; + } + + set {{ field.name }}(value: {{ field | nullable_type }}) { + this._{{ field.name }} = value; + } + {% endfor %} + + public toIon(): any { + const writer = makeWriter(); + writer.stepIn(StructType); + {% for field in model.fields %} + if (this._{{ field.name }} !== null) { + writer.setFieldName("{{ field.name }}"); + {% if field.type == "struct" %} + writer.writeStruct(this._{{ field.name }}.toIon()); + {% elif field.type == "list" %} + writer.writeList(this._{{ field.name }}.map(item => { + if (item === null) return null; + {% if field.element_type in ["string", "number", "boolean", "bigint"] %} + return item; + {% else %} + return item.toIon(); + {% endif %} + })); + {% else %} + writer.write{{ field.type | title }}(this._{{ field.name }}); + {% endif %} + } + {% endfor %} + writer.stepOut(); + return writer.getBytes(); + } + + public static fromIon(reader: Reader): {{ model.name }} { + const result = new {{ model.name }}Impl( + {% for field in model.fields %} + {% if not loop.first %}, {% endif %}null + {% endfor %} + ); + reader.stepIn(); + while (reader.next() !== null) { + const fieldName = reader.fieldName(); + switch (fieldName) { + {% for field in model.fields %} + case "{{ field.name }}": + {% if field.type == "struct" %} + result._{{ field.name }} = {{ field.type }}Impl.fromIon(reader); + {% elif field.type == "list" %} + {% if field.element_type in ["string", "number", "boolean", "bigint"] %} + result._{{ field.name }} = reader.value(); + {% else %} + result._{{ field.name }} = {{ field.element_type }}Impl.fromIon(reader); + {% endif %} + {% else %} + result._{{ field.name }} = reader.value(); + {% endif %} + break; + {% endfor %} + default: + throw new Error(`Unknown field: ${fieldName}`); + } + } + reader.stepOut(); + return result; + } +} \ No newline at end of file diff --git a/src/bin/ion/commands/generate/templates/typescript/scalar.templ b/src/bin/ion/commands/generate/templates/typescript/scalar.templ new file mode 100644 index 00000000..58246543 --- /dev/null +++ b/src/bin/ion/commands/generate/templates/typescript/scalar.templ @@ -0,0 +1,135 @@ +{% import "util_macros.templ" as macros %} + +import * as ion from 'ion-js'; +import { IonSerializable, IonDecimal, IonTimestamp, IonSymbol } from './ion_generated_code'; + +{% if model.code_gen_type and model.code_gen_type.doc_comment %} +/** + * {{ model.code_gen_type.doc_comment }} + */ +{% endif %} +{% if model.annotations %} +{%- for annotation in model.annotations %} +@{{ annotation }} +{%- endfor %} +{% endif %} +/** + * Represents a scalar value of type {{ model.code_gen_type.Scalar | ion_type_to_ts }} + */ +export type {{ model.name }} = {{ model.code_gen_type.Scalar | ion_type_to_ts }}; + +/** + * Type guard for {{ model.name }} + * @param value - Value to check + * @returns True if value is {{ model.name }} + */ +export function is{{ model.name }}(value: any): value is {{ model.name }} { + {% if model.code_gen_type.Scalar.type == "bool" %} + return typeof value === 'boolean'; + {% elif model.code_gen_type.Scalar.type == "int" %} + return typeof value === 'number' || typeof value === 'bigint'; + {% elif model.code_gen_type.Scalar.type == "float" %} + return typeof value === 'number' && !Number.isNaN(value); + {% elif model.code_gen_type.Scalar.type == "decimal" %} + return typeof value === 'object' && value !== null && + 'value' in value && 'coefficient' in value && 'exponent' in value; + {% elif model.code_gen_type.Scalar.type == "timestamp" %} + return value instanceof Date || (typeof value === 'object' && value !== null && 'value' in value); + {% elif model.code_gen_type.Scalar.type == "string" %} + return typeof value === 'string'; + {% elif model.code_gen_type.Scalar.type == "symbol" %} + return typeof value === 'object' && value !== null && 'text' in value; + {% elif model.code_gen_type.Scalar.type == "blob" %} + return value instanceof Uint8Array; + {% elif model.code_gen_type.Scalar.type == "clob" %} + return typeof value === 'string'; + {% else %} + return true; + {% endif %} +} + +/** + * Serializer implementation for {{ model.name }} + */ +export class {{ model.name }}Serializer implements IonSerializable { + private readonly value: {{ model.name }}; + + /** + * Creates a new serializer instance + * @param value - The scalar value to serialize + */ + constructor(value: {{ model.name }}) { + this.value = value; + } + + /** + * Serialize to Ion format + * @returns Serialized bytes + */ + public toIon(): any { + const writer = ion.makeTextWriter(); + {% if model.code_gen_type.Scalar.type == "bool" %} + writer.writeBoolean(this.value); + {% elif model.code_gen_type.Scalar.type == "int" %} + writer.writeInt(BigInt(this.value)); + {% elif model.code_gen_type.Scalar.type == "float" %} + writer.writeFloat(this.value); + {% elif model.code_gen_type.Scalar.type == "decimal" %} + writer.writeDecimal(this.value.value); + {% elif model.code_gen_type.Scalar.type == "timestamp" %} + writer.writeTimestamp(this.value instanceof Date ? this.value : this.value.value); + {% elif model.code_gen_type.Scalar.type == "string" %} + writer.writeString(this.value); + {% elif model.code_gen_type.Scalar.type == "symbol" %} + writer.writeSymbol(this.value.text); + {% elif model.code_gen_type.Scalar.type == "blob" %} + writer.writeBlob(this.value); + {% elif model.code_gen_type.Scalar.type == "clob" %} + writer.writeClob(this.value); + {% else %} + writer.writeAny(this.value); + {% endif %} + return writer.getBytes(); + } + + /** + * Deserialize from Ion format + * @param reader - Ion reader + * @returns Deserialized {{ model.name }} + * @throws Error if value cannot be deserialized + */ + public static fromIon(reader: ion.Reader): {{ model.name }} { + {% if model.code_gen_type.Scalar.type == "bool" %} + return reader.booleanValue(); + {% elif model.code_gen_type.Scalar.type == "int" %} + const value = reader.bigIntValue(); + return typeof value === 'bigint' ? value : BigInt(value); + {% elif model.code_gen_type.Scalar.type == "float" %} + return reader.numberValue(); + {% elif model.code_gen_type.Scalar.type == "decimal" %} + const decimalValue = reader.decimalValue(); + return { + value: decimalValue.toString(), + coefficient: BigInt(decimalValue.coefficient), + exponent: decimalValue.exponent + }; + {% elif model.code_gen_type.Scalar.type == "timestamp" %} + return reader.timestampValue(); + {% elif model.code_gen_type.Scalar.type == "string" %} + return reader.stringValue(); + {% elif model.code_gen_type.Scalar.type == "symbol" %} + const symbolValue = reader.symbolValue(); + return { + text: symbolValue.text, + sid: symbolValue.sid, + local_sid: symbolValue.local_sid + }; + {% elif model.code_gen_type.Scalar.type == "blob" %} + return reader.uInt8ArrayValue(); + {% elif model.code_gen_type.Scalar.type == "clob" %} + return reader.stringValue(); + {% else %} + return reader.value() as {{ model.name }}; + {% endif %} + } +} \ No newline at end of file diff --git a/src/bin/ion/commands/generate/templates/typescript/sequence.templ b/src/bin/ion/commands/generate/templates/typescript/sequence.templ new file mode 100644 index 00000000..19f510b4 --- /dev/null +++ b/src/bin/ion/commands/generate/templates/typescript/sequence.templ @@ -0,0 +1,95 @@ +{% import "util_macros.templ" as macros %} + +import { makeWriter, Reader, ListType } from 'ion-js'; +import { IonSerializable } from './ion_generated_code'; + +{% if model.code_gen_type and model.code_gen_type.doc_comment %} +/** + * {{ model.code_gen_type.doc_comment }} + */ +{% endif %} +{% if model.annotations %} +{%- for annotation in model.annotations %} +@{{ annotation }} +{%- endfor %} +{% endif %} +/** + * Represents a sequence of {{ model.element_type | ion_type_to_ts }} + */ +export type {{ model.name }} = Array<{{ model.element_type | ion_type_to_ts }}>; + +/** + * Type guard for {{ model.name }} + * @param value - Value to check + * @returns True if value is {{ model.name }} + */ +export function is{{ model.name }}(value: any): value is {{ model.name }} { + if (!Array.isArray(value)) return false; + return value.every(item => { + {% if model.element_type.type == "struct" %} + return is{{ model.element_type.name }}(item); + {% elif model.element_type.type == "enum" %} + return is{{ model.element_type.name }}(item); + {% else %} + return typeof item === '{{ model.element_type | ion_type_to_ts }}'; + {% endif %} + }); +} + +/** + * Serializer implementation for {{ model.name }} + */ +export class {{ model.name }}Serializer implements IonSerializable { + private readonly value: {{ model.name }}; + + /** + * Creates a new serializer instance + * @param value - The sequence to serialize + */ + constructor(value: {{ model.name }}) { + this.value = value; + } + + /** + * Serialize to Ion format + * @returns Serialized bytes + */ + public toIon(): any { + const writer = makeWriter(); + writer.stepIn(ListType); + this.value.forEach(item => { + {% if model.element_type.type == "struct" %} + writer.writeStruct(item.toIon()); + {% elif model.element_type.type == "enum" %} + writer.writeSymbol(item); + {% elif model.element_type.type == "list" %} + writer.writeList(item.map(i => i.toIon())); + {% else %} + writer.write{{ model.element_type.type | title }}(item); + {% endif %} + }); + writer.stepOut(); + return writer.getBytes(); + } + + /** + * Deserialize from Ion format + * @param reader - Ion reader + * @returns Deserialized {{ model.name }} + */ + public static fromIon(reader: Reader): {{ model.name }} { + const result: {{ model.name }} = []; + reader.stepIn(); + while (reader.next() !== null) { + {% if model.element_type.type == "struct" %} + result.push({{ model.element_type.name }}Impl.fromIon(reader)); + {% elif model.element_type.type == "enum" %} + result.push({{ model.element_type.name }}Impl.fromIon(reader)); + {% else %} + result.push(reader.value()); + {% endif %} + } + reader.stepOut(); + return result; + } +} \ No newline at end of file diff --git a/src/bin/ion/commands/generate/templates/typescript/util_macros.templ b/src/bin/ion/commands/generate/templates/typescript/util_macros.templ new file mode 100644 index 00000000..54349a52 --- /dev/null +++ b/src/bin/ion/commands/generate/templates/typescript/util_macros.templ @@ -0,0 +1,69 @@ +{% macro type_annotation(model) %} +{%- if model.annotations -%} +{%- for annotation in model.annotations %}{% if not loop.first %} {% endif %}@{{ annotation }}{% endfor -%} +{%- endif -%} +{% endmacro %} + +{% macro nullable_type(field) %} +{%- if field.1.presence == "Optional" -%} +({{ field.1.0 | fully_qualified_type_name }} | null) +{%- else -%} +{{ field.1.0 | fully_qualified_type_name }} +{%- endif -%} +{% endmacro %} + +{% macro ion_type_to_ts(type) %} +{%- if type.type == "bool" -%} +boolean +{%- elif type.type == "int" -%} +number | bigint +{%- elif type.type == "float" -%} +number +{%- elif type.type == "decimal" -%} +IonDecimal +{%- elif type.type == "timestamp" -%} +IonTimestamp +{%- elif type.type == "string" -%} +string +{%- elif type.type == "symbol" -%} +IonSymbol +{%- elif type.type == "blob" -%} +Uint8Array +{%- elif type.type == "clob" -%} +string +{%- elif type.type == "struct" -%} +{{ type.name }} +{%- elif type.type == "list" -%} +{%- if type.element_type -%} +Array<{{ type.element_type | fully_qualified_type_name }}> +{%- else -%} +Array +{%- endif -%} +{%- elif type.type == "sexp" -%} +{%- if type.element_type -%} +Array<{{ type.element_type | fully_qualified_type_name }}> +{%- else -%} +Array +{%- endif -%} +{%- else -%} +unknown +{%- endif -%} +{% endmacro %} + +{% macro type_guard(model) %} +export function is{{ model.name }}(value: any): value is {{ model.name }} { + if (typeof value !== 'object' || value === null) return false; + {% if model.code_gen_type %} + {% if model.code_gen_type.fields %} + {% for field_name, field_ref in model.code_gen_type.fields %} + if (!('{{ field_name }}' in value)) return false; + {% endfor %} + {% endif %} + {% endif %} + return true; +} +{% endmacro %} + +{% macro serializer(model) %} +// No need to redefine interfaces as they are imported from the shared types +{% endmacro %} \ No newline at end of file diff --git a/src/bin/ion/commands/generate/utils.rs b/src/bin/ion/commands/generate/utils.rs index 6961dad9..9539473f 100644 --- a/src/bin/ion/commands/generate/utils.rs +++ b/src/bin/ion/commands/generate/utils.rs @@ -474,3 +474,102 @@ impl From<&String> for IonSchemaType { value.as_str().into() } } + +pub struct TypeScriptLanguage; + +impl Language for TypeScriptLanguage { + fn file_extension() -> String { + "ts".to_string() + } + + fn name() -> String { + "typescript".to_string() + } + + fn file_name_for_type(name: &str) -> String { + name.to_case(Case::Camel) + } + + fn target_type(ion_schema_type: &IonSchemaType) -> Option { + use IonSchemaType::*; + Some( + match ion_schema_type { + Int => "number", + String | Symbol => "string", + Float => "number", + Bool => "boolean", + Blob | Clob => "Uint8Array", + List | SExp | Struct => return None, + SchemaDefined(name) => name, + } + .to_string(), + ) + } + + fn target_type_as_optional(target_type: FullyQualifiedTypeReference) -> FullyQualifiedTypeReference { + let mut optional_type = target_type; + optional_type.parameters.push(FullyQualifiedTypeReference { + type_name: vec![NamespaceNode::Type("undefined".to_string())], + parameters: vec![], + }); + optional_type + } + + fn target_type_as_sequence(element_type: FullyQualifiedTypeReference) -> FullyQualifiedTypeReference { + FullyQualifiedTypeReference { + type_name: vec![NamespaceNode::Type("Array".to_string())], + parameters: vec![element_type], + } + } + + fn template_name(template: &Template) -> String { + match template { + Template::Struct => "interface", + Template::Scalar => "scalar", + Template::Sequence => "sequence", + Template::Enum => "enum", + }.to_string() + } + + fn is_built_in_type(type_name: String) -> bool { + matches!( + type_name.as_str(), + "number" | "string" | "boolean" | "Uint8Array" | "undefined" | "Array" + ) + } + + fn namespace_separator() -> &'static str { + "/" + } + + fn add_type_to_namespace(is_nested_type: bool, type_name: &str, namespace: &mut Vec) { + if !is_nested_type { + namespace.push(NamespaceNode::Type(type_name.to_case(Case::Camel))); + } + } + + fn reset_namespace(namespace: &mut Vec) { + if let Some(last) = namespace.last() { + if matches!(last, NamespaceNode::Type(_)) { + namespace.pop(); + } + } + } + + fn fully_qualified_type_ref(name: &FullyQualifiedTypeReference) -> String { + let type_path = name.type_name.iter().map(|n| n.to_string()).join("/"); + if name.parameters.is_empty() { + type_path + } else { + format!( + "{}{}", + type_path, + name.parameters + .iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") + ) + } + } +} diff --git a/tests/code-gen-tests.rs b/tests/code-gen-tests.rs index a7b8d559..60b88d9a 100644 --- a/tests/code-gen-tests.rs +++ b/tests/code-gen-tests.rs @@ -1,9 +1,12 @@ use anyhow::Result; -use assert_cmd::Command; +use assert_cmd::assert::OutputAssertExt; +use assert_cmd::cargo::CommandCargoExt; +use assert_cmd::Command as AssertCommand; use rstest::rstest; use std::fs::File; use std::io::Write; use std::path::PathBuf; +use std::process::Command; use tempfile::TempDir; /// Returns a new [PathBuf] instance with the absolute path of the "code-gen-projects" directory. @@ -11,6 +14,15 @@ fn code_gen_projects_path() -> PathBuf { PathBuf::from_iter([env!("CARGO_MANIFEST_DIR"), "code-gen-projects"]) } +// Helper functions for paths +fn schema_dir() -> PathBuf { + code_gen_projects_path().join("schema") +} + +fn input_dir() -> PathBuf { + code_gen_projects_path().join("input") +} + #[test] fn roundtrip_tests_for_generated_code_gradle() -> Result<()> { // run the gradle project defined under `code-gen-projects`, @@ -151,3 +163,57 @@ fn test_unsupported_schema_types_failures(#[case] test_schema: &str) -> Result<( command_assert.failure(); Ok(()) } + +#[test] +fn roundtrip_tests_for_generated_code_typescript() -> Result<()> { + // Get absolute paths for ion executable and test directories + let ion_executable = env!("CARGO_BIN_EXE_ion"); + let test_project_path = code_gen_projects_path() + .join("typescript") + .join("code-gen-demo"); + + // Clean and generate TypeScript code + let generate_output = std::process::Command::new(ion_executable) + .current_dir(&test_project_path) + .env("ION_CLI", ion_executable) + .arg("-X") // Enable unstable features + .arg("generate") + .arg("--language") + .arg("typescript") + .arg("--authority") + .arg(schema_dir().to_str().unwrap()) + .arg("--output") + .arg("src/generated") + .output() + .expect("failed to execute ion generate"); + + println!("Generate status: {}", generate_output.status); + std::io::stdout().write_all(&generate_output.stdout)?; + std::io::stderr().write_all(&generate_output.stderr)?; + assert!(generate_output.status.success()); + + // Run npm install and tests + let install_output = std::process::Command::new("npm") + .current_dir(&test_project_path) + .arg("install") + .output() + .expect("failed to execute npm install"); + + println!("npm install status: {}", install_output.status); + std::io::stdout().write_all(&install_output.stdout)?; + std::io::stderr().write_all(&install_output.stderr)?; + assert!(install_output.status.success()); + + let test_output = std::process::Command::new("npm") + .current_dir(&test_project_path) + .arg("test") + .output() + .expect("failed to execute npm test"); + + println!("npm test status: {}", test_output.status); + std::io::stdout().write_all(&test_output.stdout)?; + std::io::stderr().write_all(&test_output.stderr)?; + assert!(test_output.status.success()); + + Ok(()) +}