diff --git a/CHANGELOG.md b/CHANGELOG.md index e42e024..04c525a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/doc/lam.1 b/doc/lam.1 index f192d4c..d63cf5c 100644 --- a/doc/lam.1 +++ b/doc/lam.1 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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. @@ -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 @@ -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 diff --git a/doc/lam.1.md b/doc/lam.1.md index bb3d8f5..51e6045 100644 --- a/doc/lam.1.md +++ b/doc/lam.1.md @@ -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 --- @@ -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 @@ -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. @@ -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. @@ -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. @@ -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 @@ -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 . -Project: https://github.com/hakimjonas/lambe +# HOMEPAGE -Documentation: https://pub.dev/packages/lambe + diff --git a/lib/src/_version.dart b/lib/src/_version.dart index 52a3ef0..b181ed0 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.6.1'; +const lambeVersion = '0.6.2'; diff --git a/lib/src/completer.dart b/lib/src/completer.dart index 8480ad2..8046187 100644 --- a/lib/src/completer.dart +++ b/lib/src/completer.dart @@ -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 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 candidates}); /// All pipeline operation names, sorted alphabetically. /// @@ -92,10 +102,31 @@ final Parser _fieldTailCtx = position() /// 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 = [ + 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); @@ -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--; @@ -123,8 +154,10 @@ Completions complete(String text, int cursor, Object? data) { if (pipeRes case Success( value: (final partialStart, final partial), )) { + final tokenStart = consumed + partialStart; return ( - start: consumed + partialStart, + start: tokenStart, + end: tokenStart + partial.length, candidates: [ for (final op in pipelineOps) if (op.startsWith(partial)) op, @@ -148,7 +181,7 @@ Completions complete(String text, int cursor, Object? data) { return _completionContext(ast, astEnd, rootShape); } - return (start: cursor, candidates: []); + return (start: cursor, end: cursor, candidates: []); } bool _isWs(int c) => c == 0x20 || c == 0x09 || c == 0x0a || c == 0x0d; @@ -158,6 +191,7 @@ Completions _completeCommand(String before) { final prefix = before.substring(4); return ( start: 4, + end: 4 + prefix.length, candidates: [ for (final f in _outputFormats) if (f.startsWith(prefix)) f, @@ -167,6 +201,7 @@ Completions _completeCommand(String before) { final partial = before.substring(1); return ( start: 1, + end: 1 + partial.length, candidates: [ for (final cmd in _replCommands) if (cmd.startsWith(partial)) cmd, @@ -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: []); + return (start: astEnd, end: astEnd, candidates: []); } } return _completeAstTail(ast, astEnd, inputShape); @@ -230,21 +265,28 @@ Completions _completeAstTail( astEnd, inputShape, ), - _ => (start: astEnd, candidates: []), + _ => (start: astEnd, end: astEnd, candidates: []), }; /// 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: []); + return (start: tokenEnd, end: tokenEnd, candidates: []); } final matching = target.fields.keys.where((k) => k.startsWith(partial)).toList()..sort(); - return (start: dotPos, candidates: [for (final k in matching) '.$k']); + return ( + start: dotPos, + end: tokenEnd, + candidates: [for (final k in matching) '.$k'], + ); } /// Resolve the target shape for field completion, walking into [Pipe] diff --git a/lib/src/readline.dart b/lib/src/readline.dart index 9cdce29..fe0fda9 100644 --- a/lib/src/readline.dart +++ b/lib/src/readline.dart @@ -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 candidates}) Function(String text, int cursor); + ({int start, int end, List candidates}) Function( + String text, + int cursor, + ); /// Minimal readline with history and tab completion. /// @@ -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); @@ -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); diff --git a/pubspec.yaml b/pubspec.yaml index 1b1a68e..10b724f 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.6.1 +version: 0.6.2 homepage: https://ardaproject.org/lambe repository: https://github.com/hakimjonas/lambe topics: diff --git a/test/completer_test.dart b/test/completer_test.dart index 484030a..45e8150 100644 --- a/test/completer_test.dart +++ b/test/completer_test.dart @@ -15,32 +15,32 @@ void main() { group('Field completion', () { test('root fields from identity', () { - final (:start, :candidates) = complete('.', 1, sampleData); + final (:start, :end, :candidates) = complete('.', 1, sampleData); expect(start, 0); expect(candidates, containsAll(['.config', '.users', '.version'])); }); test('partial match on root fields', () { - final (:start, :candidates) = complete('.us', 3, sampleData); + final (:start, :end, :candidates) = complete('.us', 3, sampleData); expect(start, 0); expect(candidates, ['.users']); }); test('multiple partial matches', () { final data = {'name': 'x', 'namespace': 'y', 'age': 1}; - final (:start, :candidates) = complete('.na', 3, data); + final (:start, :end, :candidates) = complete('.na', 3, data); expect(start, 0); expect(candidates, ['.name', '.namespace']); }); test('nested fields', () { - final (:start, :candidates) = complete('.config.', 8, sampleData); + final (:start, :end, :candidates) = complete('.config.', 8, sampleData); expect(start, 7); expect(candidates, ['.database']); }); test('deeply nested fields', () { - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.config.database.', 17, sampleData, @@ -50,52 +50,76 @@ void main() { }); test('after index access', () { - final (:start, :candidates) = complete('.users[0].', 10, sampleData); + final (:start, :end, :candidates) = complete( + '.users[0].', + 10, + sampleData, + ); expect(start, 9); expect(candidates, containsAll(['.active', '.age', '.name'])); }); test('partial after index access', () { - final (:start, :candidates) = complete('.users[0].na', 12, sampleData); + final (:start, :end, :candidates) = complete( + '.users[0].na', + 12, + sampleData, + ); expect(start, 9); expect(candidates, ['.name']); }); test('no match returns empty', () { - final (:start, :candidates) = complete('.xyz', 4, sampleData); + final (:start, :end, :candidates) = complete('.xyz', 4, sampleData); expect(candidates, isEmpty); }); test('non-map target returns empty', () { - final (:start, :candidates) = complete('.version.', 9, sampleData); + final (:start, :end, :candidates) = complete('.version.', 9, sampleData); expect(candidates, isEmpty); }); }); group('Pipeline operation completion', () { test('all ops after |', () { - final (:start, :candidates) = complete('.users | ', 9, sampleData); + final (:start, :end, :candidates) = complete('.users | ', 9, sampleData); expect(candidates.length, pipelineOps.length); expect(candidates, pipelineOps); }); test('partial match after |', () { - final (:start, :candidates) = complete('.users | fil', 12, sampleData); + final (:start, :end, :candidates) = complete( + '.users | fil', + 12, + sampleData, + ); expect(candidates, ['filter', 'filter_keys', 'filter_values']); }); test('single match after |', () { - final (:start, :candidates) = complete('.users | rev', 12, sampleData); + final (:start, :end, :candidates) = complete( + '.users | rev', + 12, + sampleData, + ); expect(candidates, ['reverse']); }); test('no match after |', () { - final (:start, :candidates) = complete('.users | xyz', 12, sampleData); + final (:start, :end, :candidates) = complete( + '.users | xyz', + 12, + sampleData, + ); expect(candidates, isEmpty); }); test('start position is after | and space', () { - final (:start, :candidates) = complete('.users | so', 11, sampleData); + final (:start, :end, :candidates) = complete( + '.users | so', + 11, + sampleData, + ); expect(start, 9); expect(candidates, ['sort', 'sort_by']); }); @@ -103,7 +127,7 @@ void main() { group('Inner field completion', () { test('inside filter(.', () { - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.users | filter(.', 17, sampleData, @@ -113,7 +137,7 @@ void main() { }); test('partial inside filter', () { - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.users | filter(.na', 19, sampleData, @@ -123,13 +147,17 @@ void main() { }); test('inside map(.', () { - final (:start, :candidates) = complete('.users | map(.', 14, sampleData); + final (:start, :end, :candidates) = complete( + '.users | map(.', + 14, + sampleData, + ); expect(start, 13); expect(candidates, containsAll(['.active', '.age', '.name'])); }); test('inside sort_by(.', () { - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.users | sort_by(.', 18, sampleData, @@ -139,7 +167,7 @@ void main() { }); test('after chained pipeline', () { - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.users | sort_by(.name) | filter(.a', 35, sampleData, @@ -149,7 +177,7 @@ void main() { }); test('non-list input returns empty', () { - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.config | filter(.', 18, sampleData, @@ -170,53 +198,70 @@ void main() { test('nested field inside filter', () { const text = '.users | filter(.address.ci'; - final (:start, :candidates) = complete(text, text.length, nestedData); + final (:start, :end, :candidates) = complete( + text, + text.length, + nestedData, + ); expect(candidates, ['.city']); }); test('nested field inside map', () { const text = '.users | map(.address.'; - final (:start, :candidates) = complete(text, text.length, nestedData); + final (:start, :end, :candidates) = complete( + text, + text.length, + nestedData, + ); expect(candidates, containsAll(['.city', '.zip'])); }); test('empty filter paren offers all element fields', () { const text = '.users | filter(.'; - final (:start, :candidates) = complete(text, text.length, nestedData); + final (:start, :end, :candidates) = complete( + text, + text.length, + nestedData, + ); expect(candidates, containsAll(['.address', '.name'])); }); }); group('Command completion', () { test(':to format completion', () { - final (:start, :candidates) = complete(':to ', 4, null); + final (:start, :end, :candidates) = complete(':to ', 4, null); expect(start, 4); expect(candidates, ['csv', 'hcl', 'json', 'toml', 'tsv', 'yaml']); }); test(':to partial', () { - final (:start, :candidates) = complete(':to y', 5, null); + final (:start, :end, :candidates) = complete(':to y', 5, null); expect(candidates, ['yaml']); }); test(':to no match', () { - final (:start, :candidates) = complete(':to z', 5, null); + final (:start, :end, :candidates) = complete(':to z', 5, null); expect(candidates, isEmpty); }); test('command name completion', () { - final (:start, :candidates) = complete(':sch', 4, null); + final (:start, :end, :candidates) = complete(':sch', 4, null); expect(start, 1); expect(candidates, ['schema']); }); - test('command prefix q matches q and quit', () { - final (:start, :candidates) = complete(':q', 2, null); - expect(candidates, ['q', 'quit']); - }); + test( + 'command prefix q matches quit (q itself is filtered as already typed)', + () { + final (:start, :end, :candidates) = complete(':q', 2, null); + // 'q' is a re-assertion candidate (equals text[1, 2)) and gets + // filtered. 'quit' remains as the useful completion. + expect(candidates, ['quit']); + }, + ); test('all commands on bare colon', () { - final (:start, :candidates) = complete(':', 1, null); + final (:start, :end, :candidates) = complete(':', 1, null); expect(candidates.length, 9); expect(candidates, contains('help')); expect(candidates, contains('schema')); @@ -226,30 +271,46 @@ void main() { group('String-with-pipe regression', () { test('pipe inside string literal does not confuse pipe detection', () { const text = '.users | map(.name + " | ") | fil'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(candidates, ['filter', 'filter_keys', 'filter_values']); }); test('pipe in filter predicate string does not confuse completion', () { const text = '.users | filter(.name != "admin|root") | '; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(candidates.length, pipelineOps.length); }); test('empty input returns empty', () { - final (:start, :candidates) = complete('', 0, sampleData); + final (:start, :end, :candidates) = complete('', 0, sampleData); expect(candidates, isEmpty); }); test('short-op ambiguity: sort_ completes to sort_by', () { const text = '.users | sort_'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(candidates, ['sort_by']); }); test('short-op ambiguity: unique_ completes to unique_by', () { const text = '.users | unique_'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(candidates, ['unique_by']); }); }); @@ -257,26 +318,38 @@ void main() { group('Recovery edge cases', () { test('conditional: complete in then-branch (missing else)', () { const text = 'if true then .us'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(start, text.length - 3); expect(candidates, ['.users']); }); test('conditional: complete in else-branch', () { const text = 'if true then .name else .ver'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(start, text.length - 4); expect(candidates, ['.version']); }); test('string interpolation: field inside \\(.', () { const text = r'"hello \(.us'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(candidates, ['.users']); }); test('binary op right side: .age > 20 && .na', () { - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.users | filter(.age > 20 && .na', 32, sampleData, @@ -288,21 +361,33 @@ void main() { test('complete after pipe chain with all op types', () { // Verify recovery works through chained pipes const text = '.users | filter(.active) | map(.na'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(start, text.length - 3); expect(candidates, ['.name']); }); test('empty object construction does not crash', () { const text = '.users | map({'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); // May or may not have candidates, but must not throw expect(candidates, isA>()); }); test('empty index bracket does not crash', () { const text = '.users['; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(candidates, isA>()); }); @@ -310,7 +395,11 @@ void main() { // Verify each parameterized op produces completions with recovery for (final op in ['filter', 'map', 'sort_by', 'group_by', 'unique_by']) { final text = '.users | $op(.na'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); expect(candidates, contains('.name'), reason: '$op should complete'); } }); @@ -325,7 +414,7 @@ void main() { // filter_values operates on map values, not list elements // Currently returns empty (non-list input), which is acceptable const text = '.scores | filter_values(.to'; - final (:start, :candidates) = complete(text, text.length, data); + final (:start, :end, :candidates) = complete(text, text.length, data); // filter_values context is a map, not a list: no crash expect(candidates, isA>()); }); @@ -333,7 +422,11 @@ void main() { test('complete expression is never Partial', () { // Verify recovery doesn't fire on complete expressions const text = '.users | filter(.age > 30)'; - final (:start, :candidates) = complete(text, text.length, sampleData); + final (:start, :end, :candidates) = complete( + text, + text.length, + sampleData, + ); // Complete expression: no field completion context expect(candidates, isEmpty); }); @@ -348,118 +441,322 @@ void main() { // reintroduces the bug. group('Trailing whitespace regression', () { // --- Identity tail ------------------------------------------- - test('trailing space after identity: start points at the dot', () { - final (:start, :candidates) = complete('. ', 2, sampleData); + test('trailing space after identity: offers all root fields', () { + // ". " with cursor at 2: the typed token is "." (just the dot). + // "." is not equal to any candidate like ".config", so all + // candidates pass the re-assertion filter. + final (:start, :end, :candidates) = complete('. ', 2, sampleData); expect(start, 0); + expect(end, 1); expect(candidates, containsAll(['.config', '.users', '.version'])); }); // --- Field tail ---------------------------------------------- - test('trailing space after field: start points at the dot', () { - final (:start, :candidates) = complete('.users ', 7, sampleData); + // These cases previously asserted `candidates: [".users"]`. With + // the re-assertion filter, a fully-typed ".users" (no matter how + // much trailing whitespace follows) has no useful completion: + // the only candidate equals what's already typed. + test('trailing space after fully-typed field: no candidates', () { + final (:start, :end, :candidates) = complete('.users ', 7, sampleData); expect(start, 0); - expect(candidates, ['.users']); + expect(end, 6); + expect(candidates, isEmpty); }); - test('trailing tab after field: start points at the dot', () { - final (:start, :candidates) = complete('.users\t', 7, sampleData); - expect(start, 0); - expect(candidates, ['.users']); + test('trailing tab after fully-typed field: no candidates', () { + final (:start, :end, :candidates) = complete('.users\t', 7, sampleData); + expect(candidates, isEmpty); }); - test('trailing newline after field: start points at the dot', () { - final (:start, :candidates) = complete('.users\n', 7, sampleData); - expect(start, 0); - expect(candidates, ['.users']); + test('trailing newline after fully-typed field: no candidates', () { + final (:start, :end, :candidates) = complete('.users\n', 7, sampleData); + expect(candidates, isEmpty); }); - test('multiple trailing spaces after field', () { - final (:start, :candidates) = complete('.users ', 9, sampleData); - expect(start, 0); - expect(candidates, ['.users']); + test('multiple trailing spaces after fully-typed field: no candidates', () { + final (:start, :end, :candidates) = complete('.users ', 9, sampleData); + expect(candidates, isEmpty); }); - test('mixed trailing whitespace after field', () { - final (:start, :candidates) = complete('.users \t ', 9, sampleData); - expect(start, 0); - expect(candidates, ['.users']); - }); + test( + 'mixed trailing whitespace after fully-typed field: no candidates', + () { + final (:start, :end, :candidates) = complete( + '.users \t ', + 9, + sampleData, + ); + expect(candidates, isEmpty); + }, + ); // --- Access tail --------------------------------------------- - test('trailing space after access: start at the last dot', () { - final (:start, :candidates) = complete( + test('trailing space after fully-typed access: no candidates', () { + final (:start, :end, :candidates) = complete( '.config.database ', 17, sampleData, ); - expect(start, 7); - expect(candidates, ['.database']); + expect(candidates, isEmpty); }); // --- Inside parameterized ops -------------------------------- - test('trailing space after field inside filter()', () { + test('trailing space after fully-typed field inside filter()', () { // Cursor between the space and the closing paren. - final (:start, :candidates) = complete( + // `.age` is fully typed, so the filter drops the re-assertion. + final (:start, :end, :candidates) = complete( '.users | filter(.age )', 21, sampleData, ); - expect(start, 16); - expect(candidates, ['.age']); + expect(candidates, isEmpty); }); - test('trailing space after field inside map()', () { + test('trailing space after fully-typed field inside map()', () { // Cursor between the space and the closing paren. - final (:start, :candidates) = complete( + final (:start, :end, :candidates) = complete( '.users | map(.name )', 19, sampleData, ); - expect(start, 13); - expect(candidates, ['.name']); + expect(candidates, isEmpty); }); // --- Pipe-op path with trailing whitespace ------------------- - // When the user has typed `| ` and presses Tab, the - // intent is unambiguous: complete the partial op. The returned - // `start` must point at the first character of the partial name, - // not past it, and candidates must be the pipeline ops matching - // the partial (not field candidates). + // "fil" is partial: the filter keeps filter/filter_keys/filter_values + // because none of them equal "fil" exactly. test('trailing space after partial pipe op: pipe-op path applies', () { - final (:start, :candidates) = complete('.users | fil ', 13, sampleData); + final (:start, :end, :candidates) = complete( + '.users | fil ', + 13, + sampleData, + ); expect(start, 9); + expect(end, 12); expect(candidates, ['filter', 'filter_keys', 'filter_values']); }); test('multiple trailing spaces after partial pipe op', () { - final (:start, :candidates) = complete('.users | fil ', 15, sampleData); + final (:start, :end, :candidates) = complete( + '.users | fil ', + 15, + sampleData, + ); expect(start, 9); + expect(end, 12); expect(candidates, ['filter', 'filter_keys', 'filter_values']); }); - // --- Parity: no-trailing-whitespace variants must not regress - // These already pass on HEAD. They are here so the fix cannot - // break them in pursuit of the regression cases above. - test('no trailing whitespace after field still works', () { - final (:start, :candidates) = complete('.users', 6, sampleData); + // --- Parity: no-trailing-whitespace variants -------------- + test('no trailing whitespace after fully-typed field: no candidates', () { + final (:start, :end, :candidates) = complete('.users', 6, sampleData); expect(start, 0); - expect(candidates, ['.users']); + expect(end, 6); + expect(candidates, isEmpty); }); - test('no trailing whitespace inside filter() still works', () { - final (:start, :candidates) = complete( - '.users | filter(.age', - 20, + test( + 'no trailing whitespace, fully-typed inside filter(): no candidates', + () { + final (:start, :end, :candidates) = complete( + '.users | filter(.age', + 20, + sampleData, + ); + expect(candidates, isEmpty); + }, + ); + + test('pipe-op partial without trailing whitespace: offers matches', () { + final (:start, :end, :candidates) = complete( + '.users | fil', + 12, sampleData, ); - expect(start, 16); - expect(candidates, ['.age']); - }); - - test('pipe-op partial without trailing whitespace', () { - final (:start, :candidates) = complete('.users | fil', 12, sampleData); expect(start, 9); + expect(end, 12); expect(candidates, ['filter', 'filter_keys', 'filter_values']); }); }); + + // Each test here verifies the `end` field of `Completions`. The + // contract: `end` is the position just past the last non-whitespace + // character of the partial token. Callers splice candidates over + // `text[start, end)` so that trailing whitespace between the token + // and the cursor is preserved when accepting a candidate. + group('Replacement range (start/end contract)', () { + test('partial field: end at end of typed chars', () { + final r = complete('.us', 3, sampleData); + expect(r.start, 0); + expect(r.end, 3); + }); + + test('complete field with no trailing ws: end at end of token', () { + final r = complete('.users', 6, sampleData); + expect(r.start, 0); + expect(r.end, 6); + }); + + test( + 'complete field + trailing space: end at end of token, NOT cursor', + () { + final r = complete('.users ', 7, sampleData); + expect(r.start, 0); + expect(r.end, 6); + }, + ); + + test('complete field + multiple spaces: end at end of token', () { + final r = complete('.users ', 9, sampleData); + expect(r.start, 0); + expect(r.end, 6); + }); + + test('complete field + tab: end at end of token', () { + final r = complete('.users\t', 7, sampleData); + expect(r.start, 0); + expect(r.end, 6); + }); + + test('lone dot: end just past the dot', () { + final r = complete('.', 1, sampleData); + expect(r.start, 0); + expect(r.end, 1); + }); + + test('lone dot + space: end at position 1 (just past the dot)', () { + final r = complete('. ', 2, sampleData); + expect(r.start, 0); + expect(r.end, 1); + }); + + test('nested: .config.database: end at end of "database"', () { + final r = complete('.config.database', 16, sampleData); + expect(r.start, 7); + expect(r.end, 16); + }); + + test('nested + trailing ws: end at end of "database", not cursor', () { + final r = complete('.config.database ', 17, sampleData); + expect(r.start, 7); + expect(r.end, 16); + }); + + test('pipe-op partial: end at end of partial op name', () { + final r = complete('.users | fil', 12, sampleData); + expect(r.start, 9); + expect(r.end, 12); + }); + + test('pipe-op partial + trailing space: end at end of op', () { + final r = complete('.users | fil ', 13, sampleData); + expect(r.start, 9); + expect(r.end, 12); + }); + + test('bare pipe | then space: end at cursor (nothing to replace)', () { + final r = complete('.users | ', 9, sampleData); + expect(r.start, 9); + expect(r.end, 9); + }); + + test('inside filter(.: end just past the dot', () { + final r = complete('.users | filter(.', 17, sampleData); + expect(r.start, 16); + expect(r.end, 17); + }); + + test('inside filter(.na: end at end of "na"', () { + final r = complete('.users | filter(.na', 19, sampleData); + expect(r.start, 16); + expect(r.end, 19); + }); + + test(':to yaml: end at end of "yaml"', () { + final r = complete(':to yaml', 8, null); + expect(r.start, 4); + expect(r.end, 8); + }); + + test(':q command prefix: end at end of "q"', () { + final r = complete(':q', 2, null); + expect(r.start, 1); + expect(r.end, 2); + }); + + test('empty candidates: start == end == cursor', () { + final r = complete('.xyz', 4, sampleData); + expect(r.candidates, isEmpty); + // xyz is not a root field; no candidates, but the typed token + // range is still reported so callers know what was considered. + expect(r.start, 0); + expect(r.end, 4); + }); + + test('splice semantics: partial token + trailing ws preserves the ws', () { + // Use `.us ` so the candidate (`.users`) differs from the + // typed token (`.us`) and survives the re-assertion filter. + const text = '.us '; + final r = complete(text, text.length, sampleData); + expect(r.candidates, ['.users']); + final accepted = text.replaceRange(r.start, r.end, r.candidates.first); + expect(accepted, '.users '); + }); + }); + + // The re-assertion filter: if a candidate's text equals what's + // already typed in the [start, end) range, it's filtered out. + // Accepting a re-assertion candidate is a no-op on the text and + // would only move the cursor backward — actively surprising. + group('Re-assertion filter', () { + test('fully-typed field: filtered out, empty candidates', () { + final r = complete('.users', 6, sampleData); + expect(r.candidates, isEmpty); + }); + + test('partial field: candidate passes filter', () { + final r = complete('.us', 3, sampleData); + expect(r.candidates, ['.users']); + }); + + test('.dependencies jd: filtered because .dependencies already typed', () { + final data = {'dependencies': {}}; + final r = complete('.dependencies jd', 16, data); + expect(r.candidates, isEmpty); + }); + + test( + 'fully-typed pipeline op: its exact match filtered, prefix-matches kept', + () { + // ".users | filter" matches three ops: filter, filter_keys, filter_values. + // The filter strips "filter" (exact match on typed text) but keeps + // the others. Accepting either extends the typed text usefully. + final r = complete('.users | filter', 15, sampleData); + expect(r.candidates, ['filter_keys', 'filter_values']); + }, + ); + + test('partial pipeline op: unambiguous match passes filter', () { + // "rev" is a partial; "reverse" is offered. + final r = complete('.users | rev', 12, sampleData); + expect(r.candidates, ['reverse']); + }); + + test(':quit filters out itself', () { + final r = complete(':quit', 5, null); + expect(r.candidates, isEmpty); + }); + + test(':q filters q but keeps quit', () { + final r = complete(':q', 2, null); + expect(r.candidates, ['quit']); + }); + + test('multiple candidates: filter only drops the exact-match one', () { + // `:h` matches `help` only; `h` itself is not a full command. + // Typed token is `h`, neither `help` nor any other cmd equals `h`. + final r = complete(':h', 2, null); + expect(r.candidates, ['help', 'history']); + }); + }); }