diff --git a/Metano.slnx b/Metano.slnx index 8765655..e5fbec1 100644 --- a/Metano.slnx +++ b/Metano.slnx @@ -11,6 +11,7 @@ + diff --git a/docs/compiler-refactor-plan.md b/docs/compiler-refactor-plan.md new file mode 100644 index 0000000..be1d757 --- /dev/null +++ b/docs/compiler-refactor-plan.md @@ -0,0 +1,487 @@ +# Compiler Refactor Plan for Multi-Target Backends + +## Goal + +Refactor the current TypeScript-focused compiler pipeline into a target-agnostic compiler architecture that can support multiple output languages without duplicating semantic lowering logic. + +The near-term constraint is critical: + +- Keep the current TypeScript backend working. +- Preserve current observable behavior while refactoring. +- Migrate incrementally. +- Prepare the architecture for future targets beyond TypeScript. + +## Current Baseline + +The current implementation is centered in `src/Metano.Compiler.TypeScript/`. + +Observed characteristics: + +- Roslyn already provides parsing, binding, and semantic analysis. +- The TypeScript backend currently contains both: + - semantic lowering decisions + - TypeScript-specific emission decisions +- Large transformation hotspots include: + - `Transformation/TypeTransformer.cs` + - `Transformation/RecordClassTransformer.cs` + - `Transformation/ImportCollector.cs` + - `Transformation/TypeMapper.cs` + - `TypeScript/Printer.cs` +- Current test baseline: + - `357` passing tests + - validated through the TUnit runner binary + +## Desired Target Architecture + +The compiler should evolve toward this pipeline: + +`Roslyn front-end -> semantic model extraction -> shared IR -> target-specific lowering/emission` + +### Layers + +1. **Front-end** + - Input: Roslyn `Compilation`, symbols, syntax, semantic models. + - Responsibility: discover transpilable program shape and normalize semantic facts. + +2. **Shared IR** + - A target-agnostic intermediate representation of: + - modules + - types + - members + - expressions + - statements + - type references + - runtime requirements + - diagnostics metadata + +3. **Common lowering** + - Converts Roslyn semantics to shared IR. + - Must contain language-independent rules such as: + - records/value semantics + - nullable handling + - async/task concepts + - extension methods + - overload dispatch intent + - pattern semantics + +4. **Target backends** + - Convert shared IR to a target-specific AST or textual output. + - Examples: + - TypeScript backend + - future Dart/Kotlin/Swift/etc. + +5. **Packaging/runtime integration** + - Per-target concerns: + - imports + - package/module layout + - runtime helper mapping + - dependency manifest generation + +## Design Rules + +These rules should guide every refactor step: + +- Do not break the TypeScript backend during architectural extraction. +- Do not move TypeScript-specific syntax decisions into shared lowering. +- Do not put semantic decisions into backend printers/emitters. +- Eliminate static ambient compiler state from shared logic. +- Each refactor step must leave the codebase in a releasable state. +- Prefer adapters and parallel paths over big-bang rewrites. + +## Main Problems to Fix + +### 1. TypeScript backend owns too much semantic logic + +Today, backend classes decide both what the source means and how TypeScript should express it. + +Examples: + +- `RecordClassTransformer` +- `ImportCollector` +- `TypeMapper` + +This will not scale across multiple targets. + +### 2. Static state in `TypeMapper` + +`TypeMapper` currently uses thread-static mutable state for mappings and dependency tracking. + +Risks: + +- hidden coupling +- difficult reasoning +- poor composability +- harder parallelization +- harder testing of isolated steps + +### 3. Import/runtime decisions happen too late + +The current TypeScript import logic infers runtime needs by walking TypeScript AST output. That is fragile for multi-target support. + +Instead, runtime requirements should be modeled as explicit facts earlier in the pipeline. + +### 4. Large files combine multiple concerns + +Especially: + +- `TypeTransformer.cs` +- `RecordClassTransformer.cs` +- `ImportCollector.cs` + +These should be decomposed by responsibility, not only by size. + +## Phased Execution Plan + +## Phase 0 - Freeze the Baseline + +### Objective + +Create a stable safety net before moving architecture. + +### Tasks + +1. Record the current passing test baseline. +2. Add a short contributor note describing how to run tests through the TUnit binary. +3. Identify the highest-value golden/snapshot tests for TypeScript output. +4. Add a benchmark harness for transform time on representative samples if lightweight enough. + +### Deliverables + +- documented baseline +- test execution command documented +- no behavior changes + +### Exit Criteria + +- all current tests still pass +- no production code changed or only documentation/test harness changed + +## Phase 1 - Define the Shared Compiler Model + +### Objective + +Create the target-agnostic contracts without yet migrating the full pipeline. + +### Tasks + +1. Define compiler service contracts for: + - type resolution + - symbol lowering + - runtime dependency collection + - diagnostics reporting +2. Define the first version of the shared IR: + - `IrModule` + - `IrTypeDecl` + - `IrMemberDecl` + - `IrMethodDecl` + - `IrPropertyDecl` + - `IrFieldDecl` + - `IrExpression` + - `IrStatement` + - `IrTypeRef` +3. Add support for semantic annotations/capabilities in IR: + - nullable + - async + - generator + - extension method + - record/value semantics + - runtime helper requirements + +### Deliverables + +- initial IR model +- initial compiler service interfaces +- architecture note or ADR if needed + +### Exit Criteria + +- the IR compiles and is documented +- no existing backend behavior is removed + +## Phase 2 - Remove Ambient State and Introduce Context Objects + +### Objective + +Make the current pipeline explicit and composable before rerouting it. + +### Tasks + +1. Replace thread-static `TypeMapper` state with an explicit per-compilation context/service. +2. Move dependency tracking and cross-package origin tracking into owned context objects. +3. Ensure current TypeScript transformation classes consume context explicitly. +4. Keep public behavior unchanged. + +### Deliverables + +- `TypeMapper` no longer depends on ambient mutable state +- dependency tracking lives in owned objects + +### Exit Criteria + +- all tests still pass +- no thread-static state remains in the shared mapping path + +## Phase 3 - Extract Front-End Semantic Lowering + +### Objective + +Move language-independent analysis out of TypeScript-specific transformers. + +### Tasks + +1. Split `TypeTransformer` into: + - discovery/orchestration + - semantic lowering entrypoints + - backend dispatch +2. Extract common semantic builders for: + - records/classes/interfaces/enums + - constructor model + - method model + - overload model + - nested types +3. Introduce `Roslyn -> IR` builders alongside existing TypeScript logic. +4. Start with easy shapes: + - enums + - interfaces + - type references + - simple classes + +### Deliverables + +- partial Roslyn-to-IR pipeline +- backend still driven through adapters where necessary + +### Exit Criteria + +- at least one vertical slice reaches TypeScript through IR +- tests still green + +## Phase 4 - Adapt TypeScript Backend to Consume IR + +### Objective + +Turn the current TypeScript backend into a true target backend. + +### Tasks + +1. Introduce `IR -> TypeScript AST` adapters. +2. Keep the existing `TypeScript/AST` and `Printer` initially. +3. Gradually replace direct Roslyn-driven lowering in TypeScript backend classes. +4. Migrate TypeScript-specific concerns to backend-only services: + - naming/escaping + - module path calculation + - import rendering + - package.json generation + +### Deliverables + +- TypeScript backend consuming IR for selected features +- reduced Roslyn coupling inside TS backend + +### Exit Criteria + +- core TypeScript features flow through IR +- output parity preserved for migrated areas + +## Phase 5 - Model Runtime Requirements Explicitly + +### Objective + +Stop discovering runtime needs by reverse-walking backend-specific ASTs. + +### Tasks + +1. Represent runtime helper requirements in IR. +2. Represent cross-package dependencies and external imports as semantic facts. +3. Refactor `ImportCollector` into: + - semantic dependency collection + - TypeScript import rendering +4. Ensure packaging logic reads backend facts instead of inferring from syntax trees. + +### Deliverables + +- explicit runtime/dependency facts in IR or backend-bound metadata +- simpler TypeScript import assembly + +### Exit Criteria + +- import/runtime logic no longer depends primarily on heuristics over TS AST shape + +## Phase 6 - Migrate Complex Features + +### Objective + +Move high-complexity features after the architecture is proven. + +### Migration order + +1. records and value semantics +2. overload dispatch +3. extension methods and module lowering +4. pattern matching / switch expressions +5. JSON serializer context features +6. operator overloading + +### Deliverables + +- migrated complex features through IR + +### Exit Criteria + +- TypeScript backend old/new paths substantially reduced +- parity maintained by tests + +## Phase 7 - Pilot a Second Target + +### Objective + +Validate that the architecture is truly multi-target. + +### Tasks + +1. Choose one pilot backend. +2. Implement a minimal feature slice: + - modules + - simple classes + - enums + - interfaces + - methods +3. Compare pain points against TypeScript-specific assumptions still present in IR. +4. Refine IR based on actual second-target pressure. + +### Deliverables + +- first non-TypeScript target prototype + +### Exit Criteria + +- the second target compiles a real sample +- new target does not require semantic rules to be copied out of the TypeScript backend + +## Recommended Extraction Order in the Existing Codebase + +This is the suggested file-by-file order for the first implementation wave. + +### Wave 1 + +- `Transformation/TypeMapper.cs` +- `Transformation/TypeScriptTransformContext.cs` +- `Transformation/PathNaming.cs` +- `Transformation/ImportCollector.cs` + +Reason: + +- these are foundational dependency/context concerns +- they unblock removal of hidden state +- they reduce coupling before deeper transformations + +### Wave 2 + +- `Transformation/TypeTransformer.cs` +- `Transformation/InterfaceTransformer.cs` +- `Transformation/EnumTransformer.cs` + +Reason: + +- these are good first vertical slices into shared IR + +### Wave 3 + +- `Transformation/RecordClassTransformer.cs` +- `Transformation/OverloadDispatcherBuilder.cs` +- `Transformation/ModuleTransformer.cs` + +Reason: + +- these contain the most semantic complexity and should move after the framework is ready + +### Wave 4 + +- `Transformation/ExpressionTransformer.cs` +- child handlers under `Transformation/` + +Reason: + +- expression lowering should migrate after top-level declaration structure is stabilized + +## Test Strategy + +## Keep Existing Tests + +All current TypeScript behavior tests remain required. + +## Add Layered Tests + +Add tests at three levels: + +1. Roslyn -> IR tests +2. IR -> TypeScript AST tests +3. end-to-end Roslyn -> TypeScript output tests + +## Add Feature Matrix Coverage + +Track, per feature: + +- semantic support in IR +- TypeScript backend support +- next target support +- runtime helper dependency +- expected diagnostics + +## Risks + +### Risk: Big-bang rewrite + +Mitigation: + +- use adapters +- migrate feature slices +- preserve old path until parity is proven + +### Risk: IR becomes TypeScript-shaped + +Mitigation: + +- validate with a second target early +- forbid TS syntax details in IR naming and constructs + +### Risk: Semantic duplication during migration + +Mitigation: + +- establish one canonical lowering path per migrated feature +- deprecate old path immediately after parity is achieved + +### Risk: Test suite only validates TypeScript text + +Mitigation: + +- add IR-level tests before major feature migration + +## Suggested First Concrete Milestone + +The first milestone should be intentionally narrow: + +### Milestone A + +- eliminate ambient `TypeMapper` state +- add initial IR model +- route enums and interfaces through IR +- keep TypeScript output unchanged + +Why this milestone: + +- high architectural leverage +- relatively low feature risk +- proves the new shape without touching the most fragile code first + +## Suggested Prompting Guidance for a Follow-Up Agent + +If another agent continues this work, it should: + +- preserve behavior first +- avoid rewriting everything at once +- prioritize extraction order +- explicitly separate semantic lowering from TypeScript emission +- produce small, reviewable patches + diff --git a/js/sample-issue-tracker/src/issues/application/issue-queries.ts b/js/sample-issue-tracker/src/issues/application/issue-queries.ts index 60bf7c2..44e62de 100644 --- a/js/sample-issue-tracker/src/issues/application/issue-queries.ts +++ b/js/sample-issue-tracker/src/issues/application/issue-queries.ts @@ -1,5 +1,4 @@ -import { Enumerable } from "metano-runtime"; -import type { Grouping } from "metano-runtime"; +import { Enumerable, type Grouping } from "metano-runtime"; import { IssuePriority, IssueStatus, type Issue } from "#/issues/domain"; import type { UserId } from "#/shared-kernel"; diff --git a/js/sample-issue-tracker/src/issues/domain/issue-id.ts b/js/sample-issue-tracker/src/issues/domain/issue-id.ts index f82c728..4a81b40 100644 --- a/js/sample-issue-tracker/src/issues/domain/issue-id.ts +++ b/js/sample-issue-tracker/src/issues/domain/issue-id.ts @@ -1,5 +1,4 @@ -import { HashCode } from "metano-runtime"; -import { UUID } from "metano-runtime"; +import { HashCode, UUID } from "metano-runtime"; export type IssueId = string & { readonly __brand: "IssueId" }; diff --git a/js/sample-issue-tracker/src/planning/domain/sprint.ts b/js/sample-issue-tracker/src/planning/domain/sprint.ts index a58542b..d6a02f7 100644 --- a/js/sample-issue-tracker/src/planning/domain/sprint.ts +++ b/js/sample-issue-tracker/src/planning/domain/sprint.ts @@ -1,6 +1,5 @@ import { Temporal } from "@js-temporal/polyfill"; -import { dayNumber } from "metano-runtime"; -import { HashSet } from "metano-runtime"; +import { HashSet, dayNumber } from "metano-runtime"; import type { IssueId } from "#/issues/domain"; export class Sprint { diff --git a/js/sample-issue-tracker/src/shared-kernel/user-id.ts b/js/sample-issue-tracker/src/shared-kernel/user-id.ts index b285239..3b88502 100644 --- a/js/sample-issue-tracker/src/shared-kernel/user-id.ts +++ b/js/sample-issue-tracker/src/shared-kernel/user-id.ts @@ -1,5 +1,4 @@ -import { HashCode } from "metano-runtime"; -import { UUID } from "metano-runtime"; +import { HashCode, UUID } from "metano-runtime"; export type UserId = string & { readonly __brand: "UserId" }; diff --git a/js/sample-operator-overloading/package.json b/js/sample-operator-overloading/package.json new file mode 100644 index 0000000..197fc6c --- /dev/null +++ b/js/sample-operator-overloading/package.json @@ -0,0 +1,32 @@ +{ + "name": "sample-operator-overloading", + "type": "module", + "module": "./dist/program.js", + "private": true, + "sideEffects": false, + "imports": { + "#/*": { + "types": "./dist/*.d.ts", + "import": "./dist/*.js", + "default": "./src/*.ts" + }, + "#": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./src/index.ts" + } + }, + "dependencies": { + "metano-runtime": "workspace:*", + "decimal.js": "^10.6.0" + }, + "devDependencies": { + "@types/bun": "latest", + "@typescript/native-preview": "^7.0.0-dev.20260225.1" + }, + "scripts": { + "generate": "dotnet run --project ../../src/Metano.Compiler.TypeScript/Metano.Compiler.TypeScript.csproj -- -p ../../samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj -o ./src --clean --time", + "build": "tsgo -b .", + "test": "bun test" + } +} diff --git a/js/sample-operator-overloading/src/currency.ts b/js/sample-operator-overloading/src/currency.ts new file mode 100644 index 0000000..86ae747 --- /dev/null +++ b/js/sample-operator-overloading/src/currency.ts @@ -0,0 +1,5 @@ +export enum Currency { + Brl = 0, + Usd = 1, + Eur = 2, +} diff --git a/js/sample-operator-overloading/src/index.ts b/js/sample-operator-overloading/src/index.ts new file mode 100644 index 0000000..4ce57c2 --- /dev/null +++ b/js/sample-operator-overloading/src/index.ts @@ -0,0 +1,3 @@ +export { Currency } from "./currency"; +export { Money } from "./money"; +export { NoSameMoneyCurrencyException } from "./no-same-money-currency-exception"; diff --git a/js/sample-operator-overloading/src/money.ts b/js/sample-operator-overloading/src/money.ts new file mode 100644 index 0000000..7541b4d --- /dev/null +++ b/js/sample-operator-overloading/src/money.ts @@ -0,0 +1,142 @@ +import { HashCode, isBigInt } from "metano-runtime"; +import { Decimal } from "decimal.js"; +import type { Currency } from "./currency"; +import { NoSameMoneyCurrencyException } from "./no-same-money-currency-exception"; + +export class Money { + constructor(readonly cents: bigint, readonly currency: Currency) { } + + static __add(left: Money, right: Money): Money { + Money.expectSameCurrency(left, right); + + return left.with({ cents: left.cents + right.cents }); + } + + $add(right: Money): Money { + return Money.__add(this, right); + } + + static __subtract(left: Money, right: Money): Money { + Money.expectSameCurrency(left, right); + + return left.with({ cents: left.cents - right.cents }); + } + + $subtract(right: Money): Money { + return Money.__subtract(this, right); + } + + private static __multiplyMoneyBigInteger(left: Money, factor: bigint): Money { + return left.with({ cents: left.cents * factor }); + } + + private static __multiplyMoneyMoney(left: Money, right: Money): Money { + Money.expectSameCurrency(left, right); + + return left.with({ cents: left.cents * right.cents }); + } + + static __multiply(left: Money, factor: bigint): Money; + static __multiply(left: Money, right: Money): Money; + static __multiply(...args: unknown[]): Money { + if (args.length === 2 && args[0] instanceof Money && isBigInt(args[1])) { + return Money.__multiplyMoneyBigInteger(args[0] as Money, args[1] as bigint); + } + + if (args.length === 2 && args[0] instanceof Money && args[1] instanceof Money) { + return Money.__multiplyMoneyMoney(args[0] as Money, args[1] as Money); + } + + throw new Error("No matching overload for multiply"); + } + + $multiply(factor: bigint): Money; + $multiply(right: Money): Money; + $multiply(...args: unknown[]): Money { + if (args.length === 1 && isBigInt(args[0])) { + return Money.__multiplyMoneyBigInteger(this, args[0] as bigint); + } + + if (args.length === 1 && args[0] instanceof Money) { + return Money.__multiplyMoneyMoney(this, args[0] as Money); + } + + throw new Error("No matching overload for multiply"); + } + + private static __divideMoneyBigInteger(left: Money, divisor: bigint): Money { + return left.with({ cents: left.cents / divisor }); + } + + private static __divideMoneyMoney(left: Money, divisor: Money): Money { + Money.expectSameCurrency(left, divisor); + + return left.with({ cents: left.cents / divisor.cents }); + } + + static __divide(left: Money, divisor: bigint): Money; + static __divide(left: Money, divisor: Money): Money; + static __divide(...args: unknown[]): Money { + if (args.length === 2 && args[0] instanceof Money && isBigInt(args[1])) { + return Money.__divideMoneyBigInteger(args[0] as Money, args[1] as bigint); + } + + if (args.length === 2 && args[0] instanceof Money && args[1] instanceof Money) { + return Money.__divideMoneyMoney(args[0] as Money, args[1] as Money); + } + + throw new Error("No matching overload for divide"); + } + + $divide(divisor: bigint): Money; + $divide(divisor: Money): Money; + $divide(...args: unknown[]): Money { + if (args.length === 1 && isBigInt(args[0])) { + return Money.__divideMoneyBigInteger(this, args[0] as bigint); + } + + if (args.length === 1 && args[0] instanceof Money) { + return Money.__divideMoneyMoney(this, args[0] as Money); + } + + throw new Error("No matching overload for divide"); + } + + toDecimal(): Decimal { + return new Decimal(this.cents.toString()).div(new Decimal("100")); + } + + toString(): string { + return `${this.toDecimal()} ${this.currency.toString().toUpperCase()}`; + } + + static fromCents(cents: bigint, currency: Currency): Money { + return new Money(cents, currency); + } + + static fromValue(amount: Decimal, currency: Currency): Money { + return new Money(BigInt(amount.times(new Decimal("100")).round().toFixed(0)), currency); + } + + private static expectSameCurrency(left: Money, right: Money): void { + if (left.currency !== right.currency) { + throw new NoSameMoneyCurrencyException(left.currency, right.currency); + } + } + + equals(other: any): boolean { + return other instanceof Money && this.cents === other.cents && this.currency === other.currency; + } + + hashCode(): number { + const hc = new HashCode(); + hc.add(this.cents); + hc.add(this.currency); + + return hc.toHashCode(); + } + + with(overrides?: Partial): Money { + return new Money(overrides?.cents ?? this.cents, overrides?.currency ?? this.currency); + } +} diff --git a/js/sample-operator-overloading/src/no-same-money-currency-exception.ts b/js/sample-operator-overloading/src/no-same-money-currency-exception.ts new file mode 100644 index 0000000..d1ca47e --- /dev/null +++ b/js/sample-operator-overloading/src/no-same-money-currency-exception.ts @@ -0,0 +1,7 @@ +import type { Currency } from "./currency"; + +export class NoSameMoneyCurrencyException extends Error { + constructor(expected: Currency, provided: Currency) { + super(`Not same currency. Money has ${provided}, but expected ${expected}`); + } +} diff --git a/js/sample-operator-overloading/src/program.ts b/js/sample-operator-overloading/src/program.ts new file mode 100644 index 0000000..1a6a982 --- /dev/null +++ b/js/sample-operator-overloading/src/program.ts @@ -0,0 +1,8 @@ +import { Currency } from "./currency"; +import { Money } from "./money"; + +let poket = Money.fromCents(150n, Currency.Usd); + +poket = poket.$add(new Money(250n, Currency.Usd)); + +console.log(poket); diff --git a/js/sample-operator-overloading/test/money.test.ts b/js/sample-operator-overloading/test/money.test.ts new file mode 100644 index 0000000..2fc37f0 --- /dev/null +++ b/js/sample-operator-overloading/test/money.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, test } from "bun:test"; +import { Money } from "#/money"; +import { Currency } from "#/currency"; +import { NoSameMoneyCurrencyException } from "#/no-same-money-currency-exception"; + +describe("Money operator overloads", () => { + const usd100 = new Money(10000n, Currency.Usd); + const usd50 = new Money(5000n, Currency.Usd); + const eur100 = new Money(10000n, Currency.Eur); + + // ── Addition (+) ── + + test("$add sums two Money values with same currency", () => { + const result = usd100.$add(usd50); + expect(result.cents).toBe(15000n); + expect(result.currency).toBe(Currency.Usd); + }); + + test("static __add sums two Money values", () => { + const result = Money.__add(usd100, usd50); + expect(result.cents).toBe(15000n); + }); + + test("$add throws on different currencies", () => { + expect(() => usd100.$add(eur100)).toThrow(NoSameMoneyCurrencyException); + }); + + // ── Subtraction (-) ── + + test("$subtract subtracts two Money values", () => { + const result = usd100.$subtract(usd50); + expect(result.cents).toBe(5000n); + expect(result.currency).toBe(Currency.Usd); + }); + + test("$subtract throws on different currencies", () => { + expect(() => usd100.$subtract(eur100)).toThrow(NoSameMoneyCurrencyException); + }); + + // ── Multiplication (*) — overloaded: Money×bigint and Money×Money ── + + test("$multiply scales by a bigint factor", () => { + const result = usd100.$multiply(3n); + expect(result.cents).toBe(30000n); + expect(result.currency).toBe(Currency.Usd); + }); + + test("$multiply multiplies two Money values", () => { + const usd3 = new Money(300n, Currency.Usd); + const result = usd100.$multiply(usd3); + expect(result.cents).toBe(10000n * 300n); + expect(result.currency).toBe(Currency.Usd); + }); + + test("$multiply Money×Money throws on different currencies", () => { + expect(() => usd100.$multiply(eur100)).toThrow(NoSameMoneyCurrencyException); + }); + + // ── Division (/) — overloaded: Money÷bigint and Money÷Money ── + + test("$divide divides by a bigint divisor", () => { + const result = usd100.$divide(2n); + expect(result.cents).toBe(5000n); + expect(result.currency).toBe(Currency.Usd); + }); + + test("$divide divides two Money values", () => { + const usd2 = new Money(200n, Currency.Usd); + const result = usd100.$divide(usd2); + expect(result.cents).toBe(10000n / 200n); + expect(result.currency).toBe(Currency.Usd); + }); + + test("$divide Money÷Money throws on different currencies", () => { + expect(() => usd100.$divide(eur100)).toThrow(NoSameMoneyCurrencyException); + }); + + // ── Record helpers ── + + test("with() creates a copy with overrides", () => { + const changed = usd100.with({ cents: 999n }); + expect(changed.cents).toBe(999n); + expect(changed.currency).toBe(Currency.Usd); + }); + + test("equals() checks structural equality", () => { + const copy = new Money(10000n, Currency.Usd); + expect(usd100.equals(copy)).toBe(true); + expect(usd100.equals(usd50)).toBe(false); + expect(usd100.equals(eur100)).toBe(false); + }); +}); diff --git a/js/sample-operator-overloading/tsconfig.json b/js/sample-operator-overloading/tsconfig.json new file mode 100644 index 0000000..2839955 --- /dev/null +++ b/js/sample-operator-overloading/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + "rootDir": "./src", + "outDir": "./dist", + "declaration": true, + "composite": true, + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true, + + "paths": { + "#/*": ["./src/*"] + }, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false, + }, + "include": ["src/**/*"] +} diff --git a/js/sample-todo-service/package.json b/js/sample-todo-service/package.json index a20fda5..6e110db 100644 --- a/js/sample-todo-service/package.json +++ b/js/sample-todo-service/package.json @@ -9,6 +9,11 @@ "types": "./dist/*.d.ts", "import": "./dist/*.js", "default": "./src/*.ts" + }, + "#": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./src/index.ts" } }, "dependencies": { diff --git a/js/sample-todo-service/src/program.ts b/js/sample-todo-service/src/program.ts index 6902231..ca7f611 100644 --- a/js/sample-todo-service/src/program.ts +++ b/js/sample-todo-service/src/program.ts @@ -1,5 +1,5 @@ import { Hono } from "hono"; -import { TodoStore } from "#"; +import { TodoStore } from "./todos"; const app = new Hono(); const store = new TodoStore(); diff --git a/js/sample-todo-service/src/todos.ts b/js/sample-todo-service/src/todos.ts index 5f01f70..7a546ee 100644 --- a/js/sample-todo-service/src/todos.ts +++ b/js/sample-todo-service/src/todos.ts @@ -1,6 +1,4 @@ -import { Enumerable } from "metano-runtime"; -import { UUID } from "metano-runtime"; -import { listRemove } from "metano-runtime"; +import { Enumerable, UUID, listRemove } from "metano-runtime"; import { TodoItem, type Priority } from "sample-todo"; export interface StoredTodo { diff --git a/js/sample-todo/src/todo-list.ts b/js/sample-todo/src/todo-list.ts index a3306f5..3ace313 100644 --- a/js/sample-todo/src/todo-list.ts +++ b/js/sample-todo/src/todo-list.ts @@ -1,5 +1,4 @@ -import { isString } from "metano-runtime"; -import { Enumerable } from "metano-runtime"; +import { Enumerable, isString } from "metano-runtime"; import { Priority } from "./priority"; import { TodoItem } from "./todo-item"; diff --git a/samples/SampleOperatorOverloading/Currency.cs b/samples/SampleOperatorOverloading/Currency.cs new file mode 100644 index 0000000..983d11d --- /dev/null +++ b/samples/SampleOperatorOverloading/Currency.cs @@ -0,0 +1,8 @@ +namespace SampleOperatorOverloading; + +public enum Currency +{ + Brl, + Usd, + Eur, +} diff --git a/samples/SampleOperatorOverloading/Money.cs b/samples/SampleOperatorOverloading/Money.cs new file mode 100644 index 0000000..699e896 --- /dev/null +++ b/samples/SampleOperatorOverloading/Money.cs @@ -0,0 +1,71 @@ +using System.Numerics; + +namespace SampleOperatorOverloading; + +public readonly record struct Money(BigInteger Cents, Currency Currency) +{ + public decimal ToDecimal() => (decimal)Cents / 100m; + + public override string ToString() => $"{ToDecimal()} {Currency.ToString().ToUpper()}"; + + public static Money FromCents(BigInteger cents, Currency currency) => new(cents, currency); + + public static Money FromValue(decimal amount, Currency currency) => + new((BigInteger)Math.Round(amount * 100m), currency); + + private static void ExpectSameCurrency(Money left, Money right) + { + if (left.Currency != right.Currency) + throw new NoSameMoneyCurrencyException(left.Currency, right.Currency); + } + + public static Money operator +(Money left, Money right) + { + ExpectSameCurrency(left, right); + + return left with + { + Cents = left.Cents + right.Cents, + }; + } + + public static Money operator -(Money left, Money right) + { + ExpectSameCurrency(left, right); + + return left with + { + Cents = left.Cents - right.Cents, + }; + } + + public static Money operator *(Money left, BigInteger factor) + { + return left with { Cents = left.Cents * factor }; + } + + public static Money operator *(Money left, Money right) + { + ExpectSameCurrency(left, right); + + return left with + { + Cents = left.Cents * right.Cents, + }; + } + + public static Money operator /(Money left, BigInteger divisor) + { + return left with { Cents = left.Cents / divisor }; + } + + public static Money operator /(Money left, Money divisor) + { + ExpectSameCurrency(left, divisor); + + return left with + { + Cents = left.Cents / divisor.Cents, + }; + } +} diff --git a/samples/SampleOperatorOverloading/NoSameMoneyCurrencyException.cs b/samples/SampleOperatorOverloading/NoSameMoneyCurrencyException.cs new file mode 100644 index 0000000..7f663f3 --- /dev/null +++ b/samples/SampleOperatorOverloading/NoSameMoneyCurrencyException.cs @@ -0,0 +1,4 @@ +namespace SampleOperatorOverloading; + +public sealed class NoSameMoneyCurrencyException(Currency expected, Currency provided) + : Exception($"Not same currency. Money has {provided}, but expected {expected}"); diff --git a/samples/SampleOperatorOverloading/Program.cs b/samples/SampleOperatorOverloading/Program.cs new file mode 100644 index 0000000..0bd1c94 --- /dev/null +++ b/samples/SampleOperatorOverloading/Program.cs @@ -0,0 +1,10 @@ +using Metano.Annotations; +using SampleOperatorOverloading; + +[assembly: TranspileAssembly] + +var poket = Money.FromCents(150, Currency.Usd); + +poket += new Money(250, Currency.Usd); + +Console.WriteLine(poket); diff --git a/samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj b/samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj new file mode 100644 index 0000000..1dbfa8b --- /dev/null +++ b/samples/SampleOperatorOverloading/SampleOperatorOverloading.csproj @@ -0,0 +1,18 @@ + + + Exe + net10.0 + enable + enable + false + ../../js/sample-operator-overloading/src + true + ../../src/Metano.Compiler.TypeScript/Metano.Compiler.TypeScript.csproj + + + + + + + + diff --git a/spec/04-functional-requirements.md b/spec/04-functional-requirements.md index c2f4753..57edb10 100644 --- a/spec/04-functional-requirements.md +++ b/spec/04-functional-requirements.md @@ -80,7 +80,16 @@ requirement uses a stable identifier in the format `FR-XXX`. - **FR-029** The system shall generate barrels/index files consistent with namespaces and product output conventions. - **FR-030** The system shall generate or update the target package's - `package.json` with dependencies required by the output. + `package.json` with correct `imports`, `exports`, and `dependencies` fields. + When the transpiler output directory is a subdirectory of the package's + TypeScript source root (e.g., `src/domain/` inside a package whose build + tool compiles from `src/`), the generated `imports` and `exports` paths + shall include the correct prefix so that dist paths mirror the source tree + structure. The system shall accept an explicit source-root parameter + (`--src-root` / `MetanoSrcRoot`, defaulting to the first path segment of + the output directory relative to the package root) to resolve ambiguity + when the relationship between source and dist directories cannot be + inferred. - **FR-031** The system shall reflect cross-package dependencies between transpiled assemblies through correct npm-based imports. @@ -100,6 +109,10 @@ requirement uses a stable identifier in the format `FR-XXX`. entry point. - **FR-037** The system shall operate on real C# projects, using compilation and semantic analysis as the basis for transformation. +- **FR-046** The product shall support consumption of `Metano` and + `Metano.Build` as build-only dependencies in .NET projects, so adopting the + transpiler does not unnecessarily contribute runtime or transitive package + surface to downstream application outputs. ## 9. Diagnostic Requirements @@ -126,3 +139,6 @@ requirement uses a stable identifier in the format `FR-XXX`. supported language surface. - **FR-045** The system shall detect cyclic local-package import chains between generated TypeScript files and report them through diagnostics. +- **FR-047** The system shall derive cross-package TypeScript import and export + subpaths from C# namespace structure and declared package identity, not from + incidental file placement details in the generated output tree. diff --git a/spec/08-feature-support-matrix.md b/spec/08-feature-support-matrix.md index 6012092..565fbf1 100644 --- a/spec/08-feature-support-matrix.md +++ b/spec/08-feature-support-matrix.md @@ -59,6 +59,9 @@ Statuses are intentionally high-level: | --- | --- | --- | --- | | Output | Namespace-based imports and barrels | Implemented | Guided by ADR-0006 and ADR-0007. | | Packaging | `[EmitPackage]` cross-package support | Implemented | Includes `package.json` dependency propagation. | +| Packaging | `Metano` and `Metano.Build` as build-only consumer dependencies | Planned/Partial | Intended to avoid unnecessary runtime/transitive contribution in consuming .NET projects. | +| Packaging | Subdirectory-aware `package.json` imports/exports (`--src-root`) | Planned | When output targets a subdirectory of the source tree, dist paths and export subpaths must include the correct prefix (FR-030). | +| Cross-package | Import/export subpaths derived from namespace instead of file layout | Planned/Correction | Spec-mandated behavior; current implementation requires correction. | | Serialization | `JsonSerializerContext` transpilation | Implemented | JSON names resolved at transpile time. | | Validation | Generated type guards | Implemented | Via `[GenerateGuard]`. | | Diagnostics | Stable `MS0001`-`MS0008` catalog | Implemented | See diagnostic catalog. | diff --git a/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs b/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs index c6521b1..f48c54c 100644 --- a/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs +++ b/src/Metano.Compiler.TypeScript/PackageJsonWriter.cs @@ -112,8 +112,14 @@ public static IReadOnlyList UpdateOrCreate( var exports = isExecutable ? null : BuildExports(files, distDirRelativeToPackageRoot, outputPrefix); - var rootExportKey = outputPrefix.Length > 0 ? $"./{outputPrefix}" : "."; - var hasRootIndex = exports?.ContainsKey(rootExportKey) ?? false; + // Check for a root barrel from the file list — executables skip exports + // but may still need the "#" import alias for internal barrel imports. + // When outputPrefix is set, the root barrel is at {prefix}/index.ts. + var rootIndexName = outputPrefix.Length > 0 ? $"{outputPrefix}/index.ts" : "index.ts"; + var hasRootIndex = files.Any(f => + NormalizePath(f.FileName).Equals(rootIndexName, StringComparison.Ordinal) + || NormalizePath(f.FileName).Equals("index.ts", StringComparison.Ordinal) + ); var imports = BuildImports( srcRelative, distDirRelativeToPackageRoot, diff --git a/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs b/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs index dba1782..5c295d7 100644 --- a/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs +++ b/src/Metano.Compiler.TypeScript/Transformation/ExpressionTransformer.cs @@ -146,7 +146,7 @@ public TsExpression TransformExpression(ExpressionSyntax expression) TransformExpression(cond.WhenFalse) ), - CastExpressionSyntax cast => TransformExpression(cast.Expression), + CastExpressionSyntax cast => TransformCast(cast), WithExpressionSyntax withExpr => ObjectCreation.TransformWithExpression(withExpr), @@ -197,6 +197,78 @@ public TsExpression TransformExpression(ExpressionSyntax expression) }; } + /// + /// Lowers a C# explicit cast expression. Most casts are erased (JS types are the + /// same at runtime), but numeric type conversions that change representation need + /// explicit code: + /// + /// (decimal)bigIntVarnew Decimal(bigIntVar.toString()) + /// (BigInteger)decimalExprBigInt(expr.toFixed(0)) + /// (int)decimalVar / (long)decimalVardecimalVar.toNumber() + /// + /// + private TsExpression TransformCast(CastExpressionSyntax cast) + { + var inner = TransformExpression(cast.Expression); + var sourceInfo = Model.GetTypeInfo(cast.Expression); + var sourceType = sourceInfo.Type; + var targetType = Model.GetTypeInfo(cast).Type; + + if (sourceType is null || targetType is null) + return inner; + + var sourceFull = sourceType.ToDisplayString(); + var targetFull = targetType.ToDisplayString(); + + // BigInteger → decimal: new Decimal(value.toString()) + if (sourceFull == "System.Numerics.BigInteger" && targetFull == "decimal") + { + return new TsNewExpression( + new TsIdentifier("Decimal"), + [new TsCallExpression(new TsPropertyAccess(inner, "toString"), [])] + ); + } + + // decimal → BigInteger: BigInt(value.toFixed(0)) + if (sourceFull == "decimal" && targetFull == "System.Numerics.BigInteger") + { + return new TsCallExpression( + new TsIdentifier("BigInt"), + [new TsCallExpression(new TsPropertyAccess(inner, "toFixed"), [new TsLiteral("0")])] + ); + } + + // decimal → int/long/short/byte (any integer): value.toNumber() + if ( + sourceFull == "decimal" + && targetType.SpecialType + is SpecialType.System_Int32 + or SpecialType.System_Int64 + or SpecialType.System_Int16 + or SpecialType.System_Byte + or SpecialType.System_UInt32 + or SpecialType.System_UInt64 + ) + { + return new TsCallExpression(new TsPropertyAccess(inner, "toNumber"), []); + } + + // int/long → BigInteger: BigInt(value) + if ( + targetFull == "System.Numerics.BigInteger" + && sourceType.SpecialType + is SpecialType.System_Int32 + or SpecialType.System_Int64 + or SpecialType.System_Decimal + ) + { + return new TsCallExpression(new TsIdentifier("BigInt"), [inner]); + } + + // Default: erase the cast (same-width numeric, reference casts, etc.) + return inner; + } + /// /// Lowers a C# element access expression. Arrays / lists keep the bracket form /// (arr[i] stays valid in JS). Dictionary-family receivers — which lower diff --git a/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs b/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs index fab7434..22716ee 100644 --- a/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs +++ b/src/Metano.Compiler.TypeScript/Transformation/ImportCollector.cs @@ -68,7 +68,12 @@ List statements referencedTypes.Remove($"is{tsTypeName}"); // own guard — don't import var imports = new List(); + // For compiler-synthesized types (top-level statements), the namespace is empty. + // Use the project's root namespace so same-namespace imports produce relative paths + // instead of barrel aliases. var currentNs = PathNaming.GetNamespace(currentType); + if (currentNs.Length == 0 && _pathNaming.RootNamespace.Length > 0) + currentNs = _pathNaming.RootNamespace; // The current file's "key" — file name + namespace — used to elide self-imports // for types co-located via [EmitInFile]. Without this, a multi-type file would // try to import its sibling types from their individual paths (which don't @@ -388,7 +393,90 @@ bclEntry.ExportedName is not null ); } - return imports; + return MergeImportsByPath(imports); + } + + /// + /// Consolidates multiple entries that share the same path into + /// a single import line. Default imports are kept separate. Type-only qualification is + /// preserved: a name is type-only only if ALL contributing imports marked it as such. + /// + private static IReadOnlyList MergeImportsByPath(List imports) + { + var merged = new List(); + var grouped = imports.GroupBy(i => (i.From, i.IsDefault)).ToList(); + + foreach (var group in grouped) + { + var items = group.ToList(); + if (items.Count == 1) + { + merged.Add(items[0]); + continue; + } + + // Default imports can't be merged (only one name per default import). + if (group.Key.IsDefault) + { + merged.AddRange(items); + continue; + } + + // Merge all names, preserving type-only where every contributor agrees. + var allNames = new List(); + var typeOnlyNames = new HashSet(StringComparer.Ordinal); + var allTypeOnly = true; + + foreach (var item in items) + { + foreach (var name in item.Names) + { + if (allNames.Contains(name)) + continue; + allNames.Add(name); + + var isTypeOnly = + item.TypeOnly + || (item.TypeOnlyNames is not null && item.TypeOnlyNames.Contains(name)); + if (isTypeOnly) + typeOnlyNames.Add(name); + } + + if (!item.TypeOnly) + allTypeOnly = false; + } + + // Names that appear as value in ANY import are not type-only. + if (!allTypeOnly) + { + foreach (var item in items) + { + if (item.TypeOnly) + continue; + foreach (var name in item.Names) + { + if (item.TypeOnlyNames is null || !item.TypeOnlyNames.Contains(name)) + typeOnlyNames.Remove(name); + } + } + } + + var finalAllTypeOnly = allTypeOnly || typeOnlyNames.Count == allNames.Count; + SortValuesFirst(allNames, typeOnlyNames); + + merged.Add( + new TsImport( + allNames.ToArray(), + group.Key.From, + TypeOnly: finalAllTypeOnly, + TypeOnlyNames: !finalAllTypeOnly && typeOnlyNames.Count > 0 + ? typeOnlyNames + : null + ) + ); + } + + return merged; } /// diff --git a/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs index 9cdafc4..7ac345e 100644 --- a/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs +++ b/src/Metano.Compiler.TypeScript/Transformation/InvocationHandler.cs @@ -46,6 +46,13 @@ public TsExpression Transform(InvocationExpressionSyntax invocation) return ExpandEmit(emit, emitArgs); } + // Math.Round/Floor/Ceiling with a decimal argument → decimal.js instance method + // (e.g., `Math.Round(amount)` → `amount.round()`). The standard Math.round is + // for number operands; decimal.js has its own round/floor/ceil. + var mathDecimalResult = TryRewriteMathDecimal(methodSymbol, invocation); + if (mathDecimalResult is not null) + return mathDecimalResult; + var mapped = BclMapper.TryMapMethod(methodSymbol, invocation, _parent); if (mapped is not null) return mapped; @@ -84,6 +91,46 @@ public TsExpression Transform(InvocationExpressionSyntax invocation) return new TsCallExpression(callee, args); } + /// + /// Rewrites Math.Round, Math.Floor, Math.Ceiling, and Math.Abs + /// calls when the first argument is System.Decimal. The JS Math.round only + /// works on number; decimal.js instances have their own instance methods. + /// Only rewrites single-argument overloads — multi-argument overloads (e.g., + /// Math.Round(decimal, int)) are left for the standard BCL mapping. + /// + private TsExpression? TryRewriteMathDecimal( + IMethodSymbol method, + InvocationExpressionSyntax invocation + ) + { + if ( + method.ContainingType?.ToDisplayString() != "System.Math" + || invocation.ArgumentList.Arguments.Count != 1 + ) + return null; + + var firstParamType = _parent + .Model.GetTypeInfo(invocation.ArgumentList.Arguments[0].Expression) + .Type; + if (firstParamType?.SpecialType != SpecialType.System_Decimal) + return null; + + var jsMethodName = method.Name switch + { + "Round" => "round", + "Floor" => "floor", + "Ceiling" => "ceil", + "Abs" => "abs", + _ => null, + }; + + if (jsMethodName is null) + return null; + + var arg = _parent.TransformExpression(invocation.ArgumentList.Arguments[0].Expression); + return new TsCallExpression(new TsPropertyAccess(arg, jsMethodName), []); + } + /// /// Expands an [Emit] template, replacing $0, $1, … with the textual /// form of each transformed argument. Delegates to diff --git a/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs index 9b9456e..4aec521 100644 --- a/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs +++ b/src/Metano.Compiler.TypeScript/Transformation/LiteralHandler.cs @@ -40,19 +40,28 @@ public static TsExpression Transform(LiteralExpressionSyntax lit, SemanticModel? private static TsExpression TransformNumeric(LiteralExpressionSyntax lit, SemanticModel? model) { - // For decimal literals (1.5m), wrap the literal in `new Decimal("…")` so that - // decimal.js preserves the exact value. We pass the value as a string because - // converting through a JS number first would already lose precision. - if ( - model is not null - && model.GetTypeInfo(lit).Type?.SpecialType == SpecialType.System_Decimal - ) + if (model is not null) { - return new TsNewExpression( - new TsIdentifier("Decimal"), - [new TsStringLiteral(lit.Token.ValueText)] - ); + var info = model.GetTypeInfo(lit); + // ConvertedType captures implicit conversions (e.g., int → BigInteger). + var effectiveType = info.ConvertedType ?? info.Type; + + // decimal literals (1.5m) → new Decimal("…") for decimal.js + if (effectiveType?.SpecialType == SpecialType.System_Decimal) + { + return new TsNewExpression( + new TsIdentifier("Decimal"), + [new TsStringLiteral(lit.Token.ValueText)] + ); + } + + // BigInteger targets → bigint literal with n suffix (150 → 150n) + if (effectiveType?.ToDisplayString() == "System.Numerics.BigInteger") + { + return new TsLiteral($"{lit.Token.ValueText}n"); + } } + return new TsLiteral(lit.Token.ValueText); } } diff --git a/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs index 9d94e4f..8f00566 100644 --- a/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs +++ b/src/Metano.Compiler.TypeScript/Transformation/OperatorHandler.cs @@ -108,6 +108,11 @@ public TsExpression TransformAssignment(AssignmentExpressionSyntax assign) } } + // User-defined compound assignment: x += y → x = x.$add(y) when the compound + // operator resolves to a user-defined operator method. + if (TryLowerCompoundOperatorAssignment(assign) is { } lowered) + return lowered; + return new TsBinaryExpression( _parent.TransformExpression(assign.Left), MapAssignmentOperator(assign.OperatorToken.Text), @@ -184,6 +189,49 @@ private static string MapBinaryOperator(string op) => _ => op, }; + /// + /// Lowers a compound assignment (x += y, x -= y, x *= y, etc.) + /// when the underlying operator is a user-defined operator method. The C# semantic + /// model resolves compound assignments to their operator method — if it's user-defined, + /// we rewrite to x = x.$add(y). + /// + private TsExpression? TryLowerCompoundOperatorAssignment(AssignmentExpressionSyntax assign) + { + // The semantic model exposes the operator method for compound assignments + // via GetSymbolInfo on the assignment expression itself. + var symbolInfo = _parent.Model.GetSymbolInfo(assign); + if ( + symbolInfo.Symbol is not IMethodSymbol { MethodKind: MethodKind.UserDefinedOperator } op + ) + return null; + + var opToken = assign.OperatorToken.Text.TrimEnd('='); // "+=" → "+" + var opName = MapCompoundOperatorToken(opToken); + if (opName is null) + return null; + + var left = _parent.TransformExpression(assign.Left); + var right = _parent.TransformExpression(assign.Right); + + // x = x.$add(y) — reuse `left` to avoid evaluating the LHS twice. + return new TsBinaryExpression( + left, + "=", + new TsCallExpression(new TsPropertyAccess(left, $"${opName}"), [right]) + ); + } + + private static string? MapCompoundOperatorToken(string token) => + token switch + { + "+" => "add", + "-" => "subtract", + "*" => "multiply", + "/" => "divide", + "%" => "modulo", + _ => null, + }; + private static string MapAssignmentOperator(string op) => op switch { diff --git a/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs b/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs index 8c23911..37901ec 100644 --- a/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs +++ b/src/Metano.Compiler.TypeScript/Transformation/RecordClassTransformer.cs @@ -149,6 +149,7 @@ type.BaseType is not null // Fields, properties, operators var ordinaryMethods = new List(); + var operatorMethods = new List<(string Name, IMethodSymbol Method)>(); foreach (var member in type.GetMembers()) { @@ -179,8 +180,21 @@ is not (Accessibility.Internal or Accessibility.NotApplicable) break; case IMethodSymbol method when method.MethodKind == MethodKind.UserDefinedOperator: - classMembers.AddRange(TransformClassOperator(type, method)); + { + var opSyntax = + method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() + as OperatorDeclarationSyntax; + var opName = + SymbolHelper.GetNameOverride(method) + ?? ( + opSyntax is not null + ? MapOperatorTokenToName(opSyntax.OperatorToken.Text) + : null + ); + if (opName is not null) + operatorMethods.Add((opName, method)); break; + } case IEventSymbol evt: classMembers.AddRange(TransformEvent(evt)); @@ -188,6 +202,24 @@ is not (Accessibility.Internal or Accessibility.NotApplicable) } } + // Process operators — group by derived name, dispatch when overloaded + foreach (var opGroup in operatorMethods.GroupBy(o => o.Name)) + { + var ops = opGroup.ToList(); + if (ops.Count == 1) + { + classMembers.AddRange(TransformClassOperator(type, ops[0].Method, ops[0].Name)); + } + else + { + // Multiple overloads for the same operator token — use the dispatcher. + // Wrap each operator as a named method so the existing dispatcher can handle it. + classMembers.AddRange( + BuildOperatorDispatcher(type, ops[0].Name, ops.Select(o => o.Method).ToList()) + ); + } + } + // Process ordinary methods — detect overloads (same name, different signatures) var methodGroups = ordinaryMethods.GroupBy(m => m.Name).ToList(); @@ -780,13 +812,10 @@ TsAccessibility accessibility private IReadOnlyList TransformClassOperator( INamedTypeSymbol containingType, - IMethodSymbol method + IMethodSymbol method, + string operatorName ) { - var nameOverride = SymbolHelper.GetNameOverride(method); - if (nameOverride is null) - return []; - var syntax = method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() as OperatorDeclarationSyntax; @@ -806,7 +835,7 @@ IMethodSymbol method var exprTransformer = _context.CreateExpressionTransformer(semanticModel); var body = exprTransformer.TransformBody(syntax.Body, syntax.ExpressionBody); - var staticName = $"__{nameOverride}"; + var staticName = $"__{operatorName}"; var isUnary = method.Parameters.Length == 1; var results = new List(); @@ -826,7 +855,7 @@ IMethodSymbol method [new TsIdentifier("this")] ) ); - results.Add(new TsMethodMember($"${nameOverride}", [], returnType, [helperBody])); + results.Add(new TsMethodMember($"${operatorName}", [], returnType, [helperBody])); } else { @@ -842,13 +871,269 @@ [new TsIdentifier("this")] ) ); results.Add( - new TsMethodMember($"${nameOverride}", [rightParam], returnType, [helperBody]) + new TsMethodMember($"${operatorName}", [rightParam], returnType, [helperBody]) ); } return results; } + /// + /// Maps a C# operator token to a conventional TypeScript method name. + /// Returns null for unsupported operators. + /// + private static string? MapOperatorTokenToName(string token) => + token switch + { + "+" => "add", + "-" => "subtract", + "*" => "multiply", + "/" => "divide", + "%" => "modulo", + "==" => "equals", + "!=" => "notEquals", + "<" => "lessThan", + ">" => "greaterThan", + "<=" => "lessThanOrEqual", + ">=" => "greaterThanOrEqual", + "!" => "not", + "~" => "bitwiseNot", + "&" => "bitwiseAnd", + "|" => "bitwiseOr", + "^" => "xor", + "<<" => "shiftLeft", + ">>" => "shiftRight", + _ => null, + }; + + /// + /// Generates overload-dispatching plumbing for operators that share the same derived + /// name (e.g., two operator * overloads with different parameter types). + /// Produces a static dispatcher (__multiply(...args)) plus fast-path specializations, + /// and a single instance helper ($multiply) that delegates to the static dispatcher. + /// + private IReadOnlyList BuildOperatorDispatcher( + INamedTypeSymbol containingType, + string operatorName, + List overloads + ) + { + var sorted = overloads.OrderByDescending(m => m.Parameters.Length).ToList(); + var staticName = $"__{operatorName}"; + var returnType = TypeMapper.Map(sorted[0].ReturnType); + var className = TypeTransformer.GetTsTypeName(containingType); + var members = new List(); + + // Generate overload signatures for the static dispatcher + var overloadSigs = sorted + .Select(m => + { + var @params = m + .Parameters.Select(p => new TsParameter( + TypeScriptNaming.ToCamelCase(p.Name), + TypeMapper.Map(p.Type) + )) + .ToList(); + return new TsMethodOverload(@params, TypeMapper.Map(m.ReturnType)); + }) + .ToList(); + + // Generate fast-path names using same strategy as OverloadDispatcherBuilder + var fastPathNames = sorted + .Select(m => + staticName + + string.Concat(m.Parameters.Select(p => Capitalize(SimpleTypeName(p.Type)))) + ) + .ToList(); + + // Generate fast-path methods (private, one per overload) + for (var i = 0; i < sorted.Count; i++) + { + var method = sorted[i]; + var syntax = + method.DeclaringSyntaxReferences.FirstOrDefault()?.GetSyntax() + as OperatorDeclarationSyntax; + if (syntax is null) + continue; + + var parameters = method + .Parameters.Select(p => new TsParameter( + TypeScriptNaming.ToCamelCase(p.Name), + TypeMapper.Map(p.Type) + )) + .ToList(); + var semanticModel = _context.Compilation.GetSemanticModel(syntax.SyntaxTree); + var exprTransformer = _context.CreateExpressionTransformer(semanticModel); + var body = exprTransformer.TransformBody(syntax.Body, syntax.ExpressionBody); + + members.Add( + new TsMethodMember( + fastPathNames[i], + parameters, + TypeMapper.Map(method.ReturnType), + body, + Static: true, + Accessibility: TsAccessibility.Private + ) + ); + } + + // Generate dispatcher body that delegates to fast-paths + var dispatcherBody = new List(); + for (var i = 0; i < sorted.Count; i++) + { + var method = sorted[i]; + var paramCount = method.Parameters.Length; + + TsExpression condition = new TsBinaryExpression( + new TsPropertyAccess(new TsIdentifier("args"), "length"), + "===", + new TsLiteral(paramCount.ToString()) + ); + + for (var j = 0; j < paramCount; j++) + { + var check = TypeCheckGenerator.GenerateForParam( + method.Parameters[j].Type, + j, + _context.AssemblyWideTranspile, + _context.CurrentAssembly + ); + condition = new TsBinaryExpression(condition, "&&", check); + } + + var callArgs = new List(); + for (var j = 0; j < paramCount; j++) + { + var paramType = TypeMapper.Map(method.Parameters[j].Type); + callArgs.Add(new TsCastExpression(new TsIdentifier($"args[{j}]"), paramType)); + } + + var delegateCall = new TsCallExpression( + new TsPropertyAccess(new TsIdentifier(className), fastPathNames[i]), + callArgs + ); + + dispatcherBody.Add(new TsIfStatement(condition, [new TsReturnStatement(delegateCall)])); + } + + dispatcherBody.Add( + new TsThrowStatement( + new TsNewExpression( + new TsIdentifier("Error"), + [new TsStringLiteral($"No matching overload for {operatorName}")] + ) + ) + ); + + // Static dispatcher: static __multiply(...args: unknown[]): ReturnType + members.Add( + new TsMethodMember( + staticName, + [new TsParameter("...args", new TsNamedType("unknown[]"))], + returnType, + dispatcherBody, + Static: true, + Overloads: overloadSigs + ) + ); + + // Instance helper: $multiply(...args: unknown[]): ReturnType + // Dispatches to static fast-paths with `this` as first argument. + var instanceDispatchBody = new List(); + for (var i = 0; i < sorted.Count; i++) + { + var method = sorted[i]; + // Instance args start at index 1 (skip `this`/`left`) + var instanceArgCount = method.Parameters.Length - 1; + + TsExpression instCondition = new TsBinaryExpression( + new TsPropertyAccess(new TsIdentifier("args"), "length"), + "===", + new TsLiteral(instanceArgCount.ToString()) + ); + + for (var j = 0; j < instanceArgCount; j++) + { + var check = TypeCheckGenerator.GenerateForParam( + method.Parameters[j + 1].Type, // skip first param (self) + j, + _context.AssemblyWideTranspile, + _context.CurrentAssembly + ); + instCondition = new TsBinaryExpression(instCondition, "&&", check); + } + + var instCallArgs = new List { new TsIdentifier("this") }; + for (var j = 0; j < instanceArgCount; j++) + { + var paramType = TypeMapper.Map(method.Parameters[j + 1].Type); + instCallArgs.Add(new TsCastExpression(new TsIdentifier($"args[{j}]"), paramType)); + } + + var instCall = new TsCallExpression( + new TsPropertyAccess(new TsIdentifier(className), fastPathNames[i]), + instCallArgs + ); + + instanceDispatchBody.Add( + new TsIfStatement(instCondition, [new TsReturnStatement(instCall)]) + ); + } + + instanceDispatchBody.Add( + new TsThrowStatement( + new TsNewExpression( + new TsIdentifier("Error"), + [new TsStringLiteral($"No matching overload for {operatorName}")] + ) + ) + ); + + // Instance overload signatures (skip first param — it becomes `this`) + var instanceOverloads = sorted + .Select(m => + { + var @params = m + .Parameters.Skip(1) + .Select(p => new TsParameter( + TypeScriptNaming.ToCamelCase(p.Name), + TypeMapper.Map(p.Type) + )) + .ToList(); + return new TsMethodOverload(@params, TypeMapper.Map(m.ReturnType)); + }) + .ToList(); + + members.Add( + new TsMethodMember( + $"${operatorName}", + [new TsParameter("...args", new TsNamedType("unknown[]"))], + returnType, + instanceDispatchBody, + Overloads: instanceOverloads + ) + ); + + return members; + } + + private static string Capitalize(string s) => + string.IsNullOrEmpty(s) ? s : char.ToUpperInvariant(s[0]) + s[1..]; + + private static string SimpleTypeName(ITypeSymbol type) => + type.SpecialType switch + { + SpecialType.System_Int32 => "Int", + SpecialType.System_Int64 => "Long", + SpecialType.System_String => "String", + SpecialType.System_Boolean => "Bool", + SpecialType.System_Double => "Double", + SpecialType.System_Single => "Float", + SpecialType.System_Decimal => "Decimal", + _ => type.Name, + }; + // ─── Captured primary-ctor params (DI-style) ────────────── /// diff --git a/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs b/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs index 3a84a7d..7c9efe9 100644 --- a/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs +++ b/src/Metano.Compiler.TypeScript/Transformation/StatementHandler.cs @@ -220,8 +220,15 @@ private bool IsLocalMutated(VariableDeclaratorSyntax variable) // Walking the entire enclosing block is sufficient because C# locals can't // escape it, and we re-resolve symbols through the SemanticModel so shadowing // by an inner scope (different ILocalSymbol) is naturally excluded. + // + // For top-level statements (C# 9+), each statement lives in its own + // GlobalStatementSyntax which inherits from MemberDeclarationSyntax. Walking + // only that node would miss mutations from sibling statements — use the + // CompilationUnit as scope so all top-level statements are visible. SyntaxNode? scope = variable.FirstAncestorOrSelf(); - scope ??= variable.FirstAncestorOrSelf(); + scope ??= variable.FirstAncestorOrSelf() is not null + ? variable.FirstAncestorOrSelf() + : variable.FirstAncestorOrSelf(); if (scope is null) return true; diff --git a/tests/Metano.Tests/OperatorTranspileTests.cs b/tests/Metano.Tests/OperatorTranspileTests.cs index b283f47..8386bf4 100644 --- a/tests/Metano.Tests/OperatorTranspileTests.cs +++ b/tests/Metano.Tests/OperatorTranspileTests.cs @@ -66,7 +66,7 @@ public readonly record struct Num(int Value) } [Test] - public async Task Operator_WithoutNameAttribute_IsSkipped() + public async Task Operator_WithoutNameAttribute_AutoDerivesName() { var result = TranspileHelper.Transpile( """ @@ -79,7 +79,73 @@ public readonly record struct Num(int Value) ); var output = result["num.ts"]; - await Assert.That(output).DoesNotContain("__"); - await Assert.That(output).DoesNotContain("$"); + // Auto-derived: + → "add" + await Assert.That(output).Contains("static __add("); + await Assert.That(output).Contains("$add(b: Num): Num"); + } + + [Test] + public async Task Operator_NameAttributeOverridesAutoName() + { + var result = TranspileHelper.Transpile( + """ + [Transpile] + public readonly record struct Num(int Value) + { + [Name("plus")] + public static Num operator +(Num a, Num b) => new(a.Value + b.Value); + } + """ + ); + + var output = result["num.ts"]; + // [Name("plus")] overrides the default "add" + await Assert.That(output).Contains("static __plus("); + await Assert.That(output).Contains("$plus(b: Num): Num"); + } + + [Test] + public async Task Operator_OnPlainClass_AutoDerivesName() + { + var result = TranspileHelper.Transpile( + """ + [Transpile] + public class Money + { + public decimal Amount { get; } + public Money(decimal amount) { Amount = amount; } + + public static Money operator +(Money a, Money b) => + new(a.Amount + b.Amount); + + public static Money operator -(Money a, Money b) => + new(a.Amount - b.Amount); + } + """ + ); + + var output = result["money.ts"]; + await Assert.That(output).Contains("static __add("); + await Assert.That(output).Contains("$add(b: Money): Money"); + await Assert.That(output).Contains("static __subtract("); + await Assert.That(output).Contains("$subtract(b: Money): Money"); + } + + [Test] + public async Task UnaryOperator_WithoutNameAttribute_AutoDerivesName() + { + var result = TranspileHelper.Transpile( + """ + [Transpile] + public readonly record struct Vec2(int X, int Y) + { + public static Vec2 operator -(Vec2 v) => new(-v.X, -v.Y); + } + """ + ); + + var output = result["vec2.ts"]; + await Assert.That(output).Contains("static __subtract("); + await Assert.That(output).Contains("$subtract(): Vec2"); } } diff --git a/tests/Metano.Tests/TypeMappingTranspileTests.cs b/tests/Metano.Tests/TypeMappingTranspileTests.cs index 004009c..a8ddfaf 100644 --- a/tests/Metano.Tests/TypeMappingTranspileTests.cs +++ b/tests/Metano.Tests/TypeMappingTranspileTests.cs @@ -105,7 +105,7 @@ public record Entity(Guid Id, string Name); var output = result["entity.ts"]; // Guid → branded UUID type from metano-runtime (not plain string). await Assert.That(output).Contains("id: UUID"); - await Assert.That(output).Contains("import { UUID } from \"metano-runtime\""); + await Assert.That(output).Matches("import \\{[^}]*UUID[^}]*\\} from \"metano-runtime\""); } [Test] @@ -123,7 +123,7 @@ public static class IdFactory var output = result["id-factory.ts"]; await Assert.That(output).Contains("UUID.newUuid()"); - await Assert.That(output).Contains("import { UUID } from \"metano-runtime\""); + await Assert.That(output).Matches("import \\{[^}]*UUID[^}]*\\} from \"metano-runtime\""); } [Test] @@ -159,7 +159,7 @@ public static class IdFactory var output = result["id-factory.ts"]; await Assert.That(output).Contains("UUID.empty"); - await Assert.That(output).Contains("import { UUID } from \"metano-runtime\""); + await Assert.That(output).Matches("import \\{[^}]*UUID[^}]*\\} from \"metano-runtime\""); } // ─── Dictionary → Map ───────────────────────────────────