diff --git a/docs/design.md b/docs/design.md index 20ddb0be..fa95f5b7 100644 --- a/docs/design.md +++ b/docs/design.md @@ -42,7 +42,7 @@ All four of TypeScript's `?` uses (`?.`, `??`, `?:`, `? :`) are removed. `?` now - Dot shorthand `.field` for implicit field-access closures - JSX / TSX (full support) - Generics (types and functions), template literals -- Async via `Promise.await` stdlib function (no `async`/`await` keywords) +- Async via `|> await` (or `|> Promise.await`) — no `async` keyword; return type must be `Promise` - Destructuring, spread, rest params - `||` (boolean OR), `&&`, `!` (boolean operators) - `==` (but only between same types — structural equality on objects) diff --git a/docs/llms.txt b/docs/llms.txt index 8ba9b8af..4eea58f0 100644 --- a/docs/llms.txt +++ b/docs/llms.txt @@ -399,11 +399,12 @@ items |> Array.dropWhile(.done) // drop while predicate holds ["a", "b", "c"] |> Array.intersperse(", ") // ["a", ", ", "b", ", ", "c"] // Promise helpers -// Promise.await: Promise -> T (compiles to await, infers async on enclosing fn) -const users = Promise.all([fetchUser(1), fetchUser(2)]) |> Promise.await -const fastest = Promise.race([fetchFromCDN(url), fetchFromOrigin(url)]) |> Promise.await -const results = Promise.allSettled([fetchA(), fetchB()]) |> Promise.await // Array> -Promise.delay(1000) |> Promise.await // wait 1 second +// Promise.await (or bare `await`): Promise -> T (compiles to await, infers async on enclosing fn) +// Functions using await must return Promise (like ? requires Result) +const users = Promise.all([fetchUser(1), fetchUser(2)]) |> await +const fastest = Promise.race([fetchFromCDN(url), fetchFromOrigin(url)]) |> await +const results = Promise.allSettled([fetchA(), fetchB()]) |> await // Array> +Promise.delay(1000) |> await // wait 1 second // npm imports are untrusted by default: auto-wrapped in try/catch, return Result import { npmAsyncFn } from "some-lib" @@ -704,7 +705,7 @@ match key { | `void` | Unit type `()` | | `as` (type assertion) | Type guards or `match` | | `return` | Implicit returns — last expression is the return value | -| `async`/`await` | `Promise.await` stdlib function; return type `Promise` | +| `async`/`await` | `\|> await` (or `\|> Promise.await`); return type `Promise` | ## Compilation Examples @@ -727,7 +728,7 @@ match key { | `Console.log(x)` | `console.log(x)` | | `(1, 2)` | `[1, 2] as const` | | `()` (unit) | `undefined` | -| `expr \|> Promise.await` | `await expr` (enclosing fn inferred `async`) | +| `expr \|> await` | `await expr` (enclosing fn inferred `async`) | ## CLI Commands @@ -749,7 +750,7 @@ floe lsp # start language server (used by editors) 1. **No semicolons** in Floe source 2. **Exported functions must have explicit return types** with `->` 3. **match is exhaustive** — all variants must be handled -4. **`?` only works** inside functions returning `Result` or `Option` +4. **`?` only works** inside functions returning `Result` or `Option`; **`|> await` requires** return type `Promise` 5. **`==` is structural equality** — only between same types 6. **No bracket indexing** — use `Map.get(map, key)` for maps, `Record.get(obj, key)` for plain-object records, `Array.get(arr, i)` for arrays (all return `Option`). `obj[key]` is not valid Floe syntax 7. **npm imports are untrusted by default** — calls auto-wrap in `Result`; use `import trusted` for safe imports diff --git a/docs/site/src/content/docs/docs/guide/from-typescript.md b/docs/site/src/content/docs/docs/guide/from-typescript.md index b7a928a1..6b09e1b9 100644 --- a/docs/site/src/content/docs/docs/guide/from-typescript.md +++ b/docs/site/src/content/docs/docs/guide/from-typescript.md @@ -9,7 +9,7 @@ Floe is designed to be familiar to TypeScript developers. - Import/export syntax - Template literals - JSX -- Async (via `Promise.await` stdlib function instead of keywords) +- Async (via `|> await` instead of keywords; return type must be `Promise`) - Type annotations - Generics @@ -27,7 +27,7 @@ Floe is designed to be familiar to TypeScript developers. | `{x && }` | `Option.map` | `{x \|> Option.map((v) => )}` | | `T \| null` | `Option` | `Some(value)` / `None` | | `throw` | `Result` | `Ok(value)` / `Err(error)` | -| `async`/`await` | `Promise.await` | `expr \|> Promise.await` (compiler infers `async`) | +| `async`/`await` | `\|> await` | `expr \|> await` (compiler infers `async`; return type must be `Promise`) | ## What's Removed diff --git a/docs/site/src/content/docs/docs/guide/functions.md b/docs/site/src/content/docs/docs/guide/functions.md index 69f83a37..c62d8767 100644 --- a/docs/site/src/content/docs/docs/guide/functions.md +++ b/docs/site/src/content/docs/docs/guide/functions.md @@ -128,15 +128,17 @@ type Callback = () -> () ### Async Functions -There is no `async` keyword. A function is async when its body uses `Promise.await`. The return type explicitly uses `Promise`: +There is no `async` keyword. A function is async when its body uses `|> await` (or `|> Promise.await`). The return type must explicitly use `Promise` -- the compiler enforces this, just like `?` requires `Result`: ```floe fn fetchUser(id: string) -> Promise { - const response = fetch(`/api/users/${id}`) |> Promise.await - response.json() |> Promise.await + const response = fetch(`/api/users/${id}`) |> await + response.json() |> await } ``` +For functions without a return type annotation, the compiler infers `Promise` automatically. + ## Callback Flattening with `use` The `use` keyword flattens nested callbacks. The rest of the block becomes the callback body: diff --git a/docs/site/src/content/docs/docs/reference/stdlib/promise.md b/docs/site/src/content/docs/docs/reference/stdlib/promise.md index d5ea7d36..42f8b1a6 100644 --- a/docs/site/src/content/docs/docs/reference/stdlib/promise.md +++ b/docs/site/src/content/docs/docs/reference/stdlib/promise.md @@ -21,17 +21,41 @@ Functions for working with `Promise` values. ## `Promise.await` -`Promise.await` is a stdlib function with signature `Promise -> T`. It compiles to JavaScript's `await` keyword. Using `Promise.await` anywhere in a function body causes the compiler to infer `async` on the emitted function -- no `async` keyword is needed in Floe. +`Promise.await` is a stdlib function with signature `Promise -> T`. It compiles to JavaScript's `await` keyword. Using `Promise.await` anywhere in a function body causes the compiler to infer `async` on the emitted function -- no `async` keyword is needed in Floe. `await` is also available as a bare shorthand in pipes: `expr |> await`. ```floe fn fetchUser(id: string) -> Promise { - const response = fetch(`/api/users/${id}`) |> Promise.await - response.json() |> Promise.await + const response = fetch(`/api/users/${id}`) |> await + response.json() |> await } // Compiles to: async function fetchUser(id: string): Promise { ... } ``` -The return type must explicitly use `Promise`, making async behavior visible to callers. +The return type must explicitly use `Promise`, making async behavior visible to callers. The compiler enforces this -- using `await` in a function with a non-`Promise` return type is a compile error: + +```floe +// Error: function `bad` uses `await` but return type is `string`, not `Promise` +fn bad() -> string { + getData() |> await +} + +// OK +fn good() -> Promise { + getData() |> await +} +``` + +This parallels how `?` requires the function to return `Result`. Both operators change the function contract, and both require explicit return types. + +For functions without a return type annotation, the compiler infers `Promise` automatically: + +```floe +fn fetchName(id: string) { + const user = fetchUser(id) |> await + user.name +} +// Inferred return type: Promise +``` ## Untrusted async imports diff --git a/src/checker.rs b/src/checker.rs index 5074ebf1..494ea092 100644 --- a/src/checker.rs +++ b/src/checker.rs @@ -66,11 +66,22 @@ pub fn mark_async_functions(program: &mut Program) { _ => {} } } - // Also mark arrows inside all expressions - crate::walk::walk_program_mut(program, &mut |expr| { - if let ExprKind::Arrow { async_fn, body, .. } = &mut expr.kind { + // Mark nested fn declarations and arrows inside all expressions. + // The walker visits Block/Collect items recursively, so this catches + // nested `fn` declarations (e.g. handleDragEnd inside a component body). + crate::walk::walk_program_mut(program, &mut |expr| match &mut expr.kind { + ExprKind::Arrow { async_fn, body, .. } => { *async_fn = body_has_promise_await(body) || body_has_throwing_call(body, &throwing); } + ExprKind::Block(items) | ExprKind::Collect(items) => { + for item in items { + if let ItemKind::Function(decl) = &mut item.kind { + decl.async_fn = body_has_promise_await(&decl.body) + || body_has_throwing_call(&decl.body, &throwing); + } + } + } + _ => {} }); } @@ -116,16 +127,20 @@ fn body_has_throwing_call(expr: &Expr, throwing: &HashSet) -> bool { walk(expr, throwing) } -/// Check if an expression body contains a `Promise.await` member access. -fn body_has_promise_await(expr: &Expr) -> bool { +/// Check if an expression body contains a `Promise.await` member access +/// or bare `await` identifier (shorthand for `Promise.await` in pipes). +pub(crate) fn body_has_promise_await(expr: &Expr) -> bool { fn walk(expr: &Expr) -> bool { match &expr.kind { + // Qualified: `Promise.await` ExprKind::Member { object, field } if field == "await" && matches!(&object.kind, ExprKind::Identifier(m) if m == "Promise") => { true } + // Bare shorthand: `|> await` + ExprKind::Identifier(name) if name == "await" => true, ExprKind::Call { callee, args, .. } => { walk(callee) || args.iter().any(|a| match a { diff --git a/src/checker/error_codes.rs b/src/checker/error_codes.rs index f9425ecd..646833b0 100644 --- a/src/checker/error_codes.rs +++ b/src/checker/error_codes.rs @@ -111,6 +111,8 @@ pub enum ErrorCode { UnknownBinding, /// `try` used on a Floe function (which never throws). TryOnFloeFunction, + /// Function uses `await` but return type is not `Promise`. + MissingPromiseReturn, } impl ErrorCode { @@ -161,6 +163,7 @@ impl ErrorCode { Self::SuspiciousBinding => "W005", Self::UnknownBinding => "W006", Self::TryOnFloeFunction => "W007", + Self::MissingPromiseReturn => "E041", } } } diff --git a/src/checker/items.rs b/src/checker/items.rs index 69fc1b7c..1e9185a3 100644 --- a/src/checker/items.rs +++ b/src/checker/items.rs @@ -240,6 +240,7 @@ impl Checker { fn is_promise_await_member(expr: &Expr) -> bool { matches!(&expr.kind, ExprKind::Member { object, field } if field == "await" && matches!(&object.kind, ExprKind::Identifier(m) if m == "Promise")) + || matches!(&expr.kind, ExprKind::Identifier(name) if name == "await") } /// Infer the type of an element from an array/tuple destructuring at a given index. @@ -442,12 +443,19 @@ impl Checker { // Check body let body_type = self.check_expr(&decl.body); + let uses_await = super::body_has_promise_await(&decl.body); // When no return type annotation, infer from body and update the function type if decl.return_type.is_none() && !matches!(body_type, Type::Var(_) | Type::Unknown) { + // If the body uses await, wrap the inferred return type in Promise + let inferred_return = if uses_await { + Type::Promise(Box::new(body_type.clone())) + } else { + body_type.clone() + }; let fn_type = Type::Function { params: param_types.clone(), - return_type: Box::new(body_type.clone()), + return_type: Box::new(inferred_return), required_params, }; // Update in the name_types map for hover display @@ -460,6 +468,21 @@ impl Checker { // Check return type compatibility if let Some(ref declared_return) = decl.return_type { let resolved = self.resolve_type(declared_return); + + // Error if function uses await but return type is not Promise + if uses_await && !matches!(resolved, Type::Promise(_)) { + self.emit_error_with_help( + format!( + "function `{}` uses `await` but return type is `{}`, not `Promise<{}>`", + decl.name, resolved, body_type + ), + span, + ErrorCode::MissingPromiseReturn, + format!("expected `Promise<{}>`", body_type), + "change the return type to `Promise`, or remove the `await`", + ); + } + // For functions with Promise return type, unwrap Promise since // the body type is the inner value (async wrapping is automatic) let effective_declared = match &resolved { diff --git a/src/checker/tests.rs b/src/checker/tests.rs index ec9ed27c..69334f42 100644 --- a/src/checker/tests.rs +++ b/src/checker/tests.rs @@ -2874,7 +2874,65 @@ fn stdlib_member_access_still_works() { // ── Promise / Promise.await ───────────────────────────────── -// ── Promise / Promise.await ───────────────────────────────── +#[test] +fn await_without_promise_return_type_errors() { + let diags = check( + r#" +fn getData() -> Promise { "hello" } +fn bad() -> string { getData() |> Promise.await } +"#, + ); + assert!( + has_error_containing(&diags, "uses `await`"), + "should error when await used but return type is not Promise, got: {:?}", + diags.iter().map(|d| &d.message).collect::>() + ); +} + +#[test] +fn await_with_promise_return_type_ok() { + let diags = check( + r#" +fn getData() -> Promise { "hello" } +fn good() -> Promise { getData() |> Promise.await } +"#, + ); + assert!( + !has_error_containing(&diags, "uses `await`"), + "should not error when return type is Promise, got: {:?}", + diags.iter().map(|d| &d.message).collect::>() + ); +} + +#[test] +fn bare_await_without_promise_return_type_errors() { + let diags = check( + r#" +fn getData() -> Promise { "hello" } +fn bad() -> string { getData() |> await } +"#, + ); + assert!( + has_error_containing(&diags, "uses `await`"), + "bare |> await should also trigger the error, got: {:?}", + diags.iter().map(|d| &d.message).collect::>() + ); +} + +#[test] +fn await_inferred_return_type_is_promise() { + let diags = check( + r#" +fn getData() -> Promise { "hello" } +fn inferred() { getData() |> Promise.await } +"#, + ); + assert!( + !has_error_containing(&diags, "uses `await`"), + "unannotated async fn should not error, got: {:?}", + diags.iter().map(|d| &d.message).collect::>() + ); +} #[test] fn promise_return_type_unwrap() { diff --git a/src/codegen/expr.rs b/src/codegen/expr.rs index f2f2607a..fcecd854 100644 --- a/src/codegen/expr.rs +++ b/src/codegen/expr.rs @@ -808,7 +808,7 @@ impl Codegen { } /// Check if an expression tree contains a Promise.await stdlib call. -/// Detects `expr |> Promise.await` and `Promise.await(expr)` patterns. +/// Detects `expr |> Promise.await`, `Promise.await(expr)`, and bare `|> await` patterns. pub(super) fn expr_contains_await(expr: &Expr) -> bool { match &expr.kind { // Direct member access: Promise.await (in pipe target position) @@ -818,6 +818,8 @@ pub(super) fn expr_contains_await(expr: &Expr) -> bool { { true } + // Bare shorthand: `|> await` + ExprKind::Identifier(name) if name == "await" => true, ExprKind::Call { callee, args, .. } => { expr_contains_await(callee) || args.iter().any(|a| match a { @@ -833,13 +835,16 @@ pub(super) fn expr_contains_await(expr: &Expr) -> bool { | ExprKind::Grouped(operand) | ExprKind::Unwrap(operand) | ExprKind::Spread(operand) => expr_contains_await(operand), - ExprKind::Collect(items) | ExprKind::Block(items) => items.iter().any(|item| { - if let ItemKind::Expr(e) = &item.kind { - expr_contains_await(e) - } else { - false - } - }), + ExprKind::Match { subject, arms } => { + expr_contains_await(subject) || arms.iter().any(|a| expr_contains_await(&a.body)) + } + ExprKind::Collect(items) | ExprKind::Block(items) => { + items.iter().any(|item| match &item.kind { + ItemKind::Expr(e) => expr_contains_await(e), + ItemKind::Const(c) => expr_contains_await(&c.value), + _ => false, + }) + } _ => false, } } diff --git a/src/codegen/tests.rs b/src/codegen/tests.rs index 1dc9b2f2..890949ae 100644 --- a/src/codegen/tests.rs +++ b/src/codegen/tests.rs @@ -645,6 +645,41 @@ fn promise_await_pipe() { assert!(result.contains("await fetchData()")); } +#[test] +fn bare_await_shorthand_emits_async_function() { + let result = emit_with_types("fn fetch() -> Promise { getData() |> await }"); + assert!( + result.starts_with("async function fetch()"), + "bare `|> await` should infer async on enclosing function, got: {result}" + ); + assert!(result.contains("await getData()")); +} + +#[test] +fn bare_await_shorthand_pipe() { + let result = emit_with_types("const _x = fetchData() |> await"); + assert!(result.contains("await fetchData()")); +} + +#[test] +fn nested_fn_with_promise_await_emits_async() { + let result = + emit_with_types("fn outer() { fn inner() { getData() |> Promise.await } inner() }"); + assert!( + result.contains("async function inner()"), + "nested fn with Promise.await should be async, got: {result}" + ); +} + +#[test] +fn nested_fn_with_bare_await_emits_async() { + let result = emit_with_types("fn outer() { fn inner() { getData() |> await } inner() }"); + assert!( + result.contains("async function inner()"), + "nested fn with bare await should be async, got: {result}" + ); +} + // ── Implicit Return ────────────────────────────────────────── #[test] diff --git a/src/interop/tsgo/probe_gen.rs b/src/interop/tsgo/probe_gen.rs index 47496c2b..407f1598 100644 --- a/src/interop/tsgo/probe_gen.rs +++ b/src/interop/tsgo/probe_gen.rs @@ -1364,10 +1364,11 @@ fn expr_has_promise_await(expr: &Expr) -> bool { } } -/// Check if an expression is `Promise.await` member access. +/// Check if an expression is `Promise.await` member access or bare `await` shorthand. fn is_promise_await_member(expr: &Expr) -> bool { matches!(&expr.kind, ExprKind::Member { object, field } if field == "await" && matches!(&object.kind, ExprKind::Identifier(m) if m == "Promise")) + || matches!(&expr.kind, ExprKind::Identifier(name) if name == "await") } /// Unwrap wrappers to find the inner expression.