diff --git a/LLMSuggestion.cpp b/LLMSuggestion.cpp index 7e265d2..5a79436 100644 --- a/LLMSuggestion.cpp +++ b/LLMSuggestion.cpp @@ -9,56 +9,43 @@ namespace QodeAssist { -static QStringList extractTokens(const QString &str) +static bool isClosingTail(const QString &s, int from) { - QStringList tokens; - QString currentToken; - for (const QChar &ch : str) { - if (ch.isLetterOrNumber() || ch == '_') { - currentToken += ch; - } else { - if (!currentToken.isEmpty() && currentToken.length() > 1) { - tokens.append(currentToken); - } - currentToken.clear(); - } + static const QString closeChars = QStringLiteral("(){}[];,"); + for (int i = from; i < s.size(); ++i) { + const QChar c = s.at(i); + if (!c.isSpace() && !closeChars.contains(c)) + return false; } - if (!currentToken.isEmpty() && currentToken.length() > 1) { - tokens.append(currentToken); - } - return tokens; + return true; } -int LLMSuggestion::calculateReplaceLength(const QString &suggestion, - const QString &rightText, - const QString &entireLine) +int LLMSuggestion::calculateReplaceLength(const QString &suggestion, const QString &rightText) { - if (rightText.isEmpty()) { + if (rightText.isEmpty()) return 0; + + const int maxN = qMin(suggestion.size(), rightText.size()); + int lcp = 0; + while (lcp < maxN && suggestion.at(lcp) == rightText.at(lcp)) + ++lcp; + + if (lcp > 0) { + if (isClosingTail(rightText, lcp)) + return rightText.size(); + return lcp; } - QString structuralChars = "{}[]()<>;,"; - bool hasStructuralOverlap = false; - for (const QChar &ch : structuralChars) { - if (suggestion.contains(ch) && rightText.contains(ch)) { - hasStructuralOverlap = true; - break; - } - } - - if (hasStructuralOverlap) { - return rightText.length(); - } + if (!isClosingTail(rightText, 0)) + return 0; + + static const QString closeChars = QStringLiteral("(){}[];,"); + int i = suggestion.size() - 1; + while (i >= 0 && suggestion.at(i).isSpace()) + --i; + if (i >= 0 && closeChars.contains(suggestion.at(i)) && rightText.contains(suggestion.at(i))) + return rightText.size(); - const QStringList suggestionTokens = extractTokens(suggestion); - const QStringList lineTokens = extractTokens(entireLine); - - for (const auto &token : suggestionTokens) { - if (lineTokens.contains(token)) { - return rightText.length(); - } - } - return 0; } @@ -82,22 +69,21 @@ LLMSuggestion::LLMSuggestion( QString rightText = blockText.mid(cursorPositionInBlock); QString suggestionText = data.text; - QString entireLine = blockText; if (!suggestionText.contains('\n')) { - int replaceLength = calculateReplaceLength(suggestionText, rightText, entireLine); + int replaceLength = calculateReplaceLength(suggestionText, rightText); QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText; - + QString displayText = leftText + suggestionText + remainingRightText; replacementDocument()->setPlainText(displayText); } else { int firstLineEnd = suggestionText.indexOf('\n'); QString firstLine = suggestionText.left(firstLineEnd); QString restOfCompletion = suggestionText.mid(firstLineEnd); - - int replaceLength = calculateReplaceLength(firstLine, rightText, entireLine); + + int replaceLength = calculateReplaceLength(firstLine, rightText); QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText; - + QString displayText = leftText + firstLine + remainingRightText + restOfCompletion; replacementDocument()->setPlainText(displayText); } @@ -147,10 +133,9 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget) if (startPos == 0) { QTextBlock currentBlock = cursor.block(); QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock()); - QString entireLine = currentBlock.text(); - - int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine); - + + int replaceLength = calculateReplaceLength(text, textAfterCursor); + if (replaceLength > 0) { currentCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength); currentCursor.removeSelectedText(); @@ -200,9 +185,7 @@ bool LLMSuggestion::apply() QString text = currentData.text; QTextBlock currentBlock = cursor.block(); - QString textBeforeCursor = currentBlock.text().left(cursor.positionInBlock()); QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock()); - QString entireLine = currentBlock.text(); QTextCursor editCursor = cursor; editCursor.beginEditBlock(); @@ -212,22 +195,22 @@ bool LLMSuggestion::apply() QString firstLine = text.left(firstLineEnd); QString restOfText = text.mid(firstLineEnd); - int replaceLength = calculateReplaceLength(firstLine, textAfterCursor, entireLine); - + int replaceLength = calculateReplaceLength(firstLine, textAfterCursor); + if (replaceLength > 0) { editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength); editCursor.removeSelectedText(); } - + editCursor.insertText(firstLine + restOfText); } else { - int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine); - + int replaceLength = calculateReplaceLength(text, textAfterCursor); + if (replaceLength > 0) { editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength); editCursor.removeSelectedText(); } - + editCursor.insertText(text); } diff --git a/LLMSuggestion.hpp b/LLMSuggestion.hpp index c75161f..556aa80 100644 --- a/LLMSuggestion.hpp +++ b/LLMSuggestion.hpp @@ -42,8 +42,6 @@ public: bool applyPart(Part part, TextEditor::TextEditorWidget *widget); bool apply() override; - static int calculateReplaceLength(const QString &suggestion, - const QString &rightText, - const QString &entireLine); + static int calculateReplaceLength(const QString &suggestion, const QString &rightText); }; } // namespace QodeAssist diff --git a/test/LLMSuggestionTest.cpp b/test/LLMSuggestionTest.cpp index 93a5f7e..b3ee856 100644 --- a/test/LLMSuggestionTest.cpp +++ b/test/LLMSuggestionTest.cpp @@ -15,127 +15,97 @@ class LLMSuggestionTest : public QObject, public testing::Test Q_OBJECT }; -// Basic tests -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthEmptyRight) +// Degenerate / no-op cases +TEST_F(LLMSuggestionTest, emptyRight) { - int result = LLMSuggestion::calculateReplaceLength("foo", "", "foo"); - EXPECT_EQ(result, 0); // No rightText to replace + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("foo", ""), 0); } -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthNoOverlap) +TEST_F(LLMSuggestionTest, noOverlap) { - // No structural or token overlap - int result = LLMSuggestion::calculateReplaceLength("foo", "bar", "foobar"); - EXPECT_EQ(result, 0); // Just insert, don't replace + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("foo", "bar"), 0); } -// Structural overlap tests -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralBraces) +TEST_F(LLMSuggestionTest, singleCharNoOverlap) { - // suggestion contains {}, rightText contains {} - int result = LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};", "colors{};"); - EXPECT_EQ(result, 3); // Replace all rightText + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("a", "b"), 0); } -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralSemicolon) +// LCP: model echoed the existing right side as its own prefix +TEST_F(LLMSuggestionTest, lcpExtendsRight) { - // suggestion contains ;, rightText contains ; - int result = LLMSuggestion::calculateReplaceLength("x;", ";", "int x;"); - EXPECT_EQ(result, 1); // Replace the ; + // cursor: int |myVariable ; suggestion echoes "myVar" then adds nothing. + // LCP=5, remainder "iable" not a closing tail -> replace 5. + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("myVar", "myVariable"), 5); } -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralParens) +TEST_F(LLMSuggestionTest, lcpPartialEngineCall) { - // suggestion contains (), rightText contains ) - int result = LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")", "foo(arg1, arg2)"); - EXPECT_EQ(result, 1); // Replace the ) + // cursor: |engine.rootContext() ; suggestion: engine.load() + // LCP="engine." (7); remainder "rootContext()" is not closing-only -> keep LCP + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()"), 7); } -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralBrackets) +// LCP + closing-only tail: extend to full right +TEST_F(LLMSuggestionTest, lcpThenClosingTailExtendsFull) { - // suggestion contains [], rightText contains ] - int result = LLMSuggestion::calculateReplaceLength("[0]", "];", "arr[0];"); - EXPECT_EQ(result, 2); // Replace ]; + // cursor: QStringList |colors{}; ; suggestion: colors << "red" + // LCP=6 ("colors"); remainder "{};" is punctuation -> replace all (9) + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};"), 9); } -// Token overlap tests -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthCommonToken) +// LCP=0, right is closing-only, suggestion ends with a matching closer -> replace full right +TEST_F(LLMSuggestionTest, closingTailReplaceSemicolon) { - // suggestion contains "colors", entireLine contains "colors" - int result = LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};", "QStringList colors{};"); - EXPECT_EQ(result, 9); // Replace all rightText due to common token + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("x;", ";"), 1); } -TEST_F(LLMSuggestionTest, testCalculateReplaceLengthMultipleCommonTokens) +TEST_F(LLMSuggestionTest, closingTailReplaceParen) { - // Multiple tokens in common - int result = LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()", "QmlEngine engine.rootContext()"); - EXPECT_EQ(result, 20); // Replace all rightText + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")"), 1); } -// Real-world scenarios -TEST_F(LLMSuggestionTest, testCursorInBraces) +TEST_F(LLMSuggestionTest, closingTailReplaceBrackets) { - // Cursor in braces: QStringList colors{}; - // LLM sends: "\"red\", \"green\"", rightText: "};" - // No common tokens ("red" and "green" are strings, not identifiers in entireLine) - // No structural overlap (suggestion doesn't contain } or ;) - int result = LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};", "QStringList colors{};"); - EXPECT_EQ(result, 0); // No overlap, just insert + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("[0]", "];"), 2); } -TEST_F(LLMSuggestionTest, testCursorBeforeBraces) +TEST_F(LLMSuggestionTest, closingTailReplaceBracesAndSemi) { - // Cursor before braces: QStringList colors{}; - // LLM sends: " = {\"red\"}", rightText: "{};" - int result = LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};", "QStringList colors{};"); - EXPECT_EQ(result, 3); // Structural overlap - replace all + // cursor: colors|{}; ; suggestion: = {"red"} + // LCP=0; right "{};" is closing-only; suggestion ends on "}" which is in right -> replace 3 + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};"), 3); } -TEST_F(LLMSuggestionTest, testCursorAfterType) +TEST_F(LLMSuggestionTest, closingTailWithLeadingSpace) { - // Cursor after type: QStringList colors{}; - // LLM sends: "colors << \"red\"", rightText: "colors{};" - int result = LLMSuggestion::calculateReplaceLength("colors << \"red\"", "colors{};", "QStringList colors{};"); - EXPECT_EQ(result, 9); // Common token - replace all + EXPECT_EQ(LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};"), 3); } -TEST_F(LLMSuggestionTest, testCursorInMiddleNoConflict) +TEST_F(LLMSuggestionTest, closingTailEqualsSemi) { - // Cursor in middle: int myVar = 5; - // LLM sends: "myVar", rightText: " = 5;", entireLine: "int myVar = 5;" - // "myVar" is a common token -> replace rightText - int result = LLMSuggestion::calculateReplaceLength("myVar", " = 5;", "int myVar = 5;"); - EXPECT_EQ(result, 5); // Common token found, replace all rightText + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("= 5;", ";"), 1); } -TEST_F(LLMSuggestionTest, testCursorWithEqualsSign) +// LCP=0, right is closing-only but suggestion does not end on any of its chars -> leave right alone +TEST_F(LLMSuggestionTest, closingTailNoMatchingClose) { - // LLM sends code with = and ; - int result = LLMSuggestion::calculateReplaceLength("= 5;", ";", "int x;"); - EXPECT_EQ(result, 1); // Structural overlap on ; + // right "};" closes; suggestion ends on '"' (not in close set) -> 0 + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("\"red\", \"green\"", "};"), 0); } -// Edge cases -TEST_F(LLMSuggestionTest, testNoStructuralButHasToken) +// Right side has real code (not closing-only) and no LCP -> leave it alone +TEST_F(LLMSuggestionTest, realCodeRightNoLcp) { - // Token overlap but no structural - int result = LLMSuggestion::calculateReplaceLength("myVar", "myVariable", "int myVariable"); - EXPECT_EQ(result, 0); // No structural overlap, tokens too different (length > 1 check) + // cursor: int |myVar = 5; ; suggestion: myVar + // LCP=0, right " = 5;" is not closing-only (has '5') -> 0 + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("myVar", " = 5;"), 0); } -TEST_F(LLMSuggestionTest, testOnlyWhitespace) +// Trailing whitespace is not treated as a closer, suggestion has no closer -> leave alone +TEST_F(LLMSuggestionTest, trailingWhitespaceOnlyLeftAlone) { - // rightText is just whitespace, but "code" is common token - int result = LLMSuggestion::calculateReplaceLength("code", " ", "code "); - EXPECT_EQ(result, 3); // Common token "code", replace rightText -} - -TEST_F(LLMSuggestionTest, testSingleCharTokenIgnored) -{ - // Tokens must be > 1 character - int result = LLMSuggestion::calculateReplaceLength("a", "b", "ab"); - EXPECT_EQ(result, 0); // Single char tokens ignored + EXPECT_EQ(LLMSuggestion::calculateReplaceLength("code", " "), 0); } #include "LLMSuggestionTest.moc"