Changelog
All notable changes to Keel are documented here. This project follows Keep a Changelog and Semantic Versioning.
Unreleased
Added
20 items- Record spread expression —
{ ..base }copies all fields from a record, and{ ..base, field = val }copies and overrides or adds fields. Compiles viaCopyRecord+StoreNamedField. Type inference merges the extra fields into the base record type. - Type aliases in task signatures —
task expecting MyInputs exposing MyOutputsnow works whenMyInputs/MyOutputsare type aliases that resolve to record types. Fields are extracted at compile time from the resolved alias. - Bare
taskdeclaration —taskwith noexpectingorexposingclauses is now valid syntax. Useful as a marker that a file is a task entry point with no declared contract. - Aliased arguments in
runexpressions —run "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_id—VariableInfonow carries abinding_id: usizefield 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 correlateVariableInfoacross updates.- Enum expansion in inspect —
has_children,value_children, andnavigatenow handleOutputValue::Enumvariants whose argument is aRecord. 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— Newlist_types(scope, interner) -> Vecfunction inkeel_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.ChildInfovalue-label fields —ChildInfonow carriesvalue_label_strings: Vecandvalue_label_name: Option. For DataFrame columns that have aValueLabelSet,value_label_stringscontains the label strings andvalue_label_namecontains the enum type name (e.g."Origin"). Non-DataFrame children have empty/Nonevalues.DataFrame.fromRecordscompile-time value-label embedding — WhenDataFrame.fromRecordsis called with a literal list of records that containValueLabel.value SomeEnum::Variantfields, the compiler now emits a compile-timeHashMapconstant and redirects to an internal_fromRecordsWithLabelsfunction. Value label metadata is preserved in the resulting DataFrame without any runtime overhead.inspectmodule — Shared runtime inspection utilities (list_variables,get_children) extracted intokeel_core::inspect. Returns neutralVariableInfo/ChildInfostructs 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
ValueLabelmetadata can now be used directly as a column type:score: Scoreis equivalent to the verbosescore: [(1, Score::Low), (2, Score::Mid), (3, Score::High)]. The compiler derives the full int+label contract from the enum'sValueLabeldeclarations at compile time. Plain enums (noValueLabel) on integer columns still produce a type-mismatch error. - Multiline task declarations —
task 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 operations —
setVarLabel,removeVarLabel,removeValueLabels,setMeta,setColumnMeta, andsetDisplayModenow preserve the input DataFrame schema in type inference (passthrough), so task expose type validation works correctly after these operations. setValueLabels/setValueLabelsStrictretype column to enum in task expose contracts — When the second argument isValueLabelSet.fromType SomeEnum, the type checker now updates the target column's type fromFloattoSomeEnumin the resulting DataFrame schema. Previously the column stayedFloat, causing a spurious type mismatch on task expose declarations likeresult : 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:nameand"name". This applies toselect,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.condbranch consistency — The type signature changed from[(Expr, a)] -> b -> Exprto[(Expr, a)] -> a -> Expr, so all branch values and the default must share the same type. Mismatches likeExpr.cond [(cond, "minor")] 42(String vs Int) are now caught at compile time instead of failing at Polars runtime. - Broader scalar ↔ Expr coercion —
unify_types,match_types, andare_types_compatiblenow 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 onExprvalues. When either operand is anExpr, 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 likecol "price" * 1.1,col "age" >= 18 && col "active", andcol "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 Parquet —
DataFrame.readParquetandDataFrame.readParquetColumnsnow also read variable labels from thespss_metaParquet key written by pyreadstat (format:{"column_labels": {"col": "label"}}), and from pandas column metadata embedded in thepandasParquet key. 19 tests pass including a live NIS2NL.parquet test.
Changed
8 items- Task
expecting/exposingclauses use braces —task expecting { x : Int } exposing { result : Int }replaces the old parenthesis syntaxtask expecting (x : Int) exposing (result : Int). Both clauses now accept any type expression (record literal, type alias, or parameterised type).Declaration::TaskAST changed:params: Vec<(String, Type)>+outputs: Vecreplaced byexpecting: Option+exposing: Option. runargument is any record expression — The argument torun "file.kl"is now parsed as a general expression rather than theImportFileVarsenum. Any expression that produces a record is accepted:run "f.kl" { x, y },run "f.kl" inputs(variable),run "f.kl" { ..base }(spread), orrun "f.kl"(no argument).Expr::RunFile.pass_vars: ImportFileVarsis replaced byarg: Option.> types::format_field_nameis nowpub— The function that renders record field names with symbol syntax (:Name,:"name with spaces") is now publicly accessible sokeel-fmtcan use it when formatting type aliases.- Stable Rust toolchain — Removed nightly compiler requirement. Moved to stable Rust (1.85+). Deleted vendored
polars-opsandethnumpatches (they were nightly-only workarounds). Dev shells updated accordingly. - Task syntax redesign —
Task.run/Task.definereplaced with keyword-based syntax. Declarations usetask expecting (...) exposing (...)instead ofTask.define (...) -> (...). Caller usesrun "file.kl" { x, y }with record-style braces instead ofTask.run "file.kl" (x, y). Three keywords added:task,run,expecting(plus existingexposing). - Task/module-first ordering —
taskdeclarations and file-levelmodule 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
:namesymbol syntax instead of"name"strings. - Module export quick-parser extracts enum variants —
quick_parse_module_exportsnow returnsModuleExportenum (withFunctionandEnumvariants) 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 itemsOpenRecordfield access no longer produces a spurious type error —infer_record_access_typenow handlesOpenRecordcorrectly: declared fields are looked up and returned, and undeclared fields returnUnknownwithout emitting aTypeMismatcherror.- Multiline type annotations in
letbindings parse correctly — Theindented_recordparser now makes the trailingDedenttoken optional. When a record type annotation is followed by= rhson the same line as the closing}(e.g.let data :\n { id: Float\n } = rhs), noDedentis 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 aDedent) andletbindings (where}is followed inline by= rhs). inspect::list_variablesuses register-based lookup — Variable values are now fetched viavm.registers().get(register.val())instead of the removedvm.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 taskexpecting/exposingclauses,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 exposingclauses (e.g.task expecting (c : Color)) are now validated at compile time. If a type name is undefined, the compiler emits a clearUndeclaredTypeerror 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 intoList,Tuple,DataFrame,Record, andOpenRecordtype arguments. - Multiline task params with paren on keyword line —
task expecting (\n data : Int\n)now parses correctly. The parameter and exposing list parsers now handleIndent/Dedenttokens inside parentheses, not justNewline. Previously this layout produced "found indent 4, expecting something else". - Task expose type validation — The compiler now validates task
exposingvariables: if a declared output is never assigned in the body, aTaskExposeNotBounderror is reported with a hint to add aletbinding. If the assigned type doesn't match the declared type, aTaskExposeTypeMismatcherror is reported. Previously, unbound or mistyped task outputs were silently ignored. This validation now also runs when a task file is compiled standalone (not viarun), so the LSP can reportTaskExposeNotBounderrors when editing a task file directly. Additionally, DataFrame schema mismatches with enum/contract columns are now fully checked viarun "...": a declared column that is missing from the actual result (e.g.:id : Intdeclared butDataFrame.selectdropped it) now correctly reportsTaskExposeTypeMismatchinstead of being silently bypassed by the contract-type shortcut. Note: whenDataFrame.applyExprsis 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 forrunfile parameters now usesare_types_compatibleinstead of direct!=comparison, correctly handling compatible types (e.g. Symbol/String coercion). - Enums not visible inside function bodies — Enum types imported via
exposingor defined at file scope were invisible insidefnandtaskdeclaration bodies because the parser's function-scope boundary only allowedSymbol::Functionto pass through from parent scopes. NowSymbol::Enumis also allowed, matching the compiler's behavior. This fixes "Enum X not found" errors when using imported enums inside task or function bodies. taskdeclaration params not in scope —taskdeclaration parameters (nowtask expecting (...)) are registered in both the parser scope (asSymbol::Variable) and the compiler scope (viainsert_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_iftest now runs with an 8 MB thread stack to accommodate large debug-mode stack frames from recursivecompile_expr/infer_typecalls. - Project-aware module resolution —
find_project_module_rootwalks up tokeel.tomlto determine the source root (from themainfield), so files in subdirectories (e.g.src/variables/age.kl) can import user modules fromsrc/modules/. Previously the module root was the parent directory of each file, which broke imports from non-sibling directories. - User module enums in
runtask files — Task files loaded viarunthat import user modules (e.g.import Labels exposing (Cohort)) now parse correctly. Previouslycompile_run_fileusedparse_file_lenientwith a blank parser state, so enum constructors likeCohort::Youngfailed with "Enum not found". Addedparse_file_lenient_with_stateand pre-register user modules before parsing task files.
Removed
4 itemsmutin task outputs — Mutable propagation from task outputs removed. All task outputs are now immutable.Taskstdlib module — TheTaskmodule is removed.runandtaskare now language keywords handled by the parser/compiler directly.DataFrame.filter(closure-based) — Removed the closure-basedDataFrame.filter (|r| ...)function and the entireexpr_compilermodule (~1,862 lines) that compiled closures to Polars expressions. UseDataFrame.filterwith Expr syntax instead:df |> DataFrame.filter (:age > 18).- Legacy named filter functions — Removed
filterEq,filterNeq,filterGt,filterGte,filterLt,filterLte,filterIn. UseDataFrame.filterwith Expr syntax instead (e.g.,DataFrame.filter (:col == val),DataFrame.filter (:col |> Expr.in [vals])).
Showing page 1 of 5 (5 versions)