diff --git a/LLMClientInterface.cpp b/LLMClientInterface.cpp index ba81a29..e1476be 100644 --- a/LLMClientInterface.cpp +++ b/LLMClientInterface.cpp @@ -418,12 +418,20 @@ void LLMClientInterface::sendCompletionToClient( : completion; } + if (processedCompletion.endsWith('\n')) { + QString withoutTrailing = processedCompletion.chopped(1); + if (!withoutTrailing.contains('\n')) { + LOG_MESSAGE(QString("Removed trailing newline from single-line completion")); + processedCompletion = withoutTrailing; + } + } + completionItem[LanguageServerProtocol::textKey] = processedCompletion; + QJsonObject range; range["start"] = position; - QJsonObject end = position; - end["character"] = position["character"].toInt() + processedCompletion.length(); - range["end"] = end; + range["end"] = position; + completionItem[LanguageServerProtocol::rangeKey] = range; completionItem[LanguageServerProtocol::positionKey] = position; completions.append(completionItem); diff --git a/LLMSuggestion.cpp b/LLMSuggestion.cpp index 006453a..6d0c768 100644 --- a/LLMSuggestion.cpp +++ b/LLMSuggestion.cpp @@ -29,34 +29,57 @@ namespace QodeAssist { -QString mergeWithRightText(const QString &suggestion, const QString &rightText) +static QStringList extractTokens(const QString &str) { - if (suggestion.isEmpty() || rightText.isEmpty()) { - return suggestion; - } - - int j = 0; - QString processed = rightText; - QSet matchedPositions; - - for (int i = 0; i < suggestion.length() && j < processed.length(); ++i) { - if (suggestion[i] == processed[j]) { - matchedPositions.insert(j); - ++j; + 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(); } } + if (!currentToken.isEmpty() && currentToken.length() > 1) { + tokens.append(currentToken); + } + return tokens; +} - if (matchedPositions.isEmpty()) { - return suggestion + rightText; +int LLMSuggestion::calculateReplaceLength(const QString &suggestion, + const QString &rightText, + const QString &entireLine) +{ + if (rightText.isEmpty()) { + return 0; } - QList positions = matchedPositions.values(); - std::sort(positions.begin(), positions.end(), std::greater()); - for (int pos : positions) { - processed.remove(pos, 1); + 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(); } - return suggestion; + const QStringList suggestionTokens = extractTokens(suggestion); + const QStringList lineTokens = extractTokens(entireLine); + + for (const auto &token : suggestionTokens) { + if (lineTokens.contains(token)) { + return rightText.length(); + } + } + + return 0; } LLMSuggestion::LLMSuggestion( @@ -66,10 +89,8 @@ LLMSuggestion::LLMSuggestion( const auto &data = suggestions[currentCompletion]; int startPos = data.range.begin.toPositionInDocument(sourceDocument); - int endPos = data.range.end.toPositionInDocument(sourceDocument); startPos = qBound(0, startPos, sourceDocument->characterCount()); - endPos = qBound(startPos, endPos, sourceDocument->characterCount()); QTextCursor cursor(sourceDocument); cursor.setPosition(startPos); @@ -77,17 +98,27 @@ LLMSuggestion::LLMSuggestion( QString blockText = block.text(); int cursorPositionInBlock = cursor.positionInBlock(); - + QString leftText = blockText.left(cursorPositionInBlock); QString rightText = blockText.mid(cursorPositionInBlock); - if (!data.text.contains('\n')) { - QString processedRightText = mergeWithRightText(data.text, rightText); - processedRightText = processedRightText.mid(data.text.length()); - QString displayText = blockText.left(cursorPositionInBlock) + data.text - + processedRightText; + QString suggestionText = data.text; + QString entireLine = blockText; + + if (!suggestionText.contains('\n')) { + int replaceLength = calculateReplaceLength(suggestionText, rightText, entireLine); + QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText; + + QString displayText = leftText + suggestionText + remainingRightText; replacementDocument()->setPlainText(displayText); } else { - QString displayText = blockText.left(cursorPositionInBlock) + data.text; + int firstLineEnd = suggestionText.indexOf('\n'); + QString firstLine = suggestionText.left(firstLineEnd); + QString restOfCompletion = suggestionText.mid(firstLineEnd); + + int replaceLength = calculateReplaceLength(firstLine, rightText, entireLine); + QString remainingRightText = (replaceLength > 0) ? rightText.mid(replaceLength) : rightText; + + QString displayText = leftText + firstLine + remainingRightText + restOfCompletion; replacementDocument()->setPlainText(displayText); } } @@ -104,10 +135,12 @@ bool LLMSuggestion::applyLine(TextEditor::TextEditorWidget *widget) bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget) { - const Utils::Text::Range range = suggestions()[currentSuggestion()].range; + const auto ¤tSuggestions = suggestions(); + const auto ¤tData = currentSuggestions[currentSuggestion()]; + const Utils::Text::Range range = currentData.range; const QTextCursor cursor = range.begin.toTextCursor(sourceDocument()); QTextCursor currentCursor = widget->textCursor(); - const QString text = suggestions()[currentSuggestion()].text; + const QString text = currentData.text; const int startPos = currentCursor.positionInBlock() - cursor.positionInBlock() + (cursor.selectionEnd() - cursor.selectionStart()); @@ -131,6 +164,19 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget) return false; } + if (startPos == 0) { + QTextBlock currentBlock = cursor.block(); + QString textAfterCursor = currentBlock.text().mid(cursor.positionInBlock()); + QString entireLine = currentBlock.text(); + + int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine); + + if (replaceLength > 0) { + currentCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength); + currentCursor.removeSelectedText(); + } + } + if (!subText.contains('\n')) { currentCursor.insertText(subText); @@ -167,34 +213,47 @@ bool LLMSuggestion::applyPart(Part part, TextEditor::TextEditorWidget *widget) bool LLMSuggestion::apply() { - const Utils::Text::Range range = suggestions()[currentSuggestion()].range; + const auto ¤tSuggestions = suggestions(); + const auto ¤tData = currentSuggestions[currentSuggestion()]; + const Utils::Text::Range range = currentData.range; const QTextCursor cursor = range.begin.toTextCursor(sourceDocument()); - const QString text = suggestions()[currentSuggestion()].text; + 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(); int firstLineEnd = text.indexOf('\n'); if (firstLineEnd != -1) { QString firstLine = text.left(firstLineEnd); QString restOfText = text.mid(firstLineEnd); - editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); - editCursor.removeSelectedText(); - - QString mergedFirstLine = mergeWithRightText(firstLine, textAfterCursor); - editCursor.insertText(mergedFirstLine + restOfText); + int replaceLength = calculateReplaceLength(firstLine, textAfterCursor, entireLine); + + if (replaceLength > 0) { + editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength); + editCursor.removeSelectedText(); + } + + editCursor.insertText(firstLine + restOfText); } else { - editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); - editCursor.removeSelectedText(); - - QString mergedText = mergeWithRightText(text, textAfterCursor); - editCursor.insertText(mergedText); + int replaceLength = calculateReplaceLength(text, textAfterCursor, entireLine); + + if (replaceLength > 0) { + editCursor.movePosition(QTextCursor::Right, QTextCursor::KeepAnchor, replaceLength); + editCursor.removeSelectedText(); + } + + editCursor.insertText(text); } + editCursor.endEditBlock(); return true; } } // namespace QodeAssist + diff --git a/LLMSuggestion.hpp b/LLMSuggestion.hpp index 6001398..5107630 100644 --- a/LLMSuggestion.hpp +++ b/LLMSuggestion.hpp @@ -41,5 +41,9 @@ public: bool applyLine(TextEditor::TextEditorWidget *widget) override; bool applyPart(Part part, TextEditor::TextEditorWidget *widget); bool apply() override; + + static int calculateReplaceLength(const QString &suggestion, + const QString &rightText, + const QString &entireLine); }; } // namespace QodeAssist diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b8a837f..4e167b3 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -1,8 +1,10 @@ add_executable(QodeAssistTest ../CodeHandler.cpp ../LLMClientInterface.cpp + ../LLMSuggestion.cpp CodeHandlerTest.cpp DocumentContextReaderTest.cpp + LLMSuggestionTest.cpp # LLMClientInterfaceTests.cpp unittest_main.cpp ) diff --git a/test/LLMSuggestionTest.cpp b/test/LLMSuggestionTest.cpp new file mode 100644 index 0000000..7b4cc73 --- /dev/null +++ b/test/LLMSuggestionTest.cpp @@ -0,0 +1,157 @@ +/* + * Copyright (C) 2024-2025 Petr Mironychev + * + * This file is part of QodeAssist. + * + * QodeAssist is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * QodeAssist is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with QodeAssist. If not, see . + */ + +#include "LLMSuggestion.hpp" +#include "TestUtils.hpp" + +#include +#include +#include + +using namespace QodeAssist; + +class LLMSuggestionTest : public QObject, public testing::Test +{ + Q_OBJECT +}; + +// Basic tests +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthEmptyRight) +{ + int result = LLMSuggestion::calculateReplaceLength("foo", "", "foo"); + EXPECT_EQ(result, 0); // No rightText to replace +} + +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthNoOverlap) +{ + // No structural or token overlap + int result = LLMSuggestion::calculateReplaceLength("foo", "bar", "foobar"); + EXPECT_EQ(result, 0); // Just insert, don't replace +} + +// Structural overlap tests +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralBraces) +{ + // suggestion contains {}, rightText contains {} + int result = LLMSuggestion::calculateReplaceLength("= {\"red\"}", "{};", "colors{};"); + EXPECT_EQ(result, 3); // Replace all rightText +} + +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralSemicolon) +{ + // suggestion contains ;, rightText contains ; + int result = LLMSuggestion::calculateReplaceLength("x;", ";", "int x;"); + EXPECT_EQ(result, 1); // Replace the ; +} + +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralParens) +{ + // suggestion contains (), rightText contains ) + int result = LLMSuggestion::calculateReplaceLength("arg1, arg2)", ")", "foo(arg1, arg2)"); + EXPECT_EQ(result, 1); // Replace the ) +} + +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthStructuralBrackets) +{ + // suggestion contains [], rightText contains ] + int result = LLMSuggestion::calculateReplaceLength("[0]", "];", "arr[0];"); + EXPECT_EQ(result, 2); // Replace ]; +} + +// Token overlap tests +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthCommonToken) +{ + // 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 +} + +TEST_F(LLMSuggestionTest, testCalculateReplaceLengthMultipleCommonTokens) +{ + // Multiple tokens in common + int result = LLMSuggestion::calculateReplaceLength("engine.load()", "engine.rootContext()", "QmlEngine engine.rootContext()"); + EXPECT_EQ(result, 20); // Replace all rightText +} + +// Real-world scenarios +TEST_F(LLMSuggestionTest, testCursorInBraces) +{ + // 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 +} + +TEST_F(LLMSuggestionTest, testCursorBeforeBraces) +{ + // Cursor before braces: QStringList colors{}; + // LLM sends: " = {\"red\"}", rightText: "{};" + int result = LLMSuggestion::calculateReplaceLength(" = {\"red\"}", "{};", "QStringList colors{};"); + EXPECT_EQ(result, 3); // Structural overlap - replace all +} + +TEST_F(LLMSuggestionTest, testCursorAfterType) +{ + // 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 +} + +TEST_F(LLMSuggestionTest, testCursorInMiddleNoConflict) +{ + // 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 +} + +TEST_F(LLMSuggestionTest, testCursorWithEqualsSign) +{ + // LLM sends code with = and ; + int result = LLMSuggestion::calculateReplaceLength("= 5;", ";", "int x;"); + EXPECT_EQ(result, 1); // Structural overlap on ; +} + +// Edge cases +TEST_F(LLMSuggestionTest, testNoStructuralButHasToken) +{ + // 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) +} + +TEST_F(LLMSuggestionTest, testOnlyWhitespace) +{ + // 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 +} + +#include "LLMSuggestionTest.moc"