Esc
Start typing to search...

Changelog

All notable changes to Keel are documented here. This project follows Keep a Changelog and Semantic Versioning.

Unreleased

45 changes

Added

20 items
  • Record spread expression{ ..base } copies all fields from a record, and { ..base, field = val } copies and overrides or adds fields. Compiles via CopyRecord + StoreNamedField. Type inference merges the extra fields into the base record type.
  • Type aliases in task signaturestask expecting MyInputs exposing MyOutputs now works when MyInputs/MyOutputs are type aliases that resolve to record types. Fields are extracted at compile time from the resolved alias.
  • Bare task declarationtask with no expecting or exposing clauses is now valid syntax. Useful as a marker that a file is a task entry point with no declared contract.
  • Aliased arguments in run expressionsrun "file.kl" { param = expr } lets callers pass a value under a different name than their local variable. Bare { x } remains shorthand for { x = x }. Mixed forms like { a, b = myval } are also supported. The type checker validates the aliased expression's type against the callee's declared parameter type.
  • VariableInfo::binding_idVariableInfo now carries a binding_id: usize field set to the VM register index for the binding. This uniquely identifies each binding even when two variables share the same name (e.g. shadowed variables in nested scopes). Consumers use it to correlate VariableInfo across updates.
  • Enum expansion in inspecthas_children, value_children, and navigate now handle OutputValue::Enum variants whose argument is a Record. Such enum values are expandable in the variable inspector: children are the record fields (flattened), and field paths are navigable directly. Scalar and tuple enum arguments remain non-expandable.
  • inspect::list_types — New list_types(scope, interner) -> Vec function in keel_core::inspect. Returns all user-defined enum types visible in scope (filtering out internal/stdlib enums whose names start with a lowercase letter). Consumers (keel-tui env inspector, keel-jupyter-kernel Positron variables pane) can display the variant list for a column's ValueLabelSet.
  • ChildInfo value-label fieldsChildInfo now carries value_label_strings: Vec and value_label_name: Option. For DataFrame columns that have a ValueLabelSet, value_label_strings contains the label strings and value_label_name contains the enum type name (e.g. "Origin"). Non-DataFrame children have empty/None values.
  • DataFrame.fromRecords compile-time value-label embedding — When DataFrame.fromRecords is called with a literal list of records that contain ValueLabel.value SomeEnum::Variant fields, the compiler now emits a compile-time HashMap constant and redirects to an internal _fromRecordsWithLabels function. Value label metadata is preserved in the resulting DataFrame without any runtime overhead.
  • inspect module — Shared runtime inspection utilities (list_variables, get_children) extracted into keel_core::inspect. Returns neutral VariableInfo / ChildInfo structs consumed by keel-repl (:env), keel-jupyter-kernel (Positron variables pane), and keel-tui (environment inspector pane). Removes duplicated variable-enumeration code from each consumer.
  • Enum shorthand for ValueLabel DataFrame column types — Enums whose variants all carry ValueLabel metadata can now be used directly as a column type: score: Score is equivalent to the verbose score: [(1, Score::Low), (2, Score::Mid), (3, Score::High)]. The compiler derives the full int+label contract from the enum's ValueLabel declarations at compile time. Plain enums (no ValueLabel) on integer columns still produce a type-mismatch error.
  • Multiline task declarationstask expecting (...) exposing (...) now supports Elm-style multiline syntax. Keywords (expecting, exposing) and their parameter lists can break across lines with indentation. Useful for tasks with many parameters. Single-line syntax remains supported.
  • DataFrame passthrough type inference for metadata operationssetVarLabel, removeVarLabel, removeValueLabels, setMeta, setColumnMeta, and setDisplayMode now preserve the input DataFrame schema in type inference (passthrough), so task expose type validation works correctly after these operations.
  • setValueLabels/setValueLabelsStrict retype column to enum in task expose contracts — When the second argument is ValueLabelSet.fromType SomeEnum, the type checker now updates the target column's type from Float to SomeEnum in the resulting DataFrame schema. Previously the column stayed Float, causing a spurious type mismatch on task expose declarations like result : DataFrame { :CO: Origin }.
  • Symbol-syntax field names in type annotations — Record and DataFrame type annotations now accept symbol-syntax field names for columns that don't conform to lowercase identifier syntax: { :Name : String }, { :"Total Revenue" : Int }, or mixed { id : Int, :Name : String }. Bare symbols also support uppercase starts (:Name), previously only lowercase (:name) was allowed. Type display renders non-lowercase fields with symbol syntax.
  • Symbol ↔ String type compatibility — Symbols (:name) are now compatible with String in the type system, so DataFrame column-name parameters accept both :name and "name". This applies to select, remove, rename, column, sort, sortDesc, unique, groupBy, join, pivot, partitionBy, orderBy, Expr.col, Expr.named, and all window/cumulative/rolling functions. Mixed lists like [:name, "age"] also work. Compile-time column validation and schema propagation recognize Symbol literals alongside String literals. 21 new tests covering type compatibility, column validation, schema propagation, error cases, and String regression.
  • Compile-time type checking for Expr.cond branch consistency — The type signature changed from [(Expr, a)] -> b -> Expr to [(Expr, a)] -> a -> Expr, so all branch values and the default must share the same type. Mismatches like Expr.cond [(cond, "minor")] 42 (String vs Int) are now caught at compile time instead of failing at Polars runtime.
  • Broader scalar ↔ Expr coercionunify_types, match_types, and are_types_compatible now accept all scalar types (Int, Float, String, Boolean, Decimal, Symbol) as compatible with Expr, not just Symbol. This enables mixed scalar/Expr lists like [(cond, col "x" * 2), (cond, 99)] to unify correctly.
  • Infix operators for DataFrame.Expr — Arithmetic (+, -, *, /, %, ^), comparison (==, !=, <, <=, >, >=), boolean (&&, ||), negation (-), and logical not (not) now work directly on Expr values. When either operand is an Expr, scalars are automatically coerced to literal expressions. && and || automatically detect Expr operands and compile to Polars' .and() / .or() instead of short-circuit evaluation. This enables natural syntax like col "price" * 1.1, col "age" >= 18 && col "active", and col "x" + col "y" instead of requiring the pipe API. 71 tests covering arithmetic, comparison, boolean logic, chained operations, filters, edge cases, and backward compatibility.
  • SPSS and pandas column labels from ParquetDataFrame.readParquet and DataFrame.readParquetColumns now also read variable labels from the spss_meta Parquet key written by pyreadstat (format: {"column_labels": {"col": "label"}}), and from pandas column metadata embedded in the pandas Parquet key. 19 tests pass including a live NIS2NL.parquet test.

Changed

8 items
  • Task expecting/exposing clauses use bracestask expecting { x : Int } exposing { result : Int } replaces the old parenthesis syntax task expecting (x : Int) exposing (result : Int). Both clauses now accept any type expression (record literal, type alias, or parameterised type). Declaration::Task AST changed: params: Vec<(String, Type)> + outputs: Vec replaced by expecting: Option + exposing: Option.
  • run argument is any record expression — The argument to run "file.kl" is now parsed as a general expression rather than the ImportFileVars enum. Any expression that produces a record is accepted: run "f.kl" { x, y }, run "f.kl" inputs (variable), run "f.kl" { ..base } (spread), or run "f.kl" (no argument). Expr::RunFile.pass_vars: ImportFileVars is replaced by arg: Option>.
  • types::format_field_name is now pub — The function that renders record field names with symbol syntax (:Name, :"name with spaces") is now publicly accessible so keel-fmt can use it when formatting type aliases.
  • Stable Rust toolchain — Removed nightly compiler requirement. Moved to stable Rust (1.85+). Deleted vendored polars-ops and ethnum patches (they were nightly-only workarounds). Dev shells updated accordingly.
  • Task syntax redesignTask.run/Task.define replaced with keyword-based syntax. Declarations use task expecting (...) exposing (...) instead of Task.define (...) -> (...). Caller uses run "file.kl" { x, y } with record-style braces instead of Task.run "file.kl" (x, y). Three keywords added: task, run, expecting (plus existing exposing).
  • Task/module-first orderingtask declarations and file-level module exposing (...) must now be the first declaration in the file, before any imports. Imports go inside the task/module scope. Previously, task declarations were allowed after imports.
  • DataFrame stdlib examples use symbol syntax — All FunctionDoc examples for column-name parameters updated to use idiomatic :name symbol syntax instead of "name" strings.
  • Module export quick-parser extracts enum variantsquick_parse_module_exports now returns ModuleExport enum (with Function and Enum variants) instead of (String, bool) tuples. Enum exports include full variant info (ModuleEnumVariant::Simple, Tuple, Record) extracted by scanning the token stream for type definitions matching exposed names. This enables downstream tools (LSP, compiler) to resolve enum constructors from user modules without full compilation.

Fixed

13 items
  • OpenRecord field access no longer produces a spurious type errorinfer_record_access_type now handles OpenRecord correctly: declared fields are looked up and returned, and undeclared fields return Unknown without emitting a TypeMismatch error.
  • Multiline type annotations in let bindings parse correctly — The indented_record parser now makes the trailing Dedent token optional. When a record type annotation is followed by = rhs on the same line as the closing } (e.g. let data :\n { id: Float\n } = rhs), no Dedent is emitted by the lexer, so requiring one caused a parse failure. Making it optional covers both cases: type aliases (where } is on its own line before a Dedent) and let bindings (where } is followed inline by = rhs).
  • inspect::list_variables uses register-based lookup — Variable values are now fetched via vm.registers().get(register.val()) instead of the removed vm.variables() map. Uninitialized registers (lambdas compiled but not yet called) are skipped so they do not appear as live REPL bindings.
  • Multiline record types in type annotations — Record types (e.g. { a: Int, b: Int }) spanning multiple lines now parse correctly inside type annotations, including inside task expecting/exposing clauses, Maybe, DataFrame, and other parameterized types. Both Elm-style leading-comma layout and trailing-comma layout are supported. Previously, indented records triggered "found indent" parse errors.
  • Compile-time validation of custom types in task signatures — Custom types used in task expecting/task exposing clauses (e.g. task expecting (c : Color)) are now validated at compile time. If a type name is undefined, the compiler emits a clear UndeclaredType error with fuzzy-match suggestions drawn from visible enums and type aliases. Built-in types (Int, Bool, String, Float, etc.) are always accepted. The check recurses into List, Tuple, DataFrame, Record, and OpenRecord type arguments.
  • Multiline task params with paren on keyword linetask expecting (\n data : Int\n) now parses correctly. The parameter and exposing list parsers now handle Indent/Dedent tokens inside parentheses, not just Newline. Previously this layout produced "found indent 4, expecting something else".
  • Task expose type validation — The compiler now validates task exposing variables: if a declared output is never assigned in the body, a TaskExposeNotBound error is reported with a hint to add a let binding. If the assigned type doesn't match the declared type, a TaskExposeTypeMismatch error is reported. Previously, unbound or mistyped task outputs were silently ignored. This validation now also runs when a task file is compiled standalone (not via run), so the LSP can report TaskExposeNotBound errors when editing a task file directly. Additionally, DataFrame schema mismatches with enum/contract columns are now fully checked via run "...": a declared column that is missing from the actual result (e.g. :id : Int declared but DataFrame.select dropped it) now correctly reports TaskExposeTypeMismatch instead of being silently bypassed by the contract-type shortcut. Note: when DataFrame.applyExprs is in the pipeline, type inference loses column information (by design), so column-presence errors may only be caught at runtime in those cases.
  • Task parameter type checking uses are_types_compatible — Type checking for run file parameters now uses are_types_compatible instead of direct != comparison, correctly handling compatible types (e.g. Symbol/String coercion).
  • Enums not visible inside function bodies — Enum types imported via exposing or defined at file scope were invisible inside fn and task declaration bodies because the parser's function-scope boundary only allowed Symbol::Function to pass through from parent scopes. Now Symbol::Enum is also allowed, matching the compiler's behavior. This fixes "Enum X not found" errors when using imported enums inside task or function bodies.
  • task declaration params not in scopetask declaration parameters (now task expecting (...)) are registered in both the parser scope (as Symbol::Variable) and the compiler scope (via insert_var). Previously, opening a task file directly in the editor caused "variable not found" errors for declared parameters because only the caller path registered them.
  • Stack overflow on nested if-else — The control_flow_deeply_nested_if test now runs with an 8 MB thread stack to accommodate large debug-mode stack frames from recursive compile_expr/infer_type calls.
  • Project-aware module resolutionfind_project_module_root walks up to keel.toml to determine the source root (from the main field), so files in subdirectories (e.g. src/variables/age.kl) can import user modules from src/modules/. Previously the module root was the parent directory of each file, which broke imports from non-sibling directories.
  • User module enums in run task files — Task files loaded via run that import user modules (e.g. import Labels exposing (Cohort)) now parse correctly. Previously compile_run_file used parse_file_lenient with a blank parser state, so enum constructors like Cohort::Young failed with "Enum not found". Added parse_file_lenient_with_state and pre-register user modules before parsing task files.

Removed

4 items
  • mut in task outputs — Mutable propagation from task outputs removed. All task outputs are now immutable.
  • Task stdlib module — The Task module is removed. run and task are now language keywords handled by the parser/compiler directly.
  • DataFrame.filter (closure-based) — Removed the closure-based DataFrame.filter (|r| ...) function and the entire expr_compiler module (~1,862 lines) that compiled closures to Polars expressions. Use DataFrame.filter with Expr syntax instead: df |> DataFrame.filter (:age > 18).
  • Legacy named filter functions — Removed filterEq, filterNeq, filterGt, filterGte, filterLt, filterLte, filterIn. Use DataFrame.filter with Expr syntax instead (e.g., DataFrame.filter (:col == val), DataFrame.filter (:col |> Expr.in [vals])).

Showing page 1 of 5 (5 versions)