Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Changelog

## Unreleased

### Table literals

Table literals are row-oriented sugar for a list of records. Column names are
written once; each body line is a row. After parsing, the compiler desugars
them to `List({ ... })`, so later compiler stages never see a table type.

The [homepage](https://www.roc-lang.org/) examples currently build that list by
repeating every field name on every row:

```roc
print_remaining! = |todos|
todos
.keep_if(|todo| todo.status != Done)
.for_each!(|todo| echo!("- ${todo.name}\n"))

main! = |_args| {
todos = [
{ name: "Learn Roc", status: Done },
{ name: "Buy groceries", status: Done },
{ name: "Write blog post", status: InProgress },
{ name: "Call mom", status: NotStarted },
]
print_remaining!(todos)
Ok({})
}
```

The same program with a table literal keeps the filtering and printing, and
writes the data as a grid:

```roc
print_remaining! = |todos|
todos
.keep_if(|todo| todo.status != Done)
.for_each!(|todo| echo!("- ${todo.name}\n"))

main! = |_args| {
todos = table name, status {
"Learn Roc", Done,
"Buy groceries", Done,
"Write blog post", InProgress,
"Call mom", NotStarted,
}
print_remaining!(todos)
Ok({})
}
```

Columns can optionally be typed (`table name : Str, status { ... }`) so numeric
literals pick up the column type instead of defaulting to `Dec`. `table` is a
contextual keyword: `table(x)`, `table = …`, and `foo.table` stay ordinary
names.

See [Table literals](docs/langref/expressions.md#table-literals) in the language
reference.
45 changes: 45 additions & 0 deletions docs/langref/expressions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Here are all the different types of expressions in Roc:
- Number literals, e.g. `1` or `2.34` or `0.123e4`
- List literals, e.g. `[1, 2]` or `[]` or `["foo"]`
- Record literals, e.g. `{ x: 1, y: 2 }` or `{}` or `{ x, y, ..other_record }`
- [Table literals](#table-literals), e.g. `table name, age { "Ada", 36 }` — sugar for a list of records
- Tag literals, e.g. `Foo` or `Foo(bar)`
- Tuple literals, e.g. `(a, b, "foo")`
- Function literals (aka "lambdas"), e.g. `|a, b| a + b` or `|| c + d`
Expand All @@ -33,6 +34,50 @@ Here are all the different types of expressions in Roc:

There are no other types of expressions in the language.

## [Table Literals](#table-literals) {#table-literals}

A table literal is row-oriented syntax sugar for a [list](lists) of [records](records).
After parsing, the compiler desugars it to that list; later compiler stages never see tables.

```roc
people = table name, age, favorite_color {
"Bob", 12, "blue",
"Alice", 17, "green",
"Eve", 13, "red",
}
```

This is the same as:

```roc
people = [
{ name: "Bob", age: 12, favorite_color: "blue" },
{ name: "Alice", age: 17, favorite_color: "green" },
{ name: "Eve", age: 13, favorite_color: "red" },
]
```

Column names are lowercase identifiers. A column may optionally have a type:

```roc
people = table name : Str, age : U8, favorite_color : Str {
"Bob", 12, "blue",
}
```

Typed columns are applied to the whole list so numeric literals pick up the column type
(here `12` is a `U8` rather than the default `Dec`). An empty body is allowed when the
columns are typed: `table name : Str, age : U8 {}`.

`table` is a contextual keyword. A table literal starts only when `table` is followed on
the same line by a column name or `{`. `table(x)`, `table = …`, `foo.table`, and a bare
`table` at the end of a line keep using `table` as an ordinary name. A table needs at
least one column; `table {}` is invalid.

Each body line is one row of comma-separated expressions. A newline at table-body depth
ends the row. Newlines inside `()`, `[]`, `{}`, or a lambda do not. Trailing commas before
a newline or `}` are allowed.

## [Values](#values) {#values}

A Roc value is a semantically immutable piece of data.
Expand Down
152 changes: 152 additions & 0 deletions src/canonicalize/Can.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7424,6 +7424,153 @@ pub fn canonicalizeExpr(
return self.runExprKernel(ast_expr_idx);
}

fn desugarTableLiteral(
self: *Self,
table: @FieldType(AST.Expr, "table"),
region: Region,
) std.mem.Allocator.Error!CanonicalizedExpr {
const columns = self.parse_ir.store.tableColumnSlice(table.columns);
const rows = self.parse_ir.store.tableRowSlice(table.rows);
if (columns.len == 0) {
return try self.canonicalizedMalformedExpr(Diagnostic{ .expr_not_canonicalized = .{ .region = region } });
}

const seen_top = self.scratch_seen_record_fields.top();
defer self.scratch_seen_record_fields.clearFrom(seen_top);
for (columns) |column_idx| {
const column = self.parse_ir.store.getTableColumn(column_idx);
const name = self.parse_ir.tokens.resolveIdentifier(column.name) orelse {
return try self.canonicalizedMalformedExpr(Diagnostic{ .expr_not_canonicalized = .{ .region = region } });
};
const name_region = self.parse_ir.tokens.resolve(column.name);
for (self.scratch_seen_record_fields.sliceFromStart(seen_top)) |seen_field| {
if (name.eql(seen_field.ident)) {
return try self.canonicalizedMalformedExpr(Diagnostic{ .expr_not_canonicalized = .{ .region = region } });
}
}
try self.scratch_seen_record_fields.append(SeenRecordField{
.ident = name,
.region = name_region,
});
}

const items_top = self.parse_ir.store.scratchExprTop();
for (rows) |row_idx| {
const row = self.parse_ir.store.getTableRow(row_idx);
const cells = self.parse_ir.store.exprSlice(row.items);
if (cells.len != columns.len) {
self.parse_ir.store.clearScratchExprsFrom(items_top);
return try self.canonicalizedMalformedExpr(Diagnostic{ .expr_not_canonicalized = .{ .region = region } });
}

const fields_top = self.parse_ir.store.scratchRecordFieldTop();
for (columns, cells) |column_idx, cell| {
const column = self.parse_ir.store.getTableColumn(column_idx);
const field = try self.parse_ir.store.addRecordField(.{
.name = column.name,
.value = cell,
.region = row.region,
});
try self.parse_ir.store.addScratchRecordField(field);
}
const fields = try self.parse_ir.store.recordFieldSpanFrom(fields_top);
const record = try self.parse_ir.store.addExpr(.{ .record = .{
.fields = fields,
.ext = null,
.region = row.region,
} });
try self.parse_ir.store.addScratchExpr(record);
}

const items = try self.parse_ir.store.exprSpanFrom(items_top);
const list_ast = try self.parse_ir.store.addExpr(.{ .list = .{
.items = items,
.region = table.region,
} });
const list_can = (try self.canonicalizeExpr(list_ast)) orelse {
return try self.canonicalizedMalformedExpr(Diagnostic{ .expr_not_canonicalized = .{ .region = region } });
};
return try self.wrapTableWithColumnTypes(list_can, table.columns, region);
}

fn wrapTableWithColumnTypes(
self: *Self,
list_expr: CanonicalizedExpr,
columns: AST.TableColumn.Span,
region: Region,
) std.mem.Allocator.Error!CanonicalizedExpr {
const column_slice = self.parse_ir.store.tableColumnSlice(columns);
var any_typed = false;
for (column_slice) |column_idx| {
if (self.parse_ir.store.getTableColumn(column_idx).ty != null) {
any_typed = true;
break;
}
}
if (!any_typed) return list_expr;

try self.scopeEnter(self.env.gpa, false);
defer self.scopeExit(self.env.gpa) catch |err| self.recordScopeExitError(err);

const fields_top = self.env.store.scratchAnnoRecordFieldTop();
for (column_slice) |column_idx| {
const column = self.parse_ir.store.getTableColumn(column_idx);
const name = self.parse_ir.tokens.resolveIdentifier(column.name) orelse {
self.env.store.clearScratchAnnoRecordFieldsFrom(fields_top);
return try self.canonicalizedMalformedExpr(Diagnostic{ .expr_not_canonicalized = .{ .region = region } });
};
const field_ty = if (column.ty) |ty|
try self.canonicalizeTypeAnno(ty, .local_anno)
else
try self.env.addTypeAnno(.{ .underscore = {} }, region);
const field_cir = try self.env.addAnnoRecordField(.{
.name = name,
.ty = field_ty,
.is_optional = false,
}, region);
try self.env.store.addScratchAnnoRecordField(field_cir);
}
const record_fields = try self.env.store.annoRecordFieldSpanFrom(fields_top);
const record_anno = try self.env.addTypeAnno(.{ .record = .{
.fields = record_fields,
.ext = null,
} }, region);

const args_top = self.env.store.scratchTypeAnnoTop();
try self.env.store.addScratchTypeAnno(record_anno);
const args = try self.env.store.typeAnnoSpanFrom(args_top);
const list_anno = try self.env.addTypeAnno(.{ .apply = .{
.name = self.env.idents.list,
.base = .{ .builtin = .list },
.args = args,
} }, region);

const annotation_idx = try self.createAnnotationFromTypeAnno(list_anno, null, region);
const ident = try self.generateClosureTagName(null);
const pattern_idx = try self.env.addPattern(.{ .assign = .{ .ident = ident } }, region);
_ = try self.scopeIntroduceInternal(self.env.gpa, .ident, ident, pattern_idx, false, true);

const stmts_top = self.env.store.scratchTop("statements");
const decl_stmt = try self.env.addStatement(Statement{ .s_decl = .{
.pattern = pattern_idx,
.expr = list_expr.idx,
.anno = annotation_idx,
} }, region);
try self.env.store.addScratchStatement(decl_stmt);
const stmts = try self.env.store.statementSpanFrom(stmts_top);

const lookup = try self.canonicalizedLocalLookup(pattern_idx, region);
const block = try self.env.addExpr(CIR.Expr{ .e_block = .{
.stmts = stmts,
.final_expr = lookup.idx,
} }, region);

return CanonicalizedExpr{
.idx = block,
.free_vars = list_expr.free_vars,
};
}

fn canonicalizedMalformedExpr(self: *Self, diagnostic: Diagnostic) std.mem.Allocator.Error!CanonicalizedExpr {
return CanonicalizedExpr{
.idx = try self.env.pushMalformed(Expr.Idx, diagnostic),
Expand Down Expand Up @@ -10613,6 +10760,11 @@ fn runExprKernel(
try self.env.addExpr(Expr{ .e_break = .{} }, region);
try storeExprKernelOutput(&last_expr, &child_slots, frame_allocator, current_result_target, CanonicalizedExpr{ .idx = break_expr, .free_vars = DataSpan.empty() });
},
.table => |e| {
const region = self.parse_ir.tokenizedRegionToRegion(e.region);
const can_expr = try self.desugarTableLiteral(e, region);
try storeExprKernelOutput(&last_expr, &child_slots, frame_allocator, current_result_target, can_expr);
},
.list => |e| {
const region = self.parse_ir.tokenizedRegionToRegion(e.region);
const items_slice = self.parse_ir.store.exprSlice(e.items);
Expand Down
58 changes: 56 additions & 2 deletions src/fmt/fmt.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1743,6 +1743,7 @@ const Formatter = struct {
.@"break",
.@"return",
.block,
.table,
.for_expr,
.malformed,
=> {
Expand Down Expand Up @@ -2130,6 +2131,51 @@ const Formatter = struct {
.block => |b| {
try fmt.formatBlock(b);
},
.table => |t| {
const columns = fmt.ast.store.tableColumnSlice(t.columns);
const rows = fmt.ast.store.tableRowSlice(t.rows);
const table_multiline = rows.len > 0 or fmt.ast.store.getCollectionLayout(ei) == .expanded or fmt.regionHasInteriorComment(t.region);

try fmt.pushAll("table");
for (columns, 0..) |column_idx, i| {
const column = fmt.ast.store.getTableColumn(column_idx);
if (i == 0) {
try fmt.push(' ');
} else {
try fmt.pushAll(", ");
}
try fmt.pushTokenText(column.name);
if (column.ty) |ty| {
try fmt.pushAll(" : ");
try fmt.formatTypeAnnoDiscard(ty);
}
}

if (table_multiline) {
try fmt.pushAll(" {");
fmt.curr_indent += 1;
try fmt.flushCommentsAfterDiscard(t.region.start);
for (rows) |row_idx| {
const row = fmt.ast.store.getTableRow(row_idx);
const items = fmt.ast.store.exprSlice(row.items);
try fmt.ensureNewline();
try fmt.pushIndent();
for (items, 0..) |item_idx, i| {
if (i > 0) {
try fmt.pushAll(", ");
}
try fmt.formatExprDiscard(item_idx);
}
try fmt.push(',');
}
fmt.curr_indent -= 1;
try fmt.ensureNewline();
try fmt.pushIndent();
try fmt.push('}');
} else {
try fmt.pushAll(" {}");
}
},
.for_expr => |f| {
try fmt.pushAll("for ");
try fmt.formatPatternDiscard(f.patt);
Expand Down Expand Up @@ -2272,6 +2318,7 @@ const Formatter = struct {
.@"break",
.@"return",
.block,
.table,
.for_expr,
.malformed,
=> {
Expand Down Expand Up @@ -3851,6 +3898,7 @@ const Formatter = struct {
.nominal_record,
.ellipsis,
.block,
.table,
.for_expr,
.@"break",
.@"return",
Expand All @@ -3872,7 +3920,7 @@ const Formatter = struct {
const expr_tag = std.meta.activeTag(expr);
const owns_collection = expr_tag == .list or expr_tag == .tuple or expr_tag == .record or
expr_tag == .record_builder or expr_tag == .apply or expr_tag == .method_call or
expr_tag == .nominal_apply or expr_tag == .lambda;
expr_tag == .nominal_apply or expr_tag == .lambda or expr_tag == .table;
if (owns_collection and fmt.regionHasInteriorComment(expr.to_tokenized_region())) return true;

return switch (expr) {
Expand Down Expand Up @@ -3919,6 +3967,8 @@ const Formatter = struct {
.crash => |c| fmt.groupedExprWillBeMultiline(c.expr),
.@"return" => |r| fmt.groupedExprWillBeMultiline(r.expr),
.for_expr => |f| fmt.groupedExprWillBeMultiline(f.expr) or fmt.groupedExprWillBeMultiline(f.body),
.table => |t| fmt.ast.store.tableRowSlice(t.rows).len > 0 or
fmt.ast.store.getCollectionLayout(expr_idx) == .expanded,
.int,
.frac,
.typed_int,
Expand Down Expand Up @@ -3951,7 +4001,7 @@ const Formatter = struct {
const expr_tag = std.meta.activeTag(expr);
const owns_collection = expr_tag == .list or expr_tag == .tuple or expr_tag == .record or
expr_tag == .record_builder or expr_tag == .apply or expr_tag == .method_call or
expr_tag == .nominal_apply or expr_tag == .lambda;
expr_tag == .nominal_apply or expr_tag == .lambda or expr_tag == .table;
if (owns_collection and fmt.regionHasInteriorComment(expr.to_tokenized_region())) return true;
if (!owns_collection and fmt.ast.regionIsMultiline(expr.to_tokenized_region())) {
return true;
Expand Down Expand Up @@ -4076,6 +4126,10 @@ const Formatter = struct {

return fmt.nodeWillBeMultiline(AST.Expr.Idx, f.body);
},
.table => |t| {
return fmt.ast.store.tableRowSlice(t.rows).len > 0 or
fmt.ast.store.getCollectionLayout(item) == .expanded;
},
.int,
.frac,
.typed_int,
Expand Down
Loading
Loading