Skip to content
Merged
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,34 @@
## 0.7.1

Polish release on top of 0.7.0. Error-message remediation
suggestions now surface the intent-level `as(<format>)` form,
aligning every bridge-offering surface (CLI, REPL, MCP, playground)
with the 0.6.0 shape story. The template that runs is unchanged,
so the composed `$expression | as(csv)` query produces the same
result as before.

### Changed

- **Error suggestions use `as(<format>)` as the display form.** The
suggestion shown in CLI errors, REPL prompts, MCP responses, and
the playground is now `| as(csv)` / `| as(toml)` / etc. instead
of the raw `| to_entries` / `| {items: .}` fragment. The
explanation names the underlying mechanism for transparency —
e.g. "Wraps each map entry as a {key, value} row (equivalent to
`to_entries`)".
- **`Remediation.display` and `Remediation.template` can now
differ.** The `Remediation()` constructor still sets
`display = source`. A new `Remediation.withDisplay()` factory
decouples them, used internally to surface `as(<format>)` while
the runtime AST stays as the raw fragment. Callers that only
read `Remediation.template` (e.g. through `applyBridge()`) see
no behavior change.
- **Curated template ASTs are parsed lazily on first use.** The
four canonical sources (`{items: .}`, `{value: .}`, `to_entries`,
`{value: .} | to_entries`) are parsed once per isolate and
shared across format-parameterized factories, instead of
re-parsing on every shape error.

## 0.7.0

Shape-gated tab completion, single-source-of-truth pipe-op metadata,
Expand Down
12 changes: 7 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Lambë is a query language for JSON, YAML, TOML, HCL, CSV, TSV, and Markdown. Qu
$ lam --to toml '.dependencies | keys' pubspec.yaml
Error: TOML output requires a map at the root, got list<string>.
Try appending one of:
| {items: .} # Produces a map with one entry named "items".
| as(toml) # Wraps the list under a single-entry map (equivalent to `{items: .}`).

$ lam --to toml '.dependencies | keys | as(toml)' pubspec.yaml
items = ["rumil", "rumil_parsers", "rumil_expressions"]
Expand Down Expand Up @@ -44,16 +44,18 @@ Lambë checks the result of your query against the shape the target format can s
$ lam --to toml '.name' pubspec.yaml
TOML output requires a map at the root, got string.
Try appending one of:
| {value: .} # Produces a map with one entry named "value".
| as(toml) # Wraps the scalar under a single-entry map (equivalent to `{value: .}`).

Apply a bridge?
[1] | {value: .} # Produces a map with one entry named "value".
[1] | as(toml) # Wraps the scalar under a single-entry map (equivalent to `{value: .}`).
[q] cancel
> 1
value = "rumil"
```

The same flow applies to CSV and TSV (which require a list of records at the root) and HCL (which requires a map). Suggestions are curated query fragments parsed to AST at construction, so the text you see is the code that runs.
The same flow applies to CSV and TSV (which require a list of records at the root) and HCL (which requires a map).

Suggestions surface the intent-level `as(<format>)` form. The explanation names the raw fragment (`{value: .}`, `to_entries`, etc.) the bridge composes, so `--explain` and manual composition stay available to anyone who wants them.

### `as(fmt)` — bridging in the query language

Expand Down Expand Up @@ -196,7 +198,7 @@ lam -i data.json
```

```
lambe v0.7.0 - type :help for commands, :q to quit
lambe v0.7.1 - type :help for commands, :q to quit
Data loaded: {3 fields, 42 users}

lambe> .users | filter(.age > 30) | map(.name)
Expand Down
10 changes: 10 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ Shape-gated completion and spec-driven op dispatch.

One breaking change: the `Completions` typedef gained an `end` field (see CHANGELOG). All other additions are additive.

## 0.7.1 — shipped

UX polish on top of 0.7.0. Remediation suggestions now surface the intent-level `as(<format>)` form in every bridge-offering surface.

- **`as(<format>)` in error suggestions.** CLI errors, REPL prompts, MCP responses, and the playground now show `| as(csv)` / `| as(toml)` / etc. instead of `| to_entries` / `| {items: .}`. The explanation names the raw fragment underneath.
- **`Remediation.display` and `Remediation.template` decouple.** New `Remediation.withDisplay()` factory lets the display text differ from the runtime AST's source. Template still runs the raw fragment; `applyBridge()` consumers see no behavior change.
- **Curated template ASTs are parsed lazily on first use.** The four canonical sources are shared across format-parameterized factories instead of re-parsed on every shape error.

No breaking changes.

## 0.8.0 — next

Extend the shape story to sub-expressions and polish parser errors.
Expand Down
4 changes: 2 additions & 2 deletions doc/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ For exploring unfamiliar data, use interactive mode:

```bash
$ lam -i data.json
lambe v0.7.0 - type :help for commands, :q to quit
lambe v0.7.1 - type :help for commands, :q to quit
Data loaded: {2 fields, 3 users}

lambe>
Expand Down Expand Up @@ -250,7 +250,7 @@ Add to your `pubspec.yaml`:

```yaml
dependencies:
lambe: ^0.6.0
lambe: ^0.7.0
```

## Next steps
Expand Down
2 changes: 1 addition & 1 deletion doc/lam.1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.TH "LAM" "1" "April 2026" "Lambë 0.7.0" ""
.TH "LAM" "1" "April 2026" "Lambë 0.7.1" ""
.SH AUTHOR
Hakim Jonas Ghoula
.SH NAME
Expand Down
2 changes: 1 addition & 1 deletion doc/lam.1.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
title: LAM
section: 1
source: Lambë 0.7.0
source: Lambë 0.7.1
author: Hakim Jonas Ghoula
date: April 2026
---
Expand Down
2 changes: 1 addition & 1 deletion lib/src/_version.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
// pubspec.yaml version.

/// Lambe version, sourced from pubspec.yaml at generation time.
const lambeVersion = '0.7.0';
const lambeVersion = '0.7.1';
119 changes: 93 additions & 26 deletions lib/src/shape/check.dart
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,25 @@ final class NotWritable extends ShapeReport {
/// pipe. Given user query `.users` and remediation template `{items: .}`,
/// the composed query is `.users | {items: .}`.
///
/// [display] is the human-readable source string, suitable for showing in
/// CLI output, a REPL, or a web interface. [template] is the same source
/// parsed to a [LamExpr], allowing composition through [applyBridge]
/// without string manipulation.
/// [display] is what the user sees and pastes. [template] is the AST
/// that actually runs. They are usually identical: [Remediation.new]
/// sets `display = source`. The [Remediation.withDisplay] factory
/// decouples them, letting a remediation surface an intent-level form
/// (such as `as(csv)`) while running a raw fragment (such as
/// `to_entries`). Safe because `as(fmt)` at runtime consults this
/// same curated table and resolves to the raw template.
final class Remediation {
/// Short human-readable label, for example `"Wrap under a key"`.
final String label;

/// The query fragment as source text. Always equal to the source that
/// was parsed to produce [template].
/// The query fragment as text. Shown in CLI output, the REPL, or a
/// web interface, and appended to the user's query via
/// `$expression | ${display}`.
///
/// Usually identical to [template]'s source. May differ when a
/// remediation surfaces an intent-level form (e.g. `as(csv)`) while
/// running a raw fragment (e.g. `to_entries`) underneath. See
/// [Remediation.withDisplay].
final String display;

/// The query fragment parsed to a [LamExpr]. Composable with a user
Expand All @@ -163,7 +172,8 @@ final class Remediation {
required this.explanation,
});

/// Parse [source] as a query fragment and build a [Remediation].
/// Parse [source] as a query fragment and build a [Remediation] whose
/// [display] equals [source].
///
/// Throws [ArgumentError] if [source] does not parse. This validates
/// curated templates at construction time so invalid suggestions
Expand All @@ -172,6 +182,25 @@ final class Remediation {
required String label,
required String source,
required String explanation,
}) => Remediation.withDisplay(
label: label,
source: source,
display: source,
explanation: explanation,
);

/// Build a [Remediation] whose [display] differs from its runtime
/// [source].
///
/// Used to surface a readable intent (such as `as(csv)`) while the
/// runtime AST is a raw fragment (such as `to_entries`). [source]
/// is parsed and validated exactly as in [Remediation.new];
/// [display] is opaque user-facing text and is not parsed.
factory Remediation.withDisplay({
required String label,
required String source,
required String display,
required String explanation,
}) {
final result = parser_.parseQuery(source);
final ast = switch (result) {
Expand All @@ -181,7 +210,7 @@ final class Remediation {
};
return Remediation._(
label: label,
display: source,
display: display,
template: ast,
explanation: explanation,
);
Expand Down Expand Up @@ -220,7 +249,8 @@ List<Remediation> _suggestionsFor(Shape got, OutputFormat format) => switch ((
format,
)) {
// List to a map-root format.
(SList _, OutputFormat.toml) || (SList _, OutputFormat.hcl) => [_wrapItems],
(SList _, OutputFormat.toml) ||
(SList _, OutputFormat.hcl) => [_wrapItems(format)],
// Scalar or null to a map-root format.
(SString _, OutputFormat.toml) ||
(SString _, OutputFormat.hcl) ||
Expand All @@ -229,10 +259,10 @@ List<Remediation> _suggestionsFor(Shape got, OutputFormat format) => switch ((
(SBool _, OutputFormat.toml) ||
(SBool _, OutputFormat.hcl) ||
(SNull _, OutputFormat.toml) ||
(SNull _, OutputFormat.hcl) => [_wrapValue],
(SNull _, OutputFormat.hcl) => [_wrapValue(format)],
// Map to a list-root format.
(SMap _, OutputFormat.csv) ||
(SMap _, OutputFormat.tsv) => [_toEntriesAsRows],
(SMap _, OutputFormat.tsv) => [_toEntriesAsRows(format)],
// Scalar or null to a list-root format.
(SString _, OutputFormat.csv) ||
(SString _, OutputFormat.tsv) ||
Expand All @@ -241,33 +271,70 @@ List<Remediation> _suggestionsFor(Shape got, OutputFormat format) => switch ((
(SBool _, OutputFormat.csv) ||
(SBool _, OutputFormat.tsv) ||
(SNull _, OutputFormat.csv) ||
(SNull _, OutputFormat.tsv) => [_wrapValueThenEntries],
(SNull _, OutputFormat.tsv) => [_wrapValueThenEntries(format)],
// No curated suggestion for this combination.
_ => const <Remediation>[],
};

// Curated remediations. Not `const` because each constructor runs the
// parser to validate the template.
final Remediation _wrapItems = Remediation(
// Curated remediations.
//
// The four canonical template ASTs are parsed lazily on first use
// (Dart initializes top-level `final`s on first read) and reused
// across every format that shares the same curated bridge. The
// factories below build a per-format `Remediation` from the shared
// AST, setting `display` to `as(<format>)` so the user sees the
// intent form. At runtime `as(<format>)` consults this same table
// and resolves to the raw template, which is why displaying the
// intent form is safe.

final LamExpr _wrapItemsAst = _parseTemplate('{items: .}');
final LamExpr _wrapValueAst = _parseTemplate('{value: .}');
final LamExpr _toEntriesAst = _parseTemplate('to_entries');
final LamExpr _wrapValueThenEntriesAst = _parseTemplate(
'{value: .} | to_entries',
);

LamExpr _parseTemplate(String source) {
final result = parser_.parseQuery(source);
return switch (result) {
Success(value: final v) => v,
_ =>
throw StateError('Curated remediation template failed to parse: $source'),
};
}

Remediation _wrapItems(OutputFormat format) => Remediation._(
label: 'Wrap under a key',
source: '{items: .}',
explanation: 'Produces a map with one entry named "items".',
display: 'as(${format.name})',
template: _wrapItemsAst,
explanation:
'Wraps the list under a single-entry map '
'(equivalent to `{items: .}`).',
);

final Remediation _wrapValue = Remediation(
Remediation _wrapValue(OutputFormat format) => Remediation._(
label: 'Wrap under a key',
source: '{value: .}',
explanation: 'Produces a map with one entry named "value".',
display: 'as(${format.name})',
template: _wrapValueAst,
explanation:
'Wraps the scalar under a single-entry map '
'(equivalent to `{value: .}`).',
);

final Remediation _toEntriesAsRows = Remediation(
Remediation _toEntriesAsRows(OutputFormat format) => Remediation._(
label: 'Convert entries to rows',
source: 'to_entries',
explanation: 'Produces a list of {key, value} rows.',
display: 'as(${format.name})',
template: _toEntriesAst,
explanation:
'Wraps each map entry as a {key, value} row '
'(equivalent to `to_entries`).',
);

final Remediation _wrapValueThenEntries = Remediation(
Remediation _wrapValueThenEntries(OutputFormat format) => Remediation._(
label: 'Wrap as a single-row list',
source: '{value: .} | to_entries',
explanation: 'Produces a one-row list with one column named "value".',
display: 'as(${format.name})',
template: _wrapValueThenEntriesAst,
explanation:
'Wraps the scalar as a one-row list with a "value" column '
'(equivalent to `{value: .} | to_entries`).',
);
2 changes: 1 addition & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: lambe
description: >-
Query JSON, YAML, TOML, HCL, and Markdown files with a composable pipeline DSL.
Like jq but multi-format, with cleaner syntax. CLI tool + Dart library + MCP server for AI agents.
version: 0.7.0
version: 0.7.1
homepage: https://ardaproject.org/lambe
repository: https://github.com/hakimjonas/lambe
topics:
Expand Down
36 changes: 22 additions & 14 deletions test/apply_surfaces_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -48,20 +48,28 @@ void main() {
expect(() => formatOutput(newResult, OutputFormat.toml), returnsNormally);
});

test('map to CSV is bridged by to_entries', () {
final data = <String, Object?>{
'deps': <String, Object?>{'a': '1.0', 'b': '2.0'},
};
final userAst = parseAst('.deps');
final result = evaluateAst(userAst, data);

final err = _shapeError(() => formatOutput(result, OutputFormat.csv));
final bridged = applyBridge(userAst, err.suggestions.first.template);
final newResult = evaluateAst(bridged, data);
expect(canWriteAs(newResult, OutputFormat.csv), isA<Writable>());

expect(err.suggestions.first.display, contains('to_entries'));
});
test(
'map to CSV is bridged by to_entries template, displayed as as(csv)',
() {
final data = <String, Object?>{
'deps': <String, Object?>{'a': '1.0', 'b': '2.0'},
};
final userAst = parseAst('.deps');
final result = evaluateAst(userAst, data);

final err = _shapeError(() => formatOutput(result, OutputFormat.csv));
final bridged = applyBridge(userAst, err.suggestions.first.template);
final newResult = evaluateAst(bridged, data);
expect(canWriteAs(newResult, OutputFormat.csv), isA<Writable>());

// Display surfaces the intent-level `as(csv)` form; the
// template is still the raw `to_entries` AST, so the
// applyBridge() call above composes the same query a
// pre-0.7.1 caller would have produced.
expect(err.suggestions.first.display, 'as(csv)');
expect(err.suggestions.first.explanation, contains('to_entries'));
},
);

test('list to TOML is bridged by {items: .}', () {
final data = <String, Object?>{
Expand Down
8 changes: 5 additions & 3 deletions test/shape_check_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ void main() {
final nw = report as NotWritable;
expect(nw.got, isA<SString>());
expect(nw.suggestions, isNotEmpty);
expect(nw.suggestions.first.display, contains('{'));
// Display surfaces the intent-level `as(<fmt>)` form.
expect(nw.suggestions.first.display, 'as(${fmt.name})');
});

test('${fmt.name} rejects a number with suggestions', () {
Expand Down Expand Up @@ -100,8 +101,9 @@ void main() {
expect(nw.got, isA<SMap>());
expect(nw.required, isA<MustBeList>());
expect(nw.suggestions, isNotEmpty);
// First suggestion should be to_entries for map → list of rows.
expect(nw.suggestions.first.display, contains('to_entries'));
// Display is the intent-level `as(<fmt>)` form; the template
// that actually runs is still `to_entries`.
expect(nw.suggestions.first.display, 'as(${fmt.name})');
});

test('${fmt.name} rejects a scalar with suggestions', () {
Expand Down