diff --git a/CHANGELOG.md b/CHANGELOG.md index faf99ce..53dd2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()` 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()` 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()` 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, diff --git a/README.md b/README.md index ba7fe16..92822c3 100644 --- a/README.md +++ b/README.md @@ -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. 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"] @@ -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()` 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 @@ -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) diff --git a/ROADMAP.md b/ROADMAP.md index 62894eb..aa75ec2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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()` form in every bridge-offering surface. + +- **`as()` 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. diff --git a/doc/getting-started.md b/doc/getting-started.md index 4f989ee..a0e87e1 100644 --- a/doc/getting-started.md +++ b/doc/getting-started.md @@ -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> @@ -250,7 +250,7 @@ Add to your `pubspec.yaml`: ```yaml dependencies: - lambe: ^0.6.0 + lambe: ^0.7.0 ``` ## Next steps diff --git a/doc/lam.1 b/doc/lam.1 index 8258612..aaff06b 100644 --- a/doc/lam.1 +++ b/doc/lam.1 @@ -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 diff --git a/doc/lam.1.md b/doc/lam.1.md index 9610dc4..4d37597 100644 --- a/doc/lam.1.md +++ b/doc/lam.1.md @@ -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 --- diff --git a/lib/src/_version.dart b/lib/src/_version.dart index 7375780..7708f72 100644 --- a/lib/src/_version.dart +++ b/lib/src/_version.dart @@ -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'; diff --git a/lib/src/shape/check.dart b/lib/src/shape/check.dart index 0127758..6dbce52 100644 --- a/lib/src/shape/check.dart +++ b/lib/src/shape/check.dart @@ -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 @@ -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 @@ -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) { @@ -181,7 +210,7 @@ final class Remediation { }; return Remediation._( label: label, - display: source, + display: display, template: ast, explanation: explanation, ); @@ -220,7 +249,8 @@ List _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) || @@ -229,10 +259,10 @@ List _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) || @@ -241,33 +271,70 @@ List _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 [], }; -// 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()` so the user sees the +// intent form. At runtime `as()` 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`).', ); diff --git a/pubspec.yaml b/pubspec.yaml index 04c161a..4da05d7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/test/apply_surfaces_test.dart b/test/apply_surfaces_test.dart index 5872866..c377643 100644 --- a/test/apply_surfaces_test.dart +++ b/test/apply_surfaces_test.dart @@ -48,20 +48,28 @@ void main() { expect(() => formatOutput(newResult, OutputFormat.toml), returnsNormally); }); - test('map to CSV is bridged by to_entries', () { - final data = { - 'deps': {'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()); - - expect(err.suggestions.first.display, contains('to_entries')); - }); + test( + 'map to CSV is bridged by to_entries template, displayed as as(csv)', + () { + final data = { + 'deps': {'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()); + + // 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 = { diff --git a/test/shape_check_test.dart b/test/shape_check_test.dart index 8b8cf43..6f62c82 100644 --- a/test/shape_check_test.dart +++ b/test/shape_check_test.dart @@ -56,7 +56,8 @@ void main() { final nw = report as NotWritable; expect(nw.got, isA()); expect(nw.suggestions, isNotEmpty); - expect(nw.suggestions.first.display, contains('{')); + // Display surfaces the intent-level `as()` form. + expect(nw.suggestions.first.display, 'as(${fmt.name})'); }); test('${fmt.name} rejects a number with suggestions', () { @@ -100,8 +101,9 @@ void main() { expect(nw.got, isA()); expect(nw.required, isA()); 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()` 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', () {