From 3a3220471d2b10811e77fe942a6c58f53df316b0 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 3 Aug 2026 20:43:37 +0200 Subject: feat(completion): complete both bounds of a date range Each side of '..' is an independent value against the same model. Entries that are themselves ranges are withheld once a range exists, since date:1week....today is malformed. The token is read to its full extent rather than truncated at the caret: the separator deciding which bound is being edited can sit to the right of the caret. Stems stay caret-bounded so matching never uses untyped text. --- src/querycompleter.cpp | 63 ++++++++++++++++++++++++++++++++++++++----- src/querycompleter.h | 7 +++++ tests/test_querycompleter.cpp | 42 +++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/src/querycompleter.cpp b/src/querycompleter.cpp index 45ef581..6bdd494 100644 --- a/src/querycompleter.cpp +++ b/src/querycompleter.cpp @@ -46,6 +46,22 @@ int tokenStart(const QString &text, int cursor) return start; } +/// End of the token the cursor sits in, using the same boundary characters as +/// tokenStart plus ')'. The token must extend past the cursor: a range +/// separator to the right of the cursor decides which bound is being edited, +/// so truncating the token at the cursor would hide it. +int tokenEnd(const QString &text, int cursor) +{ + int end = cursor; + while (end < text.size()) { + const QChar c = text.at(end); + if (c.isSpace() || c == QLatin1Char('(') || c == QLatin1Char(')')) + break; + ++end; + } + return end; +} + } // namespace CompletionContext completionContext(const QString &text, int cursor) @@ -59,21 +75,54 @@ CompletionContext completionContext(const QString &text, int cursor) return ctx; // kind stays None const int start = tokenStart(text, cursor); - const QString token = text.mid(start, cursor - start); + const int end = tokenEnd(text, cursor); + const QString token = text.mid(start, end - start); + + // Everything the user has typed up to the caret. Candidates are matched + // against this, never against text still sitting to the right of it. + const QString typed = text.mid(start, cursor - start); const int colon = token.indexOf(QLatin1Char(':')); - if (colon < 0) { + if (colon < 0 || cursor <= start + colon) { + // No prefix yet, or the caret is still inside the keyword itself. ctx.kind = CompletionContext::Prefix; - ctx.stem = token; + ctx.stem = typed; ctx.replaceFrom = start; - ctx.replaceLength = token.size(); + ctx.replaceLength = typed.size(); return ctx; } ctx.kind = CompletionContext::Value; ctx.prefix = token.left(colon).toLower(); - ctx.stem = token.mid(colon + 1); - ctx.replaceFrom = start + colon + 1; - ctx.replaceLength = ctx.stem.size(); + + const QString value = token.mid(colon + 1); + const int valueStart = start + colon + 1; + + // A range is two independent values. Complete whichever side the cursor + // is in, leaving the other untouched. + const int separator = value.indexOf(QStringLiteral("..")); + if (separator < 0) { + ctx.stem = text.mid(valueStart, cursor - valueStart); + ctx.replaceFrom = valueStart; + ctx.replaceLength = ctx.stem.size(); + return ctx; + } + + ctx.allowRangeEntries = false; + + const int cursorInValue = cursor - valueStart; + if (cursorInValue <= separator) { + // stem uses the cursor offset while replaceLength covers the whole + // side: matching runs on what has been typed so far, but accepting + // replaces the entire bound, so completing mid-word leaves no tail. + ctx.stem = value.left(cursorInValue); + ctx.replaceFrom = valueStart; + ctx.replaceLength = separator; + } else { + const int upperStart = separator + 2; + ctx.stem = value.mid(upperStart, cursorInValue - upperStart); + ctx.replaceFrom = valueStart + upperStart; + ctx.replaceLength = value.size() - upperStart; + } return ctx; } diff --git a/src/querycompleter.h b/src/querycompleter.h index d9635c0..e2a983b 100644 --- a/src/querycompleter.h +++ b/src/querycompleter.h @@ -44,6 +44,13 @@ struct CompletionContext /// being completed, so accepting never disturbs neighbouring text. int replaceFrom = 0; int replaceLength = 0; + + /// Whether candidates that are themselves ranges may be offered. + /// + /// The relative date entries ("1week..") are complete open-ended ranges. + /// Offering one inside an existing range yields date:1week....today, which + /// is malformed, so they are withheld once a range is underway. + bool allowRangeEntries = true; }; /// Decides what the cursor position implies about completion. diff --git a/tests/test_querycompleter.cpp b/tests/test_querycompleter.cpp index c86edac..1d06f4b 100644 --- a/tests/test_querycompleter.cpp +++ b/tests/test_querycompleter.cpp @@ -35,6 +35,10 @@ private slots: void emptyValueAfterColonStillCompletes(); void insideQuotesCompletesNothing(); void afterClosedQuotesCompletesAgain(); + void rangeUpperBoundCompletes(); + void rangeLowerBoundCompletes(); + void bareValueAllowsRelativeEntries(); + void rangeSuppressesRelativeEntries(); }; void TestQueryCompleter::emptyTextCompletesPrefix() @@ -121,5 +125,43 @@ void TestQueryCompleter::afterClosedQuotesCompletesAgain() QCOMPARE(ctx.stem, QStringLiteral("ta")); } +void TestQueryCompleter::rangeUpperBoundCompletes() +{ + const QString text = QStringLiteral("date:today..yes"); + const CompletionContext ctx = completionContext(text, text.size()); + QCOMPARE(ctx.kind, CompletionContext::Value); + QCOMPARE(ctx.prefix, QStringLiteral("date")); + QCOMPARE(ctx.stem, QStringLiteral("yes")); + // Overwrites "yes" only: "date:today.." must survive. + QCOMPARE(ctx.replaceFrom, 12); + QCOMPARE(ctx.replaceLength, 3); +} + +void TestQueryCompleter::rangeLowerBoundCompletes() +{ + // Cursor sits at offset 8, before the "..". + const QString text = QStringLiteral("date:las..today"); + const CompletionContext ctx = completionContext(text, 8); + QCOMPARE(ctx.kind, CompletionContext::Value); + QCOMPARE(ctx.stem, QStringLiteral("las")); + QCOMPARE(ctx.replaceFrom, 5); + QCOMPARE(ctx.replaceLength, 3); +} + +void TestQueryCompleter::bareValueAllowsRelativeEntries() +{ + const QString text = QStringLiteral("date:1w"); + const CompletionContext ctx = completionContext(text, text.size()); + QVERIFY(ctx.allowRangeEntries); +} + +void TestQueryCompleter::rangeSuppressesRelativeEntries() +{ + // "1week.." offered here would produce date:1week....today. + const QString text = QStringLiteral("date:1w..today"); + const CompletionContext ctx = completionContext(text, 7); + QVERIFY(!ctx.allowRangeEntries); +} + QTEST_MAIN(TestQueryCompleter) #include "test_querycompleter.moc" -- cgit v1.2.3