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

Completer replacement range and re-assertion filter: the `Completions`
typedef now carries both `start` and `end` so callers can splice a
candidate over the typed token only, preserving any trailing
whitespace the user typed. Candidates that exactly match what's
already typed in `[start, end)` are filtered out, so Tab no longer
moves the cursor backward to re-assert text that's already there.

### Breaking

`Completions` typedef gained a required `end` field. Callers that
destructured as `(:start, :candidates)` must now destructure as
`(:start, :end, :candidates)` and splice with
`text.replaceRange(start, end, candidate)` instead of
`text.replaceRange(start, cursor, candidate)`.

## 0.6.1

Tab completion fix: trailing whitespace in the REPL query no longer
Expand Down
36 changes: 32 additions & 4 deletions doc/lam.1
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.TH "LAM" "1" "April 2026" "Lambë 0.6.1" ""
.TH "LAM" "1" "April 2026" "Lambë 0.6.2" ""
.SH AUTHOR
Hakim Jonas Ghoula
.SH NAME
Expand All @@ -13,6 +13,8 @@ lam - query structured data files
.PP
Query JSON, YAML, TOML, HCL, CSV, TSV, and Markdown files using a composable pipeline DSL. Format is auto-detected from file extension.
.PP
Lambe infers the structural shape of query results and reports incompatibilities with target output formats. Use \fB--explain\fR to trace the shape at each pipeline stage, or the \fBas\fR(\fIfmt\fR) combinator inside a query to bridge common mismatches.
.PP
If no file is given, reads from standard input.
.SH OPTIONS
.TP
Expand All @@ -34,6 +36,9 @@ Output format. One of: json, yaml, toml, csv, tsv, hcl. Default is json.
\fB--schema\fR
Show the data structure with type names instead of values.
.TP
\fB--explain\fR
Trace the shape of values flowing through each pipeline stage. Static analysis only; does not execute the query. Reports which output formats the final shape can be serialized as.
.TP
\fB--assert\fR
Evaluate the expression and exit with code 0 if the result is true, 1 if false.
.TP
Expand Down Expand Up @@ -128,6 +133,12 @@ Map to [{key, value}].
\fBfrom_entries\fR
[{key, value}] to map.
.TP
\fBto_number\fR
Parse a string as a number. Pass-through for existing numbers.
.TP
\fBtype\fR
Runtime type of the value as a string: "null", "boolean", "number", "string", "array", or "object".
.TP
\fBfilter_values\fR(\fIpred\fR)
Filter a map's values.
.TP
Expand All @@ -136,6 +147,9 @@ Transform a map's values.
.TP
\fBfilter_keys\fR(\fIpred\fR)
Filter a map's keys.
.TP
\fBas\fR(\fIfmt\fR)
Shape-directed bridge to an output format. No-op when the current shape already satisfies \fIfmt\fR. Applies a single curated bridge when one exists. Errors with a list of candidates when more than one could apply. Valid \fIfmt\fR: json, yaml, toml, csv, tsv, hcl.
.SH NULL PROPAGATION
.PP
Navigation on null returns null: \fB.missing\fR returns null, \fB.missing.nested\fR returns null.
Expand Down Expand Up @@ -213,6 +227,18 @@ Schema inspection:
lam --schema deployment.yaml
.fi
.PP
Shape trace for a pipeline:
.PP
.nf
lam --explain '.users | filter(.age > 30) | map(.name)' data.json
.fi
.PP
Bridge to an output format inside the query:
.PP
.nf
lam --to toml '.dependencies | as(toml)' pubspec.yaml
.fi
.PP
CI validation:
.PP
.nf
Expand All @@ -232,8 +258,10 @@ lam -i data.json
.fi
.SH SEE ALSO
.PP
\fBjq\fR(1)
\fBjq\fR(1) — the established JSON query tool. Lambe shares its pipeline aesthetic and extends to multi-format input with shape-aware output.
.SH BUGS
.PP
Project: https://github.com/hakimjonas/lambe
Report issues at https://github.com/hakimjonas/lambe/issues.
.SH HOMEPAGE
.PP
Documentation: https://pub.dev/packages/lambe
https://ardaproject.org/lambe
34 changes: 30 additions & 4 deletions 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.6.1
source: Lambë 0.6.2
author: Hakim Jonas Ghoula
date: April 2026
---
Expand All @@ -20,6 +20,8 @@ lam - query structured data files

Query JSON, YAML, TOML, HCL, CSV, TSV, and Markdown files using a composable pipeline DSL. Format is auto-detected from file extension.

Lambe infers the structural shape of query results and reports incompatibilities with target output formats. Use **--explain** to trace the shape at each pipeline stage, or the **as**(*fmt*) combinator inside a query to bridge common mismatches.

If no file is given, reads from standard input.

# OPTIONS
Expand All @@ -42,6 +44,9 @@ If no file is given, reads from standard input.
**--schema**
: Show the data structure with type names instead of values.

**--explain**
: Trace the shape of values flowing through each pipeline stage. Static analysis only; does not execute the query. Reports which output formats the final shape can be serialized as.

**--assert**
: Evaluate the expression and exit with code 0 if the result is true, 1 if false.

Expand Down Expand Up @@ -148,6 +153,12 @@ Queries start with **.** (the current document) and chain operations with **|**.
**from_entries**
: [{key, value}] to map.

**to_number**
: Parse a string as a number. Pass-through for existing numbers.

**type**
: Runtime type of the value as a string: "null", "boolean", "number", "string", "array", or "object".

**filter_values**(*pred*)
: Filter a map's values.

Expand All @@ -157,6 +168,9 @@ Queries start with **.** (the current document) and chain operations with **|**.
**filter_keys**(*pred*)
: Filter a map's keys.

**as**(*fmt*)
: Shape-directed bridge to an output format. No-op when the current shape already satisfies *fmt*. Applies a single curated bridge when one exists. Errors with a list of candidates when more than one could apply. Valid *fmt*: json, yaml, toml, csv, tsv, hcl.

# NULL PROPAGATION

Navigation on null returns null: **.missing** returns null, **.missing.nested** returns null.
Expand Down Expand Up @@ -228,6 +242,14 @@ Schema inspection:

lam --schema deployment.yaml

Shape trace for a pipeline:

lam --explain '.users | filter(.age > 30) | map(.name)' data.json

Bridge to an output format inside the query:

lam --to toml '.dependencies | as(toml)' pubspec.yaml

CI validation:

lam --assert '.version != "0.0.0"' package.json
Expand All @@ -242,8 +264,12 @@ Interactive exploration:

# SEE ALSO

**jq**(1)
**jq**(1) — the established JSON query tool. Lambe shares its pipeline aesthetic and extends to multi-format input with shape-aware output.

# BUGS

Report issues at <https://github.com/hakimjonas/lambe/issues>.

Project: https://github.com/hakimjonas/lambe
# HOMEPAGE

Documentation: https://pub.dev/packages/lambe
<https://ardaproject.org/lambe>
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.6.1';
const lambeVersion = '0.6.2';
70 changes: 56 additions & 14 deletions lib/src/completer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,18 @@ import 'package:rumil/rumil.dart';
import '../lambe.dart';
import 'parser.dart' as parser_;

/// Completion result: replacement [start] position and [candidates].
typedef Completions = ({int start, List<String> candidates});
/// Completion result: the half-open range `[start, end)` in the
/// original input that should be replaced with a chosen candidate,
/// and the list of [candidates].
///
/// Callers splice with `text.replaceRange(start, end, candidate)`.
/// The range ends at the last non-whitespace character of the user's
/// partial token, not at the cursor — so trailing whitespace typed
/// after a complete token is preserved on accept.
///
/// When [candidates] is empty, [start] and [end] both equal the
/// cursor position; no splice should occur.
typedef Completions = ({int start, int end, List<String> candidates});

/// All pipeline operation names, sorted alphabetically.
///
Expand Down Expand Up @@ -92,10 +102,31 @@ final Parser<ParseError, (int, String)> _fieldTailCtx = position<ParseError>()
/// small Rumil parsers. Falls through to AST-tail-based completion when
/// the remainder classifies as neither.
///
/// Contract: the returned `start` is the offset in `text` where the
/// user's typed prefix begins. Callers replace `text[start, cursor)`
/// with the chosen candidate.
/// Contract: the returned `start`/`end` delimit the range in [text]
/// that the caller should replace with the chosen candidate. `end` is
/// positioned at the last non-whitespace character of the partial
/// token, so trailing whitespace between the token and the cursor is
/// preserved on accept. When `candidates` is empty, `start` and `end`
/// both equal [cursor] and no splice should occur.
///
/// Candidates whose value equals the text already in `[start, end)`
/// are filtered out before returning, because accepting them would be
/// a no-op on the text and only move the cursor backward. If the only
/// candidate was such a re-assertion, an empty candidate list is
/// returned.
Completions complete(String text, int cursor, Object? data) {
final raw = _completeRaw(text, cursor, data);
if (raw.candidates.isEmpty) return raw;
final typed = text.substring(raw.start, raw.end);
final filtered = <String>[
for (final c in raw.candidates)
if (c != typed) c,
];
if (filtered.length == raw.candidates.length) return raw;
return (start: raw.start, end: raw.end, candidates: filtered);
}

Completions _completeRaw(String text, int cursor, Object? data) {
final before = text.substring(0, cursor);

if (before.startsWith(':')) return _completeCommand(before);
Expand All @@ -111,7 +142,7 @@ Completions complete(String text, int cursor, Object? data) {
// `parsePartial` wraps `_expr` in `_ws ... _ws`, so `consumed` may
// overshoot the AST's last significant character when the user has
// typed trailing whitespace. Walk back to recover the true AST-end
// offset; the AST-tail path computes replacement `start` from it.
// offset; the AST-tail path computes replacement `start`/`end` from it.
var astEnd = consumed;
while (astEnd > 0 && _isWs(before.codeUnitAt(astEnd - 1))) {
astEnd--;
Expand All @@ -123,8 +154,10 @@ Completions complete(String text, int cursor, Object? data) {
if (pipeRes case Success<ParseError, (int, String)>(
value: (final partialStart, final partial),
)) {
final tokenStart = consumed + partialStart;
return (
start: consumed + partialStart,
start: tokenStart,
end: tokenStart + partial.length,
candidates: <String>[
for (final op in pipelineOps)
if (op.startsWith(partial)) op,
Expand All @@ -148,7 +181,7 @@ Completions complete(String text, int cursor, Object? data) {
return _completionContext(ast, astEnd, rootShape);
}

return (start: cursor, candidates: <String>[]);
return (start: cursor, end: cursor, candidates: <String>[]);
}

bool _isWs(int c) => c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d;
Expand All @@ -158,6 +191,7 @@ Completions _completeCommand(String before) {
final prefix = before.substring(4);
return (
start: 4,
end: 4 + prefix.length,
candidates: <String>[
for (final f in _outputFormats)
if (f.startsWith(prefix)) f,
Expand All @@ -167,6 +201,7 @@ Completions _completeCommand(String before) {
final partial = before.substring(1);
return (
start: 1,
end: 1 + partial.length,
candidates: <String>[
for (final cmd in _replCommands)
if (cmd.startsWith(partial)) cmd,
Expand Down Expand Up @@ -194,7 +229,7 @@ Completions _completionContext(LamExpr ast, int astEnd, Shape inputShape) {
if (collection is SList) {
return _completionContext(inner, astEnd, collection.element);
}
return (start: astEnd, candidates: <String>[]);
return (start: astEnd, end: astEnd, candidates: <String>[]);
}
}
return _completeAstTail(ast, astEnd, inputShape);
Expand Down Expand Up @@ -230,21 +265,28 @@ Completions _completeAstTail(
astEnd,
inputShape,
),
_ => (start: astEnd, candidates: <String>[]),
_ => (start: astEnd, end: astEnd, candidates: <String>[]),
};

/// Return field name completions from [target] starting with [partial].
///
/// The [dotPos] is the position of the `.` in the input, used as the
/// replacement start. Only [SMap] shapes carry field names; any other
/// shape (including [SAny]) produces no field candidates.
/// replacement start. The replacement end is just past the last char
/// of the partial token (`dotPos + 1 + partial.length`). Only [SMap]
/// shapes carry field names; any other shape (including [SAny])
/// produces no field candidates.
Completions _fieldsOf(Shape target, String partial, int dotPos) {
final tokenEnd = dotPos + 1 + partial.length;
if (target is! SMap) {
return (start: dotPos + partial.length + 1, candidates: <String>[]);
return (start: tokenEnd, end: tokenEnd, candidates: <String>[]);
}
final matching =
target.fields.keys.where((k) => k.startsWith(partial)).toList()..sort();
return (start: dotPos, candidates: <String>[for (final k in matching) '.$k']);
return (
start: dotPos,
end: tokenEnd,
candidates: <String>[for (final k in matching) '.$k'],
);
}

/// Resolve the target shape for field completion, walking into [Pipe]
Expand Down
19 changes: 11 additions & 8 deletions lib/src/readline.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,14 @@ import 'dart:io';
/// Callback for tab completion.
///
/// Takes the current input [text] and [cursor] position. Returns a record
/// with the replacement [start] position and a sorted list of [candidates].
/// with the replacement range `[start, end)` and a sorted list of
/// [candidates]. Callers splice with
/// `text.replaceRange(start, end, candidate)`.
typedef CompleteCallback =
({int start, List<String> candidates}) Function(String text, int cursor);
({int start, int end, List<String> candidates}) Function(
String text,
int cursor,
);

/// Minimal readline with history and tab completion.
///
Expand Down Expand Up @@ -273,13 +278,12 @@ class ReadLine {
if (complete == null) return cursor;

final text = String.fromCharCodes(buf);
final (:start, :candidates) = complete(text, cursor);
final (:start, :end, :candidates) = complete(text, cursor);
if (candidates.isEmpty) return cursor;

if (candidates.length == 1) {
final replacement = candidates.first;
final newText =
'${text.substring(0, start)}$replacement${text.substring(cursor)}';
final newText = text.replaceRange(start, end, replacement);
buf
..clear()
..addAll(newText.codeUnits);
Expand All @@ -290,9 +294,8 @@ class ReadLine {

final prefix = _commonPrefix(candidates);
var newCursor = cursor;
if (prefix.length > cursor - start) {
final newText =
'${text.substring(0, start)}$prefix${text.substring(cursor)}';
if (prefix.length > end - start) {
final newText = text.replaceRange(start, end, prefix);
buf
..clear()
..addAll(newText.codeUnits);
Expand Down
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.6.1
version: 0.6.2
homepage: https://ardaproject.org/lambe
repository: https://github.com/hakimjonas/lambe
topics:
Expand Down
Loading