diff --git a/docs/design.md b/docs/design.md index e7a5c62..7806495 100644 --- a/docs/design.md +++ b/docs/design.md @@ -67,9 +67,9 @@ All four of TypeScript's `?` uses (`?.`, `??`, `?:`, `? :`) are removed. `?` now | `?` operator | `fetchUser(id)?` | early return on Err/None | | Branded types | `type UserId = Brand` | `string` at runtime | | Opaque types | `opaque type HashedPw = string` | `string`, but only the defining module can create/read | -| Tagged unions | `type Route = Home \| Profile(id: string)` | discriminated union | +| Tagged unions | `type Route { \| Home \| Profile { id: string } }` | discriminated union | | String literal unions | `type Method = "GET" \| "POST" \| "PUT"` | `"GET" \| "POST" \| "PUT"` (pass-through for npm interop) | -| Nested unions | `type ApiError = Network(NetworkError) \| NotFound` | nested discriminated union (compiler generates tags) | +| Nested unions | `type ApiError { \| Network { NetworkError } \| NotFound }` | nested discriminated union (compiler generates tags) | | Multi-depth match | `Network(Timeout(ms)) -> ...` | nested if/else with tag checks | | Type constructors | `User(name: "Ryan", email: e)` | `{ name: "Ryan", email: e }` (compiler adds tags for unions) | | Record spread | `User(..user, name: "New")` | `{ ...user, name: "New" }` | @@ -383,7 +383,7 @@ The return type of `collect { ... }` is `Result>` where: ### Option - No Null, No Undefined ```floe -type User = { +type User { name: string // always present nickname: Option // might not exist avatar: Option // might not exist @@ -411,20 +411,20 @@ const avatar = user.nickname |> Option.flatMap(fn(n) findAvatar(n)) `type` does everything. No `|` = record. Has `|` = union. Unions nest infinitely. ```floe -// Record type (no |) -type User = { +// Record type +type User { id: UserId name: string email: Email } // Record type composition with spread -type BaseProps = { +type BaseProps { className: string, disabled: boolean, } -type ButtonProps = { +type ButtonProps { ...BaseProps, onClick: fn() -> (), label: string, @@ -432,65 +432,74 @@ type ButtonProps = { // ButtonProps has: className, disabled, onClick, label // Multiple spreads -type A = { x: number } -type B = { y: string } -type C = { ...A, ...B, z: boolean } +type A { x: number } +type B { y: string } +type C { ...A, ...B, z: boolean } // C has: x, y, z // Simple union type (has |) -type Route = +type Route { | Home - | Profile(id: string) - | Settings(tab: string) + | Profile { id: string } + | Settings { tab: string } | NotFound +} // String literal union (for npm interop) type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" // Union types can contain other union types — nest as deep as you want -type NetworkError = - | Timeout(ms: number) - | DnsFailure(host: string) - | ConnectionRefused(port: number) +type NetworkError { + | Timeout { ms: number } + | DnsFailure { host: string } + | ConnectionRefused { port: number } +} -type ValidationError = - | Required(field: string) - | InvalidFormat(field: string, expected: string) - | TooLong(field: string, max: number) +type ValidationError { + | Required { field: string } + | InvalidFormat { field: string, expected: string } + | TooLong { field: string, max: number } +} -type AuthError = +type AuthError { | InvalidCredentials - | TokenExpired(expiredAt: Date) - | InsufficientRole(required: Role, actual: Role) + | TokenExpired { expiredAt: Date } + | InsufficientRole { required: Role, actual: Role } +} // Parent union containing sub-unions -type ApiError = - | Network(NetworkError) - | Validation(ValidationError) - | Auth(AuthError) +type ApiError { + | Network { NetworkError } + | Validation { ValidationError } + | Auth { AuthError } | NotFound - | ServerError(status: number, body: string) + | ServerError { status: number, body: string } +} // Go deeper — a full app error hierarchy -type HttpError = - | Network(NetworkError) - | Status(code: number, body: string) - | Decode(JsonError) - -type UserError = - | Http(HttpError) - | NotFound(id: UserId) - | Banned(reason: string) - -type PaymentError = - | Http(HttpError) - | InsufficientFunds(needed: number, available: number) - | CardDeclined(reason: string) - -type AppError = - | User(UserError) - | Payment(PaymentError) - | Auth(AuthError) +type HttpError { + | Network { NetworkError } + | Status { code: number, body: string } + | Decode { JsonError } +} + +type UserError { + | Http { HttpError } + | NotFound { id: UserId } + | Banned { reason: string } +} + +type PaymentError { + | Http { HttpError } + | InsufficientFunds { needed: number, available: number } + | CardDeclined { reason: string } +} + +type AppError { + | User { UserError } + | Payment { PaymentError } + | Auth { AuthError } +} ``` ### Multi-Depth Matching @@ -625,7 +634,7 @@ Records and functions use the same call syntax: `Name(args)` with optional label ```floe // --- Record Construction --- -type User = { +type User { id: UserId name: string email: Email @@ -669,7 +678,7 @@ createUser("Ryan", role: Admin, email: Email("r@test.com")) // --- Default Values --- // On record types -type Config = { +type Config { baseUrl: string // required — no default timeout: number = 5000 // default value retries: number = 3 // default value @@ -697,7 +706,7 @@ fetchUsers(page: 3) // override one fetchUsers(limit: 50, sort: Descending) // override two // On React component props -type ButtonProps = { +type ButtonProps { label: string // required onClick: fn() -> () // required variant: Variant = Primary // default @@ -732,7 +741,7 @@ Two syntactic forms are supported: **Block form** — group multiple functions: ```floe -type User = { name: string, age: number, active: bool } +type User { name: string, age: number, active: bool } for User { fn display(self) -> string { @@ -851,7 +860,7 @@ Trait rules: Record types can auto-derive trait implementations with `deriving`: ```floe -type User = { +type User { id: string, name: string, email: string, @@ -956,13 +965,17 @@ fn double(x: number) -> number { x * 2 } // correct ```floe import { useState } from "react" -type Todo = { +type Todo { id: string text: string done: boolean } -type Tab = Overview | Team | Analytics +type Tab { + | Overview + | Team + | Analytics +} export fn Dashboard(userId: UserId) -> JSX.Element { const [tab, setTab] = useState(Overview) @@ -1611,7 +1624,7 @@ fn deleteUser(id: UserId) -> Result<(), ApiError> { } // Callbacks -type ButtonProps = { +type ButtonProps { onClick: fn() -> () } ``` @@ -1717,6 +1730,7 @@ const c = { ...a, ...b } // WARNING: 'y' from 'a' is overwritten by 'b' | Spread overlap | Warning on statically-known key overlap | Catches silent overwrites at compile time | | Compiler language | Rust | Fast, WASM-ready for browser playground, good LSP story | | Inline tests | `test "name" { assert expr }` co-located with code | Gleam/Rust-inspired; type-checked always, stripped from production output | +| Type definitions | `type Foo { fields }` for records, `type Foo { \| A \| B }` for unions | Unified syntax: all nominal types use `type Name { ... }`. `=` only for aliases and string literal unions | | For blocks | `for Type { fn f(self) ... }` groups functions under a type | Rust/Swift-like method chaining DX without OOP. `self` is explicit, no `this` magic | --- diff --git a/docs/llms.txt b/docs/llms.txt index dda7415..c66c579 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -51,46 +51,53 @@ const names: Array = ["a", "b", "c"] ```floe // Record type -type User = { +type User { id: string, name: string, email: string, } // Union type (discriminated) -type Route = +type Route { | Home - | Profile(id: string) - | Settings(tab: string) + | Profile { id: string } + | Settings { tab: string } | NotFound +} // Union types can carry data -type Validation = - | Valid(text: string) +type Validation { + | Valid { text: string } | TooShort | TooLong | Empty +} // Nested unions -type NetworkError = - | Timeout(ms: number) - | DnsFailure(host: string) +type NetworkError { + | Timeout { ms: number } + | DnsFailure { host: string } +} -type ApiError = - | Network(NetworkError) +type ApiError { + | Network { NetworkError } | NotFound - | ServerError(status: number) + | ServerError { status: number } +} + +// Newtype (single-value wrapper) +type OrderId { number } // String literal union (for npm interop) type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" // Record type composition with spread -type BaseProps = { +type BaseProps { className: string, disabled: boolean, } -type ButtonProps = { +type ButtonProps { ...BaseProps, onClick: fn() -> (), label: string, @@ -98,9 +105,9 @@ type ButtonProps = { // ButtonProps has: className, disabled, onClick, label // Multiple spreads -type A = { x: number } -type B = { y: string } -type C = { ...A, ...B, z: boolean } +type A { x: number } +type B { y: string } +type C { ...A, ...B, z: boolean } // Branded types type UserId = Brand @@ -273,7 +280,7 @@ fn loadProfile(id: string) -> Result { } // Option replaces null/undefined -type User = { +type User { name: string, nickname: Option, } @@ -410,7 +417,7 @@ for User: Display { } // Auto-derive traits for record types (only Display — Eq is built-in via ==) -type Point = { +type Point { x: number, y: number, } deriving (Display) @@ -422,7 +429,7 @@ type Point = { ```floe import trusted { useState } from "react" -type Todo = { +type Todo { id: string, text: string, done: boolean, @@ -507,7 +514,7 @@ match key { | `null`, `undefined` | `Option` with `Some`/`None` | | `let`, `var` | `const` | | `class` | Functions + types | -| `enum` | `type` with `\|` variants | +| `enum` | `type Name { \| A \| B }` variants | | `throw` | Return `Result` | | `if`/`else` | `match` expression | | `? :` (ternary) | `match` expression | diff --git a/docs/site/src/content/docs/guide/for-blocks.md b/docs/site/src/content/docs/guide/for-blocks.md index ae25337..33ed52e 100644 --- a/docs/site/src/content/docs/guide/for-blocks.md +++ b/docs/site/src/content/docs/guide/for-blocks.md @@ -7,7 +7,7 @@ title: For Blocks ## Basic Usage ```floe -type User = { name: string, age: number } +type User { name: string, age: number } for User { fn display(self) -> string { diff --git a/docs/site/src/content/docs/guide/introduction.md b/docs/site/src/content/docs/guide/introduction.md index 0f4dd62..6c3c3cc 100644 --- a/docs/site/src/content/docs/guide/introduction.md +++ b/docs/site/src/content/docs/guide/introduction.md @@ -22,7 +22,7 @@ Floe removes these and adds features that make correct code easy to write: ```floe import trusted { useState } from "react" -type Todo = { +type Todo { id: string, text: string, done: boolean, diff --git a/docs/site/src/content/docs/guide/jsx.md b/docs/site/src/content/docs/guide/jsx.md index d09502b..82f6ae8 100644 --- a/docs/site/src/content/docs/guide/jsx.md +++ b/docs/site/src/content/docs/guide/jsx.md @@ -24,7 +24,7 @@ Components are exported `fn` declarations with a `JSX.Element` return type. The ## Props ```floe -type ButtonProps = { +type ButtonProps { label: string, onClick: fn() -> (), disabled: boolean, diff --git a/docs/site/src/content/docs/guide/pattern-matching.md b/docs/site/src/content/docs/guide/pattern-matching.md index 6ba60c7..25e7ce4 100644 --- a/docs/site/src/content/docs/guide/pattern-matching.md +++ b/docs/site/src/content/docs/guide/pattern-matching.md @@ -37,10 +37,11 @@ match findItem(id) { ## Union Types ```floe -type Shape = - | Circle(radius: number) - | Rectangle(width: number, height: number) - | Triangle(base: number, height: number) +type Shape { + | Circle { radius: number } + | Rectangle { width: number, height: number } + | Triangle { base: number, height: number } +} fn area(shape: Shape) -> number { match shape { diff --git a/docs/site/src/content/docs/guide/testing.md b/docs/site/src/content/docs/guide/testing.md index bacad6c..7cbee85 100644 --- a/docs/site/src/content/docs/guide/testing.md +++ b/docs/site/src/content/docs/guide/testing.md @@ -37,11 +37,12 @@ The compiler enforces that assert expressions are boolean at compile time. Tests live in the same file as the code they test. This makes it easy to keep tests in sync with the implementation: ```floe -type Validation = - | Valid(string) +type Validation { + | Valid { string } | Empty | TooShort | TooLong +} fn validate(input: string) -> Validation { const len = input |> String.length diff --git a/docs/site/src/content/docs/guide/tour.md b/docs/site/src/content/docs/guide/tour.md index 2dd11f6..1d2ba67 100644 --- a/docs/site/src/content/docs/guide/tour.md +++ b/docs/site/src/content/docs/guide/tour.md @@ -114,17 +114,18 @@ const icon = temperature |> match { ```floe // Records -type User = { +type User { id: string, name: string, email: string, } // Union types (discriminated, exhaustive) -type Shape = - | Circle(radius: number) - | Rectangle(width: number, height: number) - | Triangle(base: number, height: number) +type Shape { + | Circle { radius: number } + | Rectangle { width: number, height: number } + | Triangle { base: number, height: number } +} fn area(shape: Shape) -> number { match shape { @@ -149,8 +150,8 @@ const (x, y) = point type UserId = Brand type OrderId = Brand -// Newtypes — single-variant wrappers -type OrderId = OrderId(number) +// Newtypes — single-value wrappers +type OrderId { number } const id = OrderId(42) const OrderId(n) = id // destructure to get inner value @@ -161,7 +162,7 @@ opaque type HashedPassword = string type Method = "GET" | "POST" | "PUT" | "DELETE" // Record composition -type ButtonProps = { +type ButtonProps { ...BaseProps, onClick: fn() -> (), label: string, @@ -297,7 +298,7 @@ for User: Display { } // Auto-derive for records -type Point = { +type Point { x: number, y: number, } deriving (Display) diff --git a/docs/site/src/content/docs/guide/traits.md b/docs/site/src/content/docs/guide/traits.md index 46b8dc6..7010155 100644 --- a/docs/site/src/content/docs/guide/traits.md +++ b/docs/site/src/content/docs/guide/traits.md @@ -19,7 +19,7 @@ trait Display { Use `for Type: Trait` to implement a trait for a type: ```floe -type User = { name: string, age: number } +type User { name: string, age: number } for User: Display { fn display(self) -> string { @@ -87,7 +87,7 @@ function display(self: User): string { return self.name; } Record types can auto-derive trait implementations with `deriving`. This generates the same code as a handwritten `for` block with no runtime cost: ```floe -type User = { +type User { id: string, name: string, email: string, @@ -111,7 +111,7 @@ This generates: ### Compiled output ```floe -type User = { name: string, age: number } deriving (Display) +type User { name: string, age: number } deriving (Display) ``` ```typescript diff --git a/docs/site/src/content/docs/guide/types.md b/docs/site/src/content/docs/guide/types.md index 7f858e3..00a6d69 100644 --- a/docs/site/src/content/docs/guide/types.md +++ b/docs/site/src/content/docs/guide/types.md @@ -13,7 +13,7 @@ const active: boolean = true ## Record Types ```floe -type User = { +type User { name: string, email: string, age: number, @@ -37,12 +37,12 @@ const updated = User(..user, age: 31) Include fields from other record types using spread syntax: ```floe -type BaseProps = { +type BaseProps { className: string, disabled: boolean, } -type ButtonProps = { +type ButtonProps { ...BaseProps, onClick: fn() -> (), label: string, @@ -53,9 +53,9 @@ type ButtonProps = { Multiple spreads are allowed: ```floe -type A = { x: number } -type B = { y: string } -type C = { ...A, ...B, z: boolean } +type A { x: number } +type B { y: string } +type C { ...A, ...B, z: boolean } ``` Rules: @@ -68,11 +68,12 @@ Rules: Discriminated unions with variants: ```floe -type Color = +type Color { | Red | Green | Blue - | Custom(r: number, g: number, b: number) + | Custom { r: number, g: number, b: number } +} ``` ### Qualified Variants @@ -80,7 +81,7 @@ type Color = When a variant name could be ambiguous (e.g., multiple unions have a variant called `Active`), use qualified syntax: ```floe -type Filter = All | Active | Completed +type Filter { | All | Active | Completed } const f = Filter.All const g = Filter.Active @@ -140,7 +141,7 @@ For pure Floe code, prefer regular tagged unions (`| Get | Post`) since they wor For operations that can fail: ```floe -type Result = Ok(T) | Err(E) +type Result { | Ok { T } | Err { E } } const result = Ok(42) const error = Err("something went wrong") @@ -151,7 +152,7 @@ const error = Err("something went wrong") For values that may be absent: ```floe -type Option = Some(T) | None +type Option { | Some { T } | None } const found = Some("hello") const missing = None diff --git a/docs/site/src/content/docs/reference/syntax.md b/docs/site/src/content/docs/reference/syntax.md index 2caadad..d7fab9f 100644 --- a/docs/site/src/content/docs/reference/syntax.md +++ b/docs/site/src/content/docs/reference/syntax.md @@ -44,15 +44,19 @@ async fn name() -> Promise { ```floe // Record -type User = { +type User { name: string, email: string, } // Union -type Shape = - | Circle(radius: number) - | Rectangle(width: number, height: number) +type Shape { + | Circle { radius: number } + | Rectangle { width: number, height: number } +} + +// Newtype (single-value wrapper) +type OrderId { number } // String literal union (for npm interop) type HttpMethod = "GET" | "POST" | "PUT" | "DELETE" @@ -67,7 +71,7 @@ type UserId = Brand opaque type Email = string // Deriving traits -type Point = { +type Point { x: number, y: number, } deriving (Display) diff --git a/docs/site/src/content/docs/reference/types.md b/docs/site/src/content/docs/reference/types.md index b104778..7a1336f 100644 --- a/docs/site/src/content/docs/reference/types.md +++ b/docs/site/src/content/docs/reference/types.md @@ -25,7 +25,7 @@ title: Types Reference Named product types with fields: ```floe -type User = { +type User { name: string, email: string, age: number, @@ -47,12 +47,12 @@ type User = { Include fields from other record types using `...` spread: ```floe -type BaseProps = { +type BaseProps { className: string, disabled: boolean, } -type ButtonProps = { +type ButtonProps { ...BaseProps, onClick: fn() -> (), label: string, @@ -73,10 +73,11 @@ Multiple spreads are allowed. Field name conflicts are compile errors. Tagged discriminated unions: ```floe -type Shape = - | Circle(radius: number) - | Rectangle(width: number, height: number) +type Shape { + | Circle { radius: number } + | Rectangle { width: number, height: number } | Point +} ``` Compiles to TypeScript discriminated union: diff --git a/editors/tree-sitter-floe/grammar.js b/editors/tree-sitter-floe/grammar.js index bb22ebd..d44b4e7 100644 --- a/editors/tree-sitter-floe/grammar.js +++ b/editors/tree-sitter-floe/grammar.js @@ -175,7 +175,7 @@ module.exports = grammar({ "type", field("name", $.type_identifier), optional($.type_parameters), - "=", + optional("="), field("definition", $._type_definition), optional($.deriving_clause), ), @@ -192,7 +192,7 @@ module.exports = grammar({ variant: ($) => prec.right(1, seq( field("name", $.type_identifier), - optional(seq("(", commaSep1($.variant_field), ")")), + optional(seq("{", commaSep1($.variant_field), "}")), )), variant_field: ($) => diff --git a/editors/tree-sitter-floe/test/corpus/declarations.txt b/editors/tree-sitter-floe/test/corpus/declarations.txt index 222ec79..7581913 100644 --- a/editors/tree-sitter-floe/test/corpus/declarations.txt +++ b/editors/tree-sitter-floe/test/corpus/declarations.txt @@ -181,7 +181,7 @@ const { x, y } = point Type declaration - record === -type Todo = { +type Todo { id: string, text: string, done: boolean, @@ -207,7 +207,7 @@ type Todo = { Type declaration - union === -type Filter = +type Filter | All | Active | Completed @@ -229,8 +229,8 @@ type Filter = Type declaration - union with fields === -type Validation = - | Valid(text: string) +type Validation + | Valid { text: string } | TooShort | Empty @@ -291,7 +291,7 @@ export const pi = 3 Generic type === -type Box = { +type Box { value: T, } diff --git a/examples/store/src/api.fl b/examples/store/src/api.fl index 5c99d36..4853782 100644 --- a/examples/store/src/api.fl +++ b/examples/store/src/api.fl @@ -7,7 +7,7 @@ import { Product, ProductId, Review, ApiError } from "./types" // ── Response types for parsing ───────────────────────── -type ProductDetailResponse = { +type ProductDetailResponse { id: number, title: string, description: string, @@ -23,12 +23,12 @@ type ProductDetailResponse = { reviews: Array, } -type ProductListResponse = { +type ProductListResponse { products: Array, total: number, } -export type CategoryResponse = { +export type CategoryResponse { slug: string, name: string, } diff --git a/examples/store/src/types.fl b/examples/store/src/types.fl index 6f7fc90..7aba171 100644 --- a/examples/store/src/types.fl +++ b/examples/store/src/types.fl @@ -1,19 +1,19 @@ // ── Newtype wrappers ─────────────────────────────────── // Single-variant unions as newtypes (Gleam/Rust style). -export type ProductId = ProductId(number) -export type OrderId = OrderId(number) +export type ProductId { number } +export type OrderId { number } // ── Shared field groups ──────────────────────────────── // Record type composition with spread. -type WithRating = { +type WithRating { rating: number, } // ── Product (from DummyJSON API) ─────────────────────── -export type Product = { +export type Product { id: ProductId, title: string, description: string, @@ -28,7 +28,7 @@ export type Product = { images: Array, } deriving (Display) -export type Review = { +export type Review { ...WithRating, comment: string, date: string, @@ -37,55 +37,61 @@ export type Review = { // ── Cart ─────────────────────────────────────────────── -export type CartItem = { +export type CartItem { product: Product, quantity: number, } // ── Nested error unions ──────────────────────────────── -export type NetworkError = - | Timeout(ms: number) - | DnsFailure(host: string) +export type NetworkError { + | Timeout { ms: number } + | DnsFailure { host: string } | ConnectionRefused +} -export type ApiError = - | Network(NetworkError) - | NotFound(id: ProductId) - | BadResponse(status: number, body: string) - | ParseError(message: string) +export type ApiError { + | Network { NetworkError } + | NotFound { id: ProductId } + | BadResponse { status: number, body: string } + | ParseError { message: string } +} // ── Sort + Filter ────────────────────────────────────── -export type SortOrder = +export type SortOrder { | PriceLow | PriceHigh | Rating | Name +} -export type PriceRange = +export type PriceRange { | Any - | Under(max: number) - | Between(min: number, max: number) - | Over(min: number) + | Under { max: number } + | Between { min: number, max: number } + | Over { min: number } +} // ── Order ────────────────────────────────────────────── -export type OrderStatus = +export type OrderStatus { | Pending - | Confirmed(orderId: OrderId) - | Shipped(tracking: string) - | Failed(reason: string) + | Confirmed { orderId: OrderId } + | Shipped { tracking: string } + | Failed { reason: string } +} // ── Checkout validation ──────────────────────────────── -export type CheckoutError = +export type CheckoutError { | EmptyCart - | InvalidEmail(email: string) - | InvalidPhone(phone: string) - | OutOfStock(productId: ProductId) + | InvalidEmail { email: string } + | InvalidPhone { phone: string } + | OutOfStock { productId: ProductId } +} -export type ShippingInfo = { +export type ShippingInfo { name: string, email: string, phone: string, diff --git a/examples/todo-app/src/pages/posts.fl b/examples/todo-app/src/pages/posts.fl index 3daa87d..8b8f192 100644 --- a/examples/todo-app/src/pages/posts.fl +++ b/examples/todo-app/src/pages/posts.fl @@ -2,14 +2,14 @@ import trusted { useState, Suspense } from "react" import trusted { useSuspenseQuery, QueryClient, QueryClientProvider, QueryErrorResetBoundary } from "@tanstack/react-query" import trusted { ErrorBoundary } from "react-error-boundary" -type Post = { +type Post { id: number, title: string, body: string, userId: number, } -type User = { +type User { id: number, name: string, email: string, diff --git a/examples/todo-app/src/types.fl b/examples/todo-app/src/types.fl index 61a43fd..0f30e54 100644 --- a/examples/todo-app/src/types.fl +++ b/examples/todo-app/src/types.fl @@ -1,26 +1,28 @@ -export type Todo = { +export type Todo { id: string, text: string, done: boolean, } -export type Filter = +export type Filter { | All | Active | Completed +} -export type Validation = - | Valid(text: string) +export type Validation { + | Valid { text: string } | TooShort | TooLong | Empty +} -export type Timestamped = { +export type Timestamped { createdAt: number, updatedAt: number, } -export type TodoWithTimestamp = { +export type TodoWithTimestamp { ...Todo, ...Timestamped, } diff --git a/src/checker/tests.rs b/src/checker/tests.rs index 93793b6..47d2da3 100644 --- a/src/checker/tests.rs +++ b/src/checker/tests.rs @@ -314,7 +314,7 @@ fn floating_result_error() { fn for_block_registers_function() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User { fn display(self) -> string { self.name } } @@ -329,7 +329,7 @@ const _x = display(User(name: "Ryan")) fn for_block_self_gets_type() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User { fn getName(self) -> string { self.name } } @@ -343,7 +343,7 @@ for User { fn for_block_multiple_params() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User { fn greet(self, greeting: string) -> string { greeting } } @@ -361,7 +361,7 @@ fn call_site_type_args_infer_return() { let program = crate::parser::Parser::new( r#" import { useState } from "react" -type Todo = { text: string } +type Todo { text: string } const [todos, _setTodos] = useState>([]) const _x = todos "#, @@ -411,7 +411,7 @@ const _x = todos fn for_block_with_pipe() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User { fn display(self) -> string { self.name } } @@ -428,7 +428,7 @@ const _x = _user |> display fn inline_for_registers_function() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User fn display(self) -> string { self.name } const _x = display(User(name: "Ryan")) "#, @@ -440,7 +440,7 @@ const _x = display(User(name: "Ryan")) fn inline_for_exported_registers_function() { let diags = check( r#" -type User = { name: string } +type User { name: string } export for User fn display(self) -> string { self.name } const _x = display(User(name: "Ryan")) "#, @@ -452,7 +452,7 @@ const _x = display(User(name: "Ryan")) fn inline_for_self_gets_type() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User fn getName(self) -> string { self.name } "#, ); @@ -463,7 +463,7 @@ for User fn getName(self) -> string { self.name } fn inline_for_with_pipe() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User fn display(self) -> string { self.name } const _user = User(name: "Ryan") const _x = _user |> display @@ -541,7 +541,7 @@ const _y = fetchUser("id") fn constructor_unknown_field_error() { let diags = check( r#" -type Todo = { +type Todo { id: string, text: string, done: bool, @@ -557,7 +557,7 @@ const _t = Todo(id: "1", textt: "hello", done: false) fn constructor_valid_fields_no_error() { let diags = check( r#" -type Todo = { +type Todo { id: string, text: string, done: bool, @@ -573,7 +573,7 @@ const _t = Todo(id: "1", text: "hello", done: false) fn constructor_missing_required_field() { let diags = check( r#" -type Todo = { +type Todo { id: string, text: string, done: bool, @@ -592,7 +592,7 @@ const _t = Todo(id: "1", text: "hello") fn constructor_missing_field_with_default_ok() { let diags = check( r#" -type Config = { +type Config { host: string, port: number = 3000, } @@ -606,7 +606,7 @@ const _c = Config(host: "localhost") fn constructor_spread_skips_missing_check() { let diags = check( r#" -type Todo = { +type Todo { id: string, text: string, done: bool, @@ -622,10 +622,11 @@ const _t = Todo(..original, text: "updated") fn union_variant_unknown_field_error() { let diags = check( r#" -type Validation = - | Valid(text: string) +type Validation { + | Valid { text: string } | TooShort | Empty +} const _v = Valid(texxt: "hello") "#, @@ -638,10 +639,11 @@ const _v = Valid(texxt: "hello") fn union_variant_valid_field_no_error() { let diags = check( r#" -type Validation = - | Valid(text: string) +type Validation { + | Valid { text: string } | TooShort | Empty +} const _v = Valid(text: "hello") "#, @@ -655,7 +657,7 @@ const _v = Valid(text: "hello") fn unknown_type_in_record_field() { let diags = check( r#" -type Todo = { +type Todo { id: string, text: string, done: asojSIDJA, @@ -687,7 +689,7 @@ fn unknown_type_in_function_return() { fn known_type_no_error() { let diags = check( r#" -type User = { name: string } +type User { name: string } const _u: User = User(name: "Alice") "#, ); @@ -710,8 +712,8 @@ const _c: boolean = true fn forward_reference_in_union_no_error() { let diags = check( r#" -type Container = { item: Item } -type Item = { name: string } +type Container { item: Item } +type Item { name: string } "#, ); assert!(!has_error_containing(&diags, "unknown type `Item`")); @@ -886,7 +888,7 @@ fn shadow_const_shadows_for_block_fn_errors() { // A const shadowing a for-block function should error let diags = check( r#" -type Todo = { text: string, done: boolean } +type Todo { text: string, done: boolean } for Array { export fn remaining(self) -> number { 0 } } @@ -925,7 +927,7 @@ fn shadow_inner_scope_const_shadows_for_block_fn() { // A const INSIDE a function body shadowing a for-block function should error let diags = check( r#" -type Todo = { text: string, done: boolean } +type Todo { text: string, done: boolean } for Array { export fn remaining(self) -> number { 0 } } @@ -966,7 +968,7 @@ fn for_block_pipe_then_shadow_errors() { // Real-world case: piping into for-block fn then shadowing its name let diags = check( r#" -type Todo = { text: string, done: boolean } +type Todo { text: string, done: boolean } for Array { export fn remaining(self) -> number { 0 } } @@ -1020,7 +1022,7 @@ const double = 42 fn shadow_error_includes_source_for_block() { let diags = check( r#" -type Todo = { text: string, done: boolean } +type Todo { text: string, done: boolean } for Array { export fn remaining(self) -> number { 0 } } @@ -1091,7 +1093,7 @@ const _r = 5 |> double fn member_access_on_record_type_resolves_field() { let diags = check( r#" -type User = { name: string, age: number } +type User { name: string, age: number } const u = User(name: "hi", age: 21) const _n = u.name "#, @@ -1117,7 +1119,7 @@ const _n = u.name fn member_access_unknown_field_errors() { let diags = check( r#" -type User = { name: string } +type User { name: string } const u = User(name: "hi") const _n = u.nonexistent "#, @@ -1150,7 +1152,7 @@ const _n = x.name fn constructor_wrong_field_type_errors() { let diags = check( r#" -type User = { name: string, age: number } +type User { name: string, age: number } const _u = User(name: 42, age: "old") "#, ); @@ -1165,7 +1167,7 @@ const _u = User(name: 42, age: "old") fn constructor_correct_types_ok() { let diags = check( r#" -type User = { name: string, age: number } +type User { name: string, age: number } const _u = User(name: "hi", age: 21) "#, ); @@ -1186,7 +1188,7 @@ fn constructor_missing_field_errors_phase1() { // but let's add one that specifically tests the two-field case) let diags = check( r#" -type User = { name: string, age: number } +type User { name: string, age: number } const _u = User(name: "hi") "#, ); @@ -1305,7 +1307,7 @@ fn calling_named_function_type_returns_its_return_type() { // (which is what Dispatch>> resolves to) let program = crate::parser::Parser::new( r#" -type Todo = { text: string } +type Todo { text: string } fn setTodos(value: Array) -> () { () } fn handler() { setTodos([]) @@ -1335,7 +1337,7 @@ fn dispatch_generic_converts_to_function() { let program = crate::parser::Parser::new( r#" import trusted { useState } from "react" -type Todo = { text: string } +type Todo { text: string } const [todos, setTodos] = useState>([]) fn handler() { setTodos([]) @@ -1408,7 +1410,7 @@ fn calling_dispatch_type_is_callable() { let program = crate::parser::Parser::new( r#" import trusted { useState } from "react" -type Todo = { text: string } +type Todo { text: string } const [todos, setTodos] = useState>([]) fn handler() { setTodos([]) @@ -1503,7 +1505,7 @@ fn outer() { fn object_destructuring_gets_field_types() { let program = crate::parser::Parser::new( r#" -type User = { name: string, age: number } +type User { name: string, age: number } const user = User(name: "hi", age: 21) const { name, age } = user const _x = name @@ -1675,7 +1677,7 @@ fn trait_impl_valid() { trait Display { fn display(self) -> string } -type User = { name: string } +type User { name: string } for User: Display { fn display(self) -> string { self.name @@ -1701,7 +1703,7 @@ fn trait_impl_missing_method() { trait Display { fn display(self) -> string } -type User = { name: string } +type User { name: string } for User: Display { fn toString(self) -> string { "wrong" @@ -1720,7 +1722,7 @@ for User: Display { fn trait_unknown_trait() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User: NonExistent { fn display(self) -> string { self.name @@ -1779,7 +1781,7 @@ trait Eq { !(self |> eq(other)) } } -type User = { name: string } +type User { name: string } for User: Eq { fn eq(self, other: string) -> boolean { self.name == other @@ -1802,7 +1804,7 @@ for User: Eq { fn trait_for_block_without_trait_still_works() { let diags = check( r#" -type User = { name: string } +type User { name: string } for User { fn greet(self) -> string { self.name @@ -1829,7 +1831,7 @@ trait Printable { fn print(self) -> string fn prettyPrint(self) -> string } -type User = { name: string } +type User { name: string } for User: Printable { fn print(self) -> string { self.name @@ -1859,7 +1861,7 @@ trait Printable { fn print(self) -> string fn prettyPrint(self) -> string } -type User = { name: string } +type User { name: string } for User: Printable { fn print(self) -> string { self.name @@ -2289,12 +2291,12 @@ fn _handle(s: Status) -> number { fn record_spread_basic() { let diags = check( r#" -type BaseProps = { +type BaseProps { className: string, disabled: boolean, } -type ButtonProps = { +type ButtonProps { ...BaseProps, onClick: fn() -> (), label: string, @@ -2314,15 +2316,15 @@ const btn = ButtonProps(className: "btn", disabled: false, onClick: fn() (), lab fn record_spread_multiple() { let diags = check( r#" -type A = { +type A { x: number, } -type B = { +type B { y: string, } -type C = { +type C { ...A, ...B, z: boolean, @@ -2342,11 +2344,11 @@ const c = C(x: 1, y: "hello", z: true) fn record_spread_conflict_error() { let diags = check( r#" -type A = { +type A { name: string, } -type B = { +type B { ...A, name: number, } @@ -2363,9 +2365,9 @@ type B = { fn record_spread_union_error() { let diags = check( r#" -type Status = | Active | Inactive +type Status { | Active | Inactive } -type Bad = { +type Bad { ...Status, extra: string, } @@ -2382,16 +2384,16 @@ type Bad = { fn record_spread_nested() { let diags = check( r#" -type A = { +type A { x: number, } -type B = { +type B { ...A, y: string, } -type C = { +type C { ...B, z: boolean, } @@ -2410,15 +2412,15 @@ const c = C(x: 1, y: "hello", z: true) fn record_spread_conflict_between_spreads() { let diags = check( r#" -type A = { +type A { name: string, } -type B = { +type B { name: string, } -type C = { +type C { ...A, ...B, } @@ -2557,7 +2559,7 @@ fn f() -> Result> { fn deriving_eq_is_error() { let diags = check( r#" -type Point = { +type Point { x: number, y: number, } deriving (Eq) @@ -2574,7 +2576,7 @@ type Point = { fn deriving_display_on_record_type() { let diags = check( r#" -type User = { +type User { name: string, age: number, } deriving (Display) @@ -2591,7 +2593,7 @@ type User = { fn deriving_eq_and_display_errors_on_eq() { let diags = check( r#" -type User = { +type User { name: string, age: number, } deriving (Eq, Display) @@ -2608,7 +2610,7 @@ type User = { fn deriving_on_union_type_is_error() { let diags = check( r#" -type Shape = | Circle(radius: number) | Square(side: number) deriving (Display) +type Shape { | Circle { radius: number } | Square { side: number } } deriving (Display) "#, ); assert!( @@ -2622,7 +2624,7 @@ type Shape = | Circle(radius: number) | Square(side: number) deriving (Display) fn deriving_unknown_trait_is_error() { let diags = check( r#" -type Point = { +type Point { x: number, y: number, } deriving (Hash) @@ -2639,7 +2641,7 @@ type Point = { #[test] fn newtype_with_number() { - let diags = check("type ProductId = ProductId(number)"); + let diags = check("type ProductId { number }"); assert!( !has_error_containing(&diags, "is not defined"), "ProductId(number) should parse as a newtype, got: {:?}", @@ -2649,7 +2651,7 @@ fn newtype_with_number() { #[test] fn newtype_with_string() { - let diags = check("type Email = Email(string)"); + let diags = check("type Email { string }"); assert!( !has_error_containing(&diags, "is not defined"), "Email(string) should parse as a newtype, got: {:?}", @@ -2659,7 +2661,7 @@ fn newtype_with_string() { #[test] fn newtype_with_boolean() { - let diags = check("type Flag = Flag(boolean)"); + let diags = check("type Flag { boolean }"); assert!( !has_error_containing(&diags, "is not defined"), "Flag(boolean) should parse as a newtype, got: {:?}", @@ -2669,7 +2671,7 @@ fn newtype_with_boolean() { #[test] fn newtype_with_named_field() { - let diags = check("type UserId = UserId(value: number)"); + let diags = check("type UserId { value: number }"); assert!( !has_error_containing(&diags, "is not defined"), "UserId(value: number) should parse as a newtype, got: {:?}", @@ -2681,11 +2683,12 @@ fn newtype_with_named_field() { fn newtype_coexists_with_regular_unions() { let diags = check( r#" -type ProductId = ProductId(number) -type Route = +type ProductId { number } +type Route { | Home - | Profile(id: string) + | Profile { id: string } | NotFound +} "#, ); assert!( diff --git a/src/codegen/tests.rs b/src/codegen/tests.rs index d316be6..afd18d2 100644 --- a/src/codegen/tests.rs +++ b/src/codegen/tests.rs @@ -327,13 +327,13 @@ fn match_guard_with_binding() { #[test] fn type_record() { - let result = emit("type User = { id: string, name: string }"); + let result = emit("type User { id: string, name: string }"); assert_eq!(result, "type User = { id: string; name: string };"); } #[test] fn type_union() { - let result = emit("type Route = | Home | Profile(id: string) | NotFound"); + let result = emit("type Route { | Home | Profile { id: string } | NotFound }"); assert!(result.contains("tag: \"Home\"")); assert!(result.contains("tag: \"Profile\"")); assert!(result.contains("tag: \"NotFound\"")); @@ -801,7 +801,7 @@ fn type_directed_array_filter() { fn union_variant_dot_access() { let result = emit( r#" -type Filter = | All | Active | Completed +type Filter { | All | Active | Completed } const _f = Filter.All "#, ); @@ -1027,7 +1027,7 @@ test "math" { fn inline_for_emits_same_as_block() { let block_result = emit( r#" -type User = { name: string } +type User { name: string } for User { fn display(self) -> string { self.name } } @@ -1035,7 +1035,7 @@ for User { ); let inline_result = emit( r#" -type User = { name: string } +type User { name: string } for User fn display(self) -> string { self.name } "#, ); @@ -1046,7 +1046,7 @@ for User fn display(self) -> string { self.name } fn inline_for_exported() { let result = emit( r#" -type User = { name: string } +type User { name: string } export for User fn display(self) -> string { self.name } "#, ); @@ -1061,7 +1061,7 @@ export for User fn display(self) -> string { self.name } fn inline_for_multiple_separate() { let result = emit( r#" -type User = { name: string } +type User { name: string } for User fn display(self) -> string { self.name } export for User fn greet(self, greeting: string) -> string { greeting } "#, @@ -1308,7 +1308,7 @@ fn f() -> Result> { fn deriving_display_generates_string() { let result = emit( r#" -type User = { +type User { name: string, age: number, } deriving (Display) diff --git a/src/cst.rs b/src/cst.rs index d12c336..83f2341 100644 --- a/src/cst.rs +++ b/src/cst.rs @@ -372,10 +372,15 @@ impl<'src> CstParser<'src> { self.eat_trivia(); } - self.expect(TokenKind::Equal); - self.eat_trivia(); - - self.parse_type_def(); + // New syntax: `type Name { ... }` for records/unions/newtypes + // Old syntax: `type Name = ...` for aliases and string literal unions + if self.at(TokenKind::LeftBrace) { + self.parse_type_body_in_braces(); + } else { + self.expect(TokenKind::Equal); + self.eat_trivia(); + self.parse_type_def_after_eq(); + } // Optional deriving clause: `deriving (Display)` self.eat_trivia(); @@ -393,51 +398,116 @@ impl<'src> CstParser<'src> { self.builder.finish_node(); } - fn parse_type_def(&mut self) { - if self.at_pipe_in_union() { - self.parse_union_variants(); - } else if self.at_string_literal_union() { - self.parse_string_literal_union(); - } else if self.at(TokenKind::LeftBrace) { - self.builder.start_node(SyntaxKind::TYPE_DEF_RECORD.into()); - self.parse_record_fields(); - self.builder.finish_node(); - } else if self.is_newtype_constructor() { - // Single-variant newtype: `type ProductId = ProductId(number)` - // Parse as a union with one variant - self.builder.start_node(SyntaxKind::TYPE_DEF_UNION.into()); - self.builder.start_node(SyntaxKind::VARIANT.into()); - self.expect_ident(); // variant name - self.eat_trivia(); - if self.at(TokenKind::LeftParen) { - self.bump(); + /// Parse type body inside `{ }`: disambiguate between record, union, and newtype. + fn parse_type_body_in_braces(&mut self) { + // Peek at first non-trivia token inside `{` to disambiguate: + // - `|` → union variants + // - lowercase ident + `:` → record fields + // - `...` → record fields (spread) + // - `}` → empty record + // - anything else → newtype wrapper + let first_inside = self.peek_inside_brace(); + + match first_inside { + Some(TokenKind::VerticalBar) => { + self.builder.start_node(SyntaxKind::TYPE_DEF_UNION.into()); + self.bump(); // { self.eat_trivia(); - self.parse_comma_separated(Self::parse_variant_field, TokenKind::RightParen); - self.expect(TokenKind::RightParen); + self.parse_union_variants_inner(); + self.expect(TokenKind::RightBrace); + self.builder.finish_node(); + } + Some(TokenKind::DotDotDot) => { + // Record with spread + self.builder.start_node(SyntaxKind::TYPE_DEF_RECORD.into()); + self.parse_record_fields(); + self.builder.finish_node(); + } + Some(TokenKind::Identifier(name)) if name.starts_with(char::is_lowercase) => { + // Peek further: if followed by `:`, it's a record field. + // Otherwise it's a newtype (e.g. `type OrderId { number }`) + if self.peek_inside_brace_second() == Some(TokenKind::Colon) { + self.builder.start_node(SyntaxKind::TYPE_DEF_RECORD.into()); + self.parse_record_fields(); + self.builder.finish_node(); + } else { + // Newtype wrapping a lowercase type like `number`, `string`, `boolean` + self.builder.start_node(SyntaxKind::TYPE_DEF_UNION.into()); + self.bump(); // { + self.eat_trivia(); + self.builder.start_node(SyntaxKind::VARIANT_FIELD.into()); + self.parse_type_expr(); + self.builder.finish_node(); + self.eat_trivia(); + self.expect(TokenKind::RightBrace); + self.builder.finish_node(); + } + } + Some(TokenKind::RightBrace) => { + // Empty record: `type Foo {}` + self.builder.start_node(SyntaxKind::TYPE_DEF_RECORD.into()); + self.parse_record_fields(); + self.builder.finish_node(); + } + _ => { + // Newtype: `type OrderId { number }` + // Parse as single-variant union matching the type name + self.builder.start_node(SyntaxKind::TYPE_DEF_UNION.into()); + self.bump(); // { + self.eat_trivia(); + // Synthesize a variant with the type's name — the lowerer + // will pick up the inner type expression as a variant field + self.builder.start_node(SyntaxKind::VARIANT_FIELD.into()); + self.parse_type_expr(); + self.builder.finish_node(); + self.eat_trivia(); + self.expect(TokenKind::RightBrace); + self.builder.finish_node(); } - self.builder.finish_node(); - self.builder.finish_node(); - } else { - self.builder.start_node(SyntaxKind::TYPE_DEF_ALIAS.into()); - self.parse_type_expr(); - self.builder.finish_node(); } } - /// Check if the current position is a newtype constructor: `Uppercase(...)`. - fn is_newtype_constructor(&self) -> bool { - if let Some(TokenKind::Identifier(name)) = self.current_kind() - && name.starts_with(char::is_uppercase) - { - self.peek_is(TokenKind::LeftParen) - } else { - false + /// Peek at the first non-trivia token after the current `{`. + fn peek_inside_brace(&self) -> Option { + let mut i = self.pos + 1; + while i < self.tokens.len() { + if !self.tokens[i].kind.is_trivia() { + return Some(self.tokens[i].kind.clone()); + } + i += 1; } + None } - fn parse_union_variants(&mut self) { - self.builder.start_node(SyntaxKind::TYPE_DEF_UNION.into()); + /// Peek at the second non-trivia token after the current `{`. + fn peek_inside_brace_second(&self) -> Option { + let mut i = self.pos + 1; + let mut count = 0; + while i < self.tokens.len() { + if !self.tokens[i].kind.is_trivia() { + count += 1; + if count == 2 { + return Some(self.tokens[i].kind.clone()); + } + } + i += 1; + } + None + } + + /// Parse after `=`: aliases and string literal unions only. + fn parse_type_def_after_eq(&mut self) { + if self.at_string_literal_union() { + self.parse_string_literal_union(); + } else { + self.builder.start_node(SyntaxKind::TYPE_DEF_ALIAS.into()); + self.parse_type_expr(); + self.builder.finish_node(); + } + } + /// Parse union variants inside `{ }`. The `{` is already consumed, `}` is consumed by caller. + fn parse_union_variants_inner(&mut self) { while self.at_pipe_in_union() { self.builder.start_node(SyntaxKind::VARIANT.into()); self.bump(); // | @@ -445,18 +515,17 @@ impl<'src> CstParser<'src> { self.expect_ident(); self.eat_trivia(); - if self.at(TokenKind::LeftParen) { - self.bump(); + // Variant fields now use { } instead of ( ) + if self.at(TokenKind::LeftBrace) { + self.bump(); // { self.eat_trivia(); - self.parse_comma_separated(Self::parse_variant_field, TokenKind::RightParen); - self.expect(TokenKind::RightParen); + self.parse_comma_separated(Self::parse_variant_field, TokenKind::RightBrace); + self.expect(TokenKind::RightBrace); self.eat_trivia(); } self.builder.finish_node(); } - - self.builder.finish_node(); } fn parse_string_literal_union(&mut self) { @@ -2403,19 +2472,19 @@ mod tests { #[test] fn export_type() { - assert_no_errors("export type Color = | Red | Green | Blue"); + assert_no_errors("export type Color { | Red | Green | Blue }"); } // ── Type declarations ───────────────────────────────────────── #[test] fn type_record() { - assert_no_errors("type User = { name: string, age: number }"); + assert_no_errors("type User { name: string, age: number }"); } #[test] fn type_union() { - assert_no_errors("type Color = | Red | Green | Blue"); + assert_no_errors("type Color { | Red | Green | Blue }"); } #[test] @@ -2440,12 +2509,12 @@ mod tests { #[test] fn type_generic() { - assert_no_errors("type Box = { value: T }"); + assert_no_errors("type Box { value: T }"); } #[test] fn type_exported() { - assert_no_errors("export type Point = { x: number, y: number }"); + assert_no_errors("export type Point { x: number, y: number }"); } // ── Expressions ─────────────────────────────────────────────── diff --git a/src/formatter/items.rs b/src/formatter/items.rs index 60cf69b..694d64a 100644 --- a/src/formatter/items.rs +++ b/src/formatter/items.rs @@ -195,8 +195,6 @@ impl Formatter<'_> { self.write(">"); } - self.write(" ="); - for child in node.children() { match child.kind() { SyntaxKind::TYPE_DEF_UNION => { @@ -206,8 +204,8 @@ impl Formatter<'_> { self.write(" "); self.fmt_record_def(&child); } - SyntaxKind::TYPE_DEF_ALIAS => { - self.write(" "); + SyntaxKind::TYPE_DEF_ALIAS | SyntaxKind::TYPE_DEF_STRING_UNION => { + self.write(" = "); self.fmt_type_alias_def(&child); } SyntaxKind::DERIVING_CLAUSE => { @@ -227,6 +225,25 @@ impl Formatter<'_> { .filter(|c| c.kind() == SyntaxKind::VARIANT) .collect(); + // Newtype case: no VARIANT children, just VARIANT_FIELD directly + if variants.is_empty() { + self.write(" {"); + self.indent += 1; + self.newline(); + self.write_indent(); + for child in node.children() { + if child.kind() == SyntaxKind::VARIANT_FIELD { + self.fmt_variant_field(&child); + } + } + self.indent -= 1; + self.newline(); + self.write_indent(); + self.write("}"); + return; + } + + self.write(" {"); self.indent += 1; for variant in &variants { self.newline(); @@ -235,6 +252,9 @@ impl Formatter<'_> { self.fmt_variant(variant); } self.indent -= 1; + self.newline(); + self.write_indent(); + self.write("}"); } fn fmt_variant(&mut self, node: &SyntaxNode) { @@ -254,14 +274,14 @@ impl Formatter<'_> { .collect(); if !fields.is_empty() { - self.write("("); + self.write(" { "); for (i, field) in fields.iter().enumerate() { if i > 0 { self.write(", "); } self.fmt_variant_field(field); } - self.write(")"); + self.write(" }"); } } diff --git a/src/formatter/tests.rs b/src/formatter/tests.rs index 6b1b951..bd8047d 100644 --- a/src/formatter/tests.rs +++ b/src/formatter/tests.rs @@ -50,16 +50,16 @@ fn format_export() { #[test] fn format_type_record() { assert_fmt( - "type User = {id:string,name:string}", - "type User = {\n id: string,\n name: string,\n}", + "type User {id:string,name:string}", + "type User {\n id: string,\n name: string,\n}", ); } #[test] fn format_type_union() { assert_fmt( - "type Route = |Home|Profile(id:string)|NotFound", - "type Route =\n | Home\n | Profile(id: string)\n | NotFound", + "type Route {|Home|Profile{id:string}|NotFound}", + "type Route {\n | Home\n | Profile { id: string }\n | NotFound\n}", ); } diff --git a/src/interop/tsgo.rs b/src/interop/tsgo.rs index 9418a36..8aea5b1 100644 --- a/src/interop/tsgo.rs +++ b/src/interop/tsgo.rs @@ -1306,7 +1306,7 @@ const [count, setCount] = useState(0)"#; #[test] fn generate_probe_with_type_args() { let source = r#"import { useState } from "react" -type Todo = { text: string } +type Todo { text: string } const [todos, setTodos] = useState>([])"#; let program = Parser::new(source).parse_program().unwrap(); let probe = generate_probe(&program, &HashMap::new()); @@ -1343,7 +1343,7 @@ const [count, setCount] = useState(0)"#; #[test] fn type_decl_to_ts_record() { - let source = "type Todo = { text: string, done: bool }"; + let source = "type Todo { text: string, done: bool }"; let program = Parser::new(source).parse_program().unwrap(); if let ItemKind::TypeDecl(decl) = &program.items[0].kind { let ts = type_decl_to_ts(decl); @@ -1366,7 +1366,7 @@ const [count, setCount] = useState(0)"#; let source = r#" import trusted { useState } from "react" -type Todo = { text: string, done: bool } +type Todo { text: string, done: bool } const [todos, setTodos] = useState>([]) const [input, setInput] = useState("") "#; @@ -1411,7 +1411,7 @@ const [input, setInput] = useState("") let source = r#" import trusted { useState } from "react" -type Filter = | All | Active | Completed +type Filter { | All | Active | Completed } const [filter, setFilter] = useState(Filter.All) "#; let program = Parser::new(source).parse_program().unwrap(); @@ -1444,7 +1444,7 @@ const [filter, setFilter] = useState(Filter.All) #[test] fn type_expr_to_ts_option() { - let source = "type Foo = { bar: Option }"; + let source = "type Foo { bar: Option }"; let program = Parser::new(source).parse_program().unwrap(); if let ItemKind::TypeDecl(decl) = &program.items[0].kind { let ts = type_decl_to_ts(decl); diff --git a/src/lower.rs b/src/lower.rs index c72f383..b9817ce 100644 --- a/src/lower.rs +++ b/src/lower.rs @@ -610,6 +610,35 @@ impl<'src> Lowerer<'src> { fn lower_type_def_union(&mut self, node: &SyntaxNode) -> TypeDef { let mut variants = Vec::new(); + + // Check for newtype case: VARIANT_FIELD directly inside TYPE_DEF_UNION (no VARIANT wrapper) + // This happens for `type OrderId { number }` — synthesize a variant from the parent type name + let has_direct_field = node + .children() + .any(|c| c.kind() == SyntaxKind::VARIANT_FIELD); + if has_direct_field { + // Get the type name from the parent TYPE_DECL + if let Some(parent) = node.parent() + && let Some(type_name) = self.collect_idents_direct(&parent).first().cloned() + { + let span = self.node_span(node); + let mut fields = Vec::new(); + for child in node.children() { + if child.kind() == SyntaxKind::VARIANT_FIELD + && let Some(field) = self.lower_variant_field(&child) + { + fields.push(field); + } + } + variants.push(Variant { + name: type_name, + fields, + span, + }); + } + return TypeDef::Union(variants); + } + for child in node.children() { if child.kind() == SyntaxKind::VARIANT && let Some(variant) = self.lower_variant(&child) @@ -1456,7 +1485,7 @@ mod tests { #[test] fn type_record() { - let item = first_item("type User = { name: string, age: number }"); + let item = first_item("type User { name: string, age: number }"); let ItemKind::TypeDecl(decl) = item else { panic!("expected TypeDecl") }; @@ -1466,7 +1495,7 @@ mod tests { #[test] fn type_union() { - let item = first_item("type Color = | Red | Green | Blue"); + let item = first_item("type Color { | Red | Green | Blue }"); let ItemKind::TypeDecl(decl) = item else { panic!("expected TypeDecl") }; diff --git a/src/lsp/tests.rs b/src/lsp/tests.rs index ae0ca48..f0bf2e9 100644 --- a/src/lsp/tests.rs +++ b/src/lsp/tests.rs @@ -130,7 +130,7 @@ fn symbol_index_const() { #[test] fn symbol_index_type() { - let source = "type User = { name: string, age: number }"; + let source = "type User { name: string, age: number }"; let program = Parser::new(source).parse_program().unwrap(); let index = SymbolIndex::build(&program); let syms = index.find_by_name("User"); @@ -150,7 +150,7 @@ fn symbol_index_import() { #[test] fn symbol_index_union_variants() { - let source = "type Color = | Red | Green | Blue"; + let source = "type Color { | Red | Green | Blue }"; let program = Parser::new(source).parse_program().unwrap(); let index = SymbolIndex::build(&program); assert_eq!(index.find_by_name("Color").len(), 1); @@ -643,11 +643,11 @@ fn hover_const_without_annotation_detail_lacks_type_before_fix() { #[test] fn match_context_detects_variants() { // Build the index from valid source with the type declaration - let valid_source = "type Color = | Red | Green | Blue"; + let valid_source = "type Color { | Red | Green | Blue }"; let (index, _) = build_index_and_types(valid_source); // Simulate incomplete source as it would appear in the editor - let editor_source = "type Color = | Red | Green | Blue\nmatch Color {\n "; + let editor_source = "type Color { | Red | Green | Blue }\nmatch Color {\n "; let offset = editor_source.len(); let variants = detect_match_context(editor_source, offset, &index); assert!(variants.is_some(), "should detect match context"); @@ -659,10 +659,10 @@ fn match_context_detects_variants() { #[test] fn match_context_not_detected_outside_match() { - let valid_source = "type Color = | Red | Green | Blue"; + let valid_source = "type Color { | Red | Green | Blue }"; let (index, _) = build_index_and_types(valid_source); - let editor_source = "type Color = | Red | Green | Blue\nconst x = "; + let editor_source = "type Color { | Red | Green | Blue }\nconst x = "; let offset = editor_source.len(); let variants = detect_match_context(editor_source, offset, &index); assert!( @@ -837,7 +837,7 @@ fn import_path_at_offset_single_quotes() { #[test] fn symbol_index_for_block_function_detail() { let source = r#" -type Todo = { text: string, done: boolean } +type Todo { text: string, done: boolean } for Array { export fn remaining(self) -> number { 0 } } diff --git a/src/parser/tests.rs b/src/parser/tests.rs index d38d059..4de0950 100644 --- a/src/parser/tests.rs +++ b/src/parser/tests.rs @@ -751,7 +751,7 @@ fn type_alias() { #[test] fn type_record() { - match first_item("type User = { id: UserId, name: string }") { + match first_item("type User { id: UserId, name: string }") { ItemKind::TypeDecl(decl) => { assert_eq!(decl.name, "User"); match decl.def { @@ -765,7 +765,7 @@ fn type_record() { #[test] fn type_record_with_spread() { - match first_item("type B = { ...A, extra: string }") { + match first_item("type B { ...A, extra: string }") { ItemKind::TypeDecl(decl) => { assert_eq!(decl.name, "B"); match decl.def { @@ -785,7 +785,7 @@ fn type_record_with_spread() { #[test] fn type_record_with_multiple_spreads() { - match first_item("type C = { ...A, ...B, extra: string }") { + match first_item("type C { ...A, ...B, extra: string }") { ItemKind::TypeDecl(decl) => { assert_eq!(decl.name, "C"); match decl.def { @@ -804,7 +804,7 @@ fn type_record_with_multiple_spreads() { #[test] fn type_union() { - let input = r#"type Route = | Home | Profile(id: string) | NotFound"#; + let input = r#"type Route { | Home | Profile { id: string } | NotFound }"#; match first_item(input) { ItemKind::TypeDecl(decl) => { assert_eq!(decl.name, "Route"); @@ -1036,7 +1036,7 @@ fn full_program() { let input = r#" import { useState } from "react" -type Todo = { id: string, text: string, done: boolean } +type Todo { id: string, text: string, done: boolean } export fn TodoApp() { const [todos, setTodos] = useState([]) @@ -1052,7 +1052,7 @@ export fn TodoApp() { #[test] fn for_block_basic() { let input = r#" -type User = { name: string } +type User { name: string } for User { fn display(self) -> string { self.name @@ -1076,7 +1076,7 @@ for User { #[test] fn for_block_multiple_functions() { let input = r#" -type User = { name: string, age: number } +type User { name: string, age: number } for User { fn display(self) -> string { self.name } fn isAdult(self) -> bool { self.age >= 18 } @@ -1126,7 +1126,7 @@ for Array { #[test] fn self_as_expression() { let input = r#" -type User = { name: string } +type User { name: string } for User { fn getName(self) -> string { self.name } } @@ -1161,7 +1161,7 @@ fn for_block_error_non_fn() { #[test] fn inline_for_declaration() { let input = r#" -type User = { name: string } +type User { name: string } for User fn display(self) -> string { self.name } @@ -1182,7 +1182,7 @@ for User fn display(self) -> string { #[test] fn inline_for_declaration_exported() { let input = r#" -type User = { name: string } +type User { name: string } export for User fn display(self) -> string { self.name } @@ -1201,7 +1201,7 @@ export for User fn display(self) -> string { #[test] fn inline_for_multiple_declarations() { let input = r#" -type User = { name: string } +type User { name: string } for User fn display(self) -> string { self.name } export for User fn greet(self, greeting: string) -> string { `${greeting}` } "#; @@ -1268,7 +1268,7 @@ export for User async fn fetchData(self) -> string { self.name } #[test] fn mixed_inline_and_block_for() { let input = r#" -type User = { name: string } +type User { name: string } export for User fn display(self) -> string { self.name } for User { export fn greet(self) -> string { self.name } @@ -1947,7 +1947,7 @@ fn collect_block_with_const() { #[test] fn newtype_parses_as_single_variant_union() { - let item = first_item("type ProductId = ProductId(number)"); + let item = first_item("type ProductId { number }"); match item { ItemKind::TypeDecl(decl) => { assert_eq!(decl.name, "ProductId"); @@ -1970,18 +1970,17 @@ fn newtype_parses_as_single_variant_union() { } #[test] -fn newtype_with_named_field_parses() { - let item = first_item("type UserId = UserId(value: number)"); +fn newtype_with_named_field_is_record() { + // With new syntax, `{ value: number }` is a record, not a newtype + let item = first_item("type UserId { value: number }"); match item { ItemKind::TypeDecl(decl) => { assert_eq!(decl.name, "UserId"); - match &decl.def { - TypeDef::Union(variants) => { - assert_eq!(variants.len(), 1); - assert_eq!(variants[0].fields[0].name.as_deref(), Some("value")); - } - other => panic!("expected Union, got {other:?}"), - } + assert!( + matches!(decl.def, TypeDef::Record(ref fields) if fields.len() == 1), + "expected Record, got {:?}", + decl.def + ); } other => panic!("expected TypeDecl, got {other:?}"), } diff --git a/src/resolve.rs b/src/resolve.rs index 040e014..956eb4c 100644 --- a/src/resolve.rs +++ b/src/resolve.rs @@ -551,7 +551,7 @@ mod tests { ("main.fl", ""), ( "types.fl", - "type WithRating = { rating: number }\nexport type Product = { ...WithRating, title: string }", + "type WithRating { rating: number }\nexport type Product { ...WithRating, title: string }", ), ]); let main_path = base.join("main.fl"); @@ -599,7 +599,7 @@ mod tests { ("main.fl", ""), ( "types.fl", - "type A = { x: number }\ntype B = { ...A, y: string }\nexport type C = { ...B, z: boolean }", + "type A { x: number }\ntype B { ...A, y: string }\nexport type C { ...B, z: boolean }", ), ]); let main_path = base.join("main.fl"); diff --git a/tests/fixtures/constructors.fl b/tests/fixtures/constructors.fl index e20f895..6771c30 100644 --- a/tests/fixtures/constructors.fl +++ b/tests/fixtures/constructors.fl @@ -1,4 +1,4 @@ -type Point = { +type Point { x: number, y: number, } diff --git a/tests/fixtures/deriving.fl b/tests/fixtures/deriving.fl index 8ade5bf..23f3ef8 100644 --- a/tests/fixtures/deriving.fl +++ b/tests/fixtures/deriving.fl @@ -1,12 +1,12 @@ // Record type with deriving Display -type Color = { +type Color { r: number, g: number, b: number, } deriving (Display) // Record type with Display only (Eq is built-in via ==) -type User = { +type User { id: string, name: string, email: string, diff --git a/tests/fixtures/errors/trait_missing_method.fl b/tests/fixtures/errors/trait_missing_method.fl index 5a413fc..d55ac62 100644 --- a/tests/fixtures/errors/trait_missing_method.fl +++ b/tests/fixtures/errors/trait_missing_method.fl @@ -2,7 +2,7 @@ trait Display { fn display(self) -> string } -type User = { +type User { name: string, age: number } diff --git a/tests/fixtures/errors/trait_unknown.fl b/tests/fixtures/errors/trait_unknown.fl index 2bbbb0a..b7d08a8 100644 --- a/tests/fixtures/errors/trait_unknown.fl +++ b/tests/fixtures/errors/trait_unknown.fl @@ -1,4 +1,4 @@ -type User = { +type User { name: string } diff --git a/tests/fixtures/for_blocks.fl b/tests/fixtures/for_blocks.fl index b840aa9..63d6c7c 100644 --- a/tests/fixtures/for_blocks.fl +++ b/tests/fixtures/for_blocks.fl @@ -1,4 +1,4 @@ -type User = { +type User { name: string, age: number, active: boolean diff --git a/tests/fixtures/match_expr.fl b/tests/fixtures/match_expr.fl index 3ae310f..1d1face 100644 --- a/tests/fixtures/match_expr.fl +++ b/tests/fixtures/match_expr.fl @@ -1,7 +1,8 @@ -type Route = +type Route { | Home - | Profile(id: string) + | Profile { id: string } | NotFound +} const route = Home @@ -12,9 +13,10 @@ const _page = match route { } // Match with guards -type User = - | Adult(age: number) - | Child(age: number) +type User { + | Adult { age: number } + | Child { age: number } +} const user = Adult(age: 25) diff --git a/tests/fixtures/record_spread.fl b/tests/fixtures/record_spread.fl index 9bc353d..bae6b98 100644 --- a/tests/fixtures/record_spread.fl +++ b/tests/fixtures/record_spread.fl @@ -1,23 +1,23 @@ -type BaseProps = { +type BaseProps { className: string, disabled: boolean, } -type ButtonProps = { +type ButtonProps { ...BaseProps, onClick: fn() -> (), label: string, } -type A = { +type A { x: number, } -type B = { +type B { y: string, } -type C = { +type C { ...A, ...B, z: boolean, diff --git a/tests/fixtures/traits.fl b/tests/fixtures/traits.fl index b815394..c30149f 100644 --- a/tests/fixtures/traits.fl +++ b/tests/fixtures/traits.fl @@ -12,7 +12,7 @@ trait Eq { } // Type definition -type User = { +type User { name: string, age: number } diff --git a/tests/fixtures/types.fl b/tests/fixtures/types.fl index 2f9ed0b..cd9af11 100644 --- a/tests/fixtures/types.fl +++ b/tests/fixtures/types.fl @@ -1,11 +1,12 @@ -type User = { +type User { id: string, name: string, email: string, } -type Status = +type Status { | Active - | Inactive(reason: string) + | Inactive { reason: string } +} type UserId = Brand diff --git a/tests/fixtures/unit_type.fl b/tests/fixtures/unit_type.fl index f47f56c..bfafcd2 100644 --- a/tests/fixtures/unit_type.fl +++ b/tests/fixtures/unit_type.fl @@ -9,6 +9,6 @@ fn deleteUser(id: string) -> Result<(), string> { } // Callback with unit return -type ButtonProps = { +type ButtonProps { onClick: fn() -> () }