diff --git a/src/enum/enum_test.ts b/src/enum/enum.test.ts similarity index 99% rename from src/enum/enum_test.ts rename to src/enum/enum.test.ts index 9480e42..0af8d8a 100644 --- a/src/enum/enum_test.ts +++ b/src/enum/enum.test.ts @@ -1,4 +1,4 @@ -import { runCodemod } from "./enum.ts"; +import { enumCodemod } from "./enum.ts"; import { spawn } from "node:child_process"; import { assertEquals } from "jsr:@std/assert"; @@ -45,7 +45,7 @@ class TestUtils { } async runAndReadCodemod(filePath: string): Promise { - runCodemod(filePath); + enumCodemod(filePath); return await this.readTestFile(filePath); } diff --git a/src/enum/enum.ts b/src/enum/enum.ts index 78e28ba..b52ef1e 100644 --- a/src/enum/enum.ts +++ b/src/enum/enum.ts @@ -82,7 +82,7 @@ function updateEnumReferences(sourceFile: Node, enumNames: string[]): void { }); } -export function runCodemod(filePath: string): void { +export function enumCodemod(filePath: string): void { const sourceFile = project.addSourceFileAtPath(filePath); const enums = sourceFile.getEnums(); diff --git a/src/parameter-properties/parameter-properties.test.ts b/src/parameter-properties/parameter-properties.test.ts new file mode 100644 index 0000000..b06fc7b --- /dev/null +++ b/src/parameter-properties/parameter-properties.test.ts @@ -0,0 +1,821 @@ +import { parameterPropertiesCodemod } from "./parameter-properties.ts"; +import { spawn } from "node:child_process"; +import { assertEquals } from "jsr:@std/assert"; + +/** + * Checks a TypeScript file using tsc. + * @example + * await checkTsFile("src/index.ts"); + */ +async function checkTsFile(filePath: string): Promise { + await new Promise((resolve, reject) => { + const tsc = spawn("deno", ["check", filePath]); + + tsc.stderr.on("data", (data) => { + console.error(data.toString()); + }); + + tsc.on("close", (code: number) => { + if (code === 0) { + console.log("Typecheck succeeded 🎉"); + resolve(undefined); + } else { + reject(new Error("Typecheck failed")); + } + }); + }); +} + +/** + * Test utilities for codemod testing with proper cleanup + * @example + * const utils = new TestUtils(); + * await utils.createTestFile("test.ts", "class Test { constructor(public name: string) {} }"); + */ +class TestUtils { + private tempFiles = new Set(); + + async createTestFile(filePath: string, content: string): Promise { + await Deno.writeTextFile("tmp/" + filePath, content); + this.tempFiles.add("tmp/" + filePath); + } + + async readTestFile(filePath: string): Promise { + return await Deno.readTextFile("tmp/" + filePath); + } + + async runAndReadCodemod(filePath: string): Promise { + parameterPropertiesCodemod("tmp/" + filePath); + return await this.readTestFile("tmp/" + filePath); + } + + async cleanup(): Promise { + if (Deno.env.get("DEBUG")) return; + for (const file of this.tempFiles) { + try { + await Deno.remove(file); + } catch { + // Ignore if file doesn't exist + } + } + this.tempFiles.clear(); + } +} + +Deno.test.only("converts public parameter properties", async () => { + const utils = new TestUtils(); + const testFile = "public_params_test.ts"; + + try { + const originalContent = `class User { + constructor(public name: string, public age: number) {} +} + +const user = new User("John", 30); +console.log(user.name, user.age);`; + + const expectedOutput = `class User { + public name: string; + public age: number; + + constructor(name: string, age: number) { + this.name = name; + this.age = age; + } +} + +const user = new User("John", 30); +console.log(user.name, user.age);`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("converts private parameter properties", async () => { + const utils = new TestUtils(); + const testFile = "private_params_test.ts"; + + try { + const originalContent = `class BankAccount { + constructor(private balance: number, private accountNumber: string) {} + + getBalance(): number { + return this.balance; + } + + getAccountNumber(): string { + return this.accountNumber; + } +}`; + + const expectedOutput = `class BankAccount { + private balance: number; + private accountNumber: string; + + constructor(balance: number, accountNumber: string) { + this.balance = balance; + this.accountNumber = accountNumber; + } + + getBalance(): number { + return this.balance; + } + + getAccountNumber(): string { + return this.accountNumber; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("converts readonly parameter properties", async () => { + const utils = new TestUtils(); + const testFile = "readonly_params_test.ts"; + + try { + const originalContent = `class Configuration { + constructor(readonly apiKey: string, readonly baseUrl: string) {} + + getApiKey(): string { + return this.apiKey; + } + + getBaseUrl(): string { + return this.baseUrl; + } +}`; + + const expectedOutput = `class Configuration { + readonly apiKey: string; + readonly baseUrl: string; + + constructor(apiKey: string, baseUrl: string) { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + } + + getApiKey(): string { + return this.apiKey; + } + + getBaseUrl(): string { + return this.baseUrl; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("converts mixed access modifiers", async () => { + const utils = new TestUtils(); + const testFile = "mixed_modifiers_test.ts"; + + try { + const originalContent = `class Product { + constructor( + public name: string, + private price: number, + protected category: string, + readonly id: string + ) {} + + getName(): string { + return this.name; + } + + getPrice(): number { + return this.price; + } + + getCategory(): string { + return this.category; + } + + getId(): string { + return this.id; + } +}`; + + const expectedOutput = `class Product { + public name: string; + private price: number; + protected category: string; + readonly id: string; + + constructor( + name: string, + price: number, + category: string, + id: string + ) { + this.name = name; + this.price = price; + this.category = category; + this.id = id; + } + + getName(): string { + return this.name; + } + + getPrice(): number { + return this.price; + } + + getCategory(): string { + return this.category; + } + + getId(): string { + return this.id; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("handles parameter properties with initializers", async () => { + const utils = new TestUtils(); + const testFile = "initializers_test.ts"; + + try { + const originalContent = `class Settings { + constructor( + public theme: string = "dark", + public language: string = "en", + private debug: boolean = false + ) {} + + getTheme(): string { + return this.theme; + } + + getLanguage(): string { + return this.language; + } + + isDebug(): boolean { + return this.debug; + } +}`; + + const expectedOutput = `class Settings { + public theme: string; + public language: string; + private debug: boolean; + + constructor( + theme: string = "dark", + language: string = "en", + debug: boolean = false + ) { + this.theme = theme; + this.language = language; + this.debug = debug; + } + + getTheme(): string { + return this.theme; + } + + getLanguage(): string { + return this.language; + } + + isDebug(): boolean { + return this.debug; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("handles complex types in parameter properties", async () => { + const utils = new TestUtils(); + const testFile = "complex_types_test.ts"; + + try { + const originalContent = `interface UserData { + id: string; + name: string; + email: string; +} + +type Status = "active" | "inactive" | "pending"; + +class UserManager { + constructor( + public users: UserData[], + private statusFilter: Status, + protected config: { maxUsers: number; timeout: number } + ) {} + + getUsers(): UserData[] { + return this.users; + } + + getStatusFilter(): Status { + return this.statusFilter; + } + + getConfig(): { maxUsers: number; timeout: number } { + return this.config; + } +}`; + + const expectedOutput = `interface UserData { + id: string; + name: string; + email: string; +} + +type Status = "active" | "inactive" | "pending"; + +class UserManager { + public users: UserData[]; + private statusFilter: Status; + protected config: { maxUsers: number; timeout: number }; + + constructor( + users: UserData[], + statusFilter: Status, + config: { maxUsers: number; timeout: number } + ) { + this.users = users; + this.statusFilter = statusFilter; + this.config = config; + } + + getUsers(): UserData[] { + return this.users; + } + + getStatusFilter(): Status { + return this.statusFilter; + } + + getConfig(): { maxUsers: number; timeout: number } { + return this.config; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("handles multiple classes in single file", async () => { + const utils = new TestUtils(); + const testFile = "multiple_classes_test.ts"; + + try { + const originalContent = `class Person { + constructor(public name: string, public age: number) {} + + introduce(): string { + return \`Hi, I'm \${this.name} and I'm \${this.age} years old.\`; + } +} + +class Car { + constructor( + private brand: string, + private model: string, + public year: number + ) {} + + getBrand(): string { + return this.brand; + } + + getModel(): string { + return this.model; + } + + getYear(): number { + return this.year; + } +} + +class Database { + constructor(readonly connectionString: string, private maxConnections: number) {} + + getConnectionString(): string { + return this.connectionString; + } + + getMaxConnections(): number { + return this.maxConnections; + } +}`; + + const expectedOutput = `class Person { + public name: string; + public age: number; + + constructor(name: string, age: number) { + this.name = name; + this.age = age; + } + + introduce(): string { + return \`Hi, I'm \${this.name} and I'm \${this.age} years old.\`; + } +} + +class Car { + private brand: string; + private model: string; + public year: number; + + constructor( + brand: string, + model: string, + year: number + ) { + this.brand = brand; + this.model = model; + this.year = year; + } + + getBrand(): string { + return this.brand; + } + + getModel(): string { + return this.model; + } + + getYear(): number { + return this.year; + } +} + +class Database { + readonly connectionString: string; + private maxConnections: number; + + constructor(connectionString: string, maxConnections: number) { + this.connectionString = connectionString; + this.maxConnections = maxConnections; + } + + getConnectionString(): string { + return this.connectionString; + } + + getMaxConnections(): number { + return this.maxConnections; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("preserves existing class properties", async () => { + const utils = new TestUtils(); + const testFile = "existing_properties_test.ts"; + + try { + const originalContent = `class Employee { + private department: string = "Engineering"; + + constructor(public name: string, public id: number) {} + + getDepartment(): string { + return this.department; + } + + setDepartment(dept: string): void { + this.department = dept; + } +}`; + + const expectedOutput = `class Employee { + private department: string = "Engineering"; + public name: string; + public id: number; + + constructor(name: string, id: number) { + this.name = name; + this.id = id; + } + + getDepartment(): string { + return this.department; + } + + setDepartment(dept: string): void { + this.department = dept; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("handles classes without parameter properties", async () => { + const utils = new TestUtils(); + const testFile = "no_params_test.ts"; + + try { + const originalContent = `class SimpleClass { + private value: string; + + constructor(value: string) { + this.value = value; + } + + getValue(): string { + return this.value; + } +} + +class EmptyClass { + constructor() {} + + doSomething(): void { + console.log("Hello World"); + } +}`; + + const expectedOutput = `class SimpleClass { + private value: string; + + constructor(value: string) { + this.value = value; + } + + getValue(): string { + return this.value; + } +} + +class EmptyClass { + constructor() {} + + doSomething(): void { + console.log("Hello World"); + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("preserves comments", async () => { + const utils = new TestUtils(); + const testFile = "comments_test.ts"; + + try { + const originalContent = `/** + * User class with parameter properties + */ +class User { + /** + * Creates a new user instance + * @param name - The user's name + * @param age - The user's age + */ + constructor(public name: string, public age: number) {} + + // Get user info + getInfo(): string { + return \`\${this.name} (\${this.age})\`; + } +}`; + + const expectedOutput = `/** + * User class with parameter properties + */ +class User { + public name: string; + public age: number; + + /** + * Creates a new user instance + * @param name - The user's name + * @param age - The user's age + */ + constructor(name: string, age: number) { + this.name = name; + this.age = age; + } + + // Get user info + getInfo(): string { + return \`\${this.name} (\${this.age})\`; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("handles generic classes", async () => { + const utils = new TestUtils(); + const testFile = "generic_classes_test.ts"; + + try { + const originalContent = `class Container { + constructor(private value: T, public label: string) {} + + getValue(): T { + return this.value; + } + + getLabel(): string { + return this.label; + } +} + +class Pair { + constructor(public key: K, public value: V) {} + + getKey(): K { + return this.key; + } + + getValue(): V { + return this.value; + } +}`; + + const expectedOutput = `class Container { + private value: T; + public label: string; + + constructor(value: T, label: string) { + this.value = value; + this.label = label; + } + + getValue(): T { + return this.value; + } + + getLabel(): string { + return this.label; + } +} + +class Pair { + public key: K; + public value: V; + + constructor(key: K, value: V) { + this.key = key; + this.value = value; + } + + getKey(): K { + return this.key; + } + + getValue(): V { + return this.value; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); + +Deno.test("handles abstract classes", async () => { + const utils = new TestUtils(); + const testFile = "abstract_classes_test.ts"; + + try { + const originalContent = `abstract class Animal { + constructor(public name: string, protected species: string) {} + + abstract makeSound(): string; + + getName(): string { + return this.name; + } + + getSpecies(): string { + return this.species; + } +} + +class Dog extends Animal { + constructor(name: string, public breed: string) { + super(name, "Canis"); + } + + makeSound(): string { + return "Woof!"; + } + + getBreed(): string { + return this.breed; + } +}`; + + const expectedOutput = `abstract class Animal { + public name: string; + protected species: string; + + constructor(name: string, species: string) { + this.name = name; + this.species = species; + } + + abstract makeSound(): string; + + getName(): string { + return this.name; + } + + getSpecies(): string { + return this.species; + } +} + +class Dog extends Animal { + public breed: string; + + constructor(name: string, breed: string) { + super(name, "Canis"); + this.breed = breed; + } + + makeSound(): string { + return "Woof!"; + } + + getBreed(): string { + return this.breed; + } +}`; + + await utils.createTestFile(testFile, originalContent); + const result = await utils.runAndReadCodemod(testFile); + await checkTsFile(testFile); + assertEquals(result, expectedOutput); + } finally { + await utils.cleanup(); + } +}); diff --git a/src/parameter-properties/parameter-properties.ts b/src/parameter-properties/parameter-properties.ts new file mode 100644 index 0000000..448fe0c --- /dev/null +++ b/src/parameter-properties/parameter-properties.ts @@ -0,0 +1,96 @@ +import { + ClassDeclaration, + ConstructorDeclaration, + Node, + Project, + SyntaxKind, +} from "ts-morph"; + +const project = new Project(); + +function convertParameterProperties(classDeclaration: ClassDeclaration): void { + const constructors = classDeclaration.getConstructors(); + if (constructors.length === 0) return; + + const constructor = constructors[0]; + const parameters = constructor.getParameters(); + const parameterProperties = parameters.filter((param) => + param.hasModifier(SyntaxKind.ReadonlyKeyword) || + param.hasModifier(SyntaxKind.PublicKeyword) || + param.hasModifier(SyntaxKind.PrivateKeyword) || + param.hasModifier(SyntaxKind.ProtectedKeyword) + ); + + if (parameterProperties.length === 0) return; + + const className = classDeclaration.getName(); + if (!className) return; + + const existingProperties = classDeclaration + .getProperties() + .map((property) => property.getName()); + + const propertyDeclarations: string[] = []; + const constructorAssignments: string[] = []; + + parameterProperties.forEach((param) => { + const paramName = param.getName(); + const paramType = param.getType().getText(); + const modifiers = param.getModifiers().map((mod) => mod.getText()).join( + " ", + ); + const initializer = param.getInitializer()?.getText(); + + if (!existingProperties.includes(paramName)) { + const propertyDeclaration = `${modifiers} ${paramName}: ${paramType};`; + propertyDeclarations.push(propertyDeclaration); + + const assignment = initializer + ? `this.${paramName} = ${paramName};` + : `this.${paramName} = ${paramName};`; + constructorAssignments.push(assignment); + } + }); + + if (propertyDeclarations.length > 0) { + const classComment = classDeclaration.getLeadingCommentRanges()[0] + ?.getText(); + const existingText = classDeclaration.getText(); + const constructorStart = existingText.indexOf("constructor("); + const constructorEnd = existingText.lastIndexOf(")") + 1; + + const newConstructorBody = constructorAssignments.length > 0 + ? ` {\n ${constructorAssignments.join("\n ")}\n }` + : ""; + + const newConstructor = existingText.substring( + constructorStart, + constructorEnd, + ) + newConstructorBody; + + const newClassText = existingText.replace( + existingText.substring(constructorStart), + newConstructor, + ); + + const propertiesText = propertyDeclarations.join("\n "); + let finalText = newClassText.replace( + /class\s+\w+\s*{/, + `class ${className} {\n ${propertiesText}`, + ); + if (classComment) { + finalText = classComment + "\n" + finalText; + } + + classDeclaration.replaceWithText(finalText); + } +} + +export function parameterPropertiesCodemod(filePath: string): void { + const sourceFile = project.addSourceFileAtPath(filePath); + + const classes = sourceFile.getClasses(); + classes.forEach(convertParameterProperties); + + sourceFile.saveSync(); +}