/* * Copyright (C) 2023 The Qt Company Ltd. * Copyright (C) 2024-2026 Petr Mironychev * * This file is part of QodeAssist. * * The Qt Company portions: * SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0+ OR GPL-3.0 WITH Qt-GPL-exception-1.0 * * Petr Mironychev portions: * 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 "QodeAssistClient.hpp" #include #include #include #include #include #include #include #include "LLMClientInterface.hpp" #include "LLMSuggestion.hpp" #include "RefactorSuggestion.hpp" #include "RefactorSuggestionHoverHandler.hpp" #include "settings/CodeCompletionSettings.hpp" #include "settings/GeneralSettings.hpp" #include "settings/ProjectSettings.hpp" #include "settings/QuickRefactorSettings.hpp" #include "widgets/RefactorWidgetHandler.hpp" #include "RefactorContextHelper.hpp" #include #include using namespace LanguageServerProtocol; using namespace TextEditor; using namespace Utils; using namespace ProjectExplorer; using namespace Core; namespace QodeAssist { namespace { Utils::Text::Position toTextPos(const Utils::Text::Position &pos) { return Utils::Text::Position{pos.line, pos.column}; } bool isIdentifierChar(QChar c) { return c.isLetterOrNumber() || c == QLatin1Char('_'); } bool isInsideIdentifier(const QTextCursor &cursor) { const QTextBlock block = cursor.block(); const int col = cursor.positionInBlock(); const QString text = block.text(); if (col <= 0 || col > text.size()) return false; if (!isIdentifierChar(text.at(col - 1))) return false; return col < text.size() && isIdentifierChar(text.at(col)); } bool isAfterMemberAccess(const QTextCursor &cursor) { const QTextBlock block = cursor.block(); const int col = cursor.positionInBlock(); const QString text = block.text(); if (col <= 0) return false; int i = col - 1; while (i >= 0 && isIdentifierChar(text.at(i))) --i; if (i < 0) return false; const QChar c = text.at(i); if (c == QLatin1Char('.')) return true; if (c == QLatin1Char('>') && i >= 1 && text.at(i - 1) == QLatin1Char('-')) return true; if (c == QLatin1Char(':') && i >= 1 && text.at(i - 1) == QLatin1Char(':')) return true; return false; } bool isFreshIndentedLine(const QTextCursor &cursor) { const QTextBlock block = cursor.block(); const int col = cursor.positionInBlock(); if (col == 0) return false; const QString leftText = block.text().left(col); for (const QChar &ch : leftText) { if (!ch.isSpace()) return false; } return true; } bool isAfterEagerTrigger(const QTextCursor &cursor) { const QTextBlock block = cursor.block(); const int col = cursor.positionInBlock(); const QString text = block.text(); int i = col - 1; while (i >= 0 && text.at(i).isSpace()) --i; if (i < 0) return false; const QChar c = text.at(i); return c == QLatin1Char('{') || c == QLatin1Char('(') || c == QLatin1Char(',') || c == QLatin1Char('=') || c == QLatin1Char('[') || c == QLatin1Char(';') || c == QLatin1Char(':') || c == QLatin1Char('>'); } bool isManualMode() { return Settings::codeCompletionSettings().completionMode.stringValue() == "Manual"; } } // anonymous namespace QodeAssistClient::QodeAssistClient(LLMClientInterface *clientInterface) : LanguageClient::Client(clientInterface) , m_llmClient(clientInterface) , m_recentCharCount(0) { setName("QodeAssist"); LanguageClient::LanguageFilter filter; filter.mimeTypes = QStringList() << "*"; setSupportedLanguage(filter); start(); setupConnections(); m_typingTimer.start(); m_refactorHoverHandler = new RefactorSuggestionHoverHandler(); m_refactorWidgetHandler = new RefactorWidgetHandler(this); } QodeAssistClient::~QodeAssistClient() { cleanupConnections(); delete m_refactorHoverHandler; delete m_refactorWidgetHandler; } void QodeAssistClient::openDocument(TextEditor::TextDocument *document) { auto project = ProjectManager::projectForFile(document->filePath()); if (!isEnabled(project)) return; Client::openDocument(document); auto editors = TextEditor::BaseTextEditor::textEditorsForDocument(document); for (auto *editor : editors) { if (auto *widget = editor->editorWidget()) { widget->addHoverHandler(m_refactorHoverHandler); widget->installEventFilter(this); } } connect( document, &TextDocument::contentsChangedWithPosition, this, [this, document](int position, int charsRemoved, int charsAdded) { if (!Settings::codeCompletionSettings().autoCompletion()) return; if (isManualMode()) return; auto project = ProjectManager::projectForFile(document->filePath()); if (!isEnabled(project)) return; auto textEditor = BaseTextEditor::currentTextEditor(); if (!textEditor || textEditor->document() != document) return; if (Settings::codeCompletionSettings().useProjectChangesCache()) Context::ChangesManager::instance() .addChange(document, position, charsRemoved, charsAdded); TextEditorWidget *widget = textEditor->editorWidget(); if (widget->isReadOnly() || widget->multiTextCursor().hasMultipleCursors()) return; const int cursorPosition = widget->textCursor().position(); if (cursorPosition < position || cursorPosition > position + charsAdded) return; if (charsRemoved > 0 || charsAdded <= 0) { m_recentCharCount = 0; m_typingTimer.restart(); return; } QTextCursor cursor = widget->textCursor(); cursor.movePosition(QTextCursor::Left, QTextCursor::KeepAnchor, 1); const QString lastChar = cursor.selectedText(); if (lastChar.isEmpty()) return; const QChar lastCh = lastChar[0]; if (lastCh == QLatin1Char('\n') || lastCh == QChar::ParagraphSeparator || lastCh == QChar::LineSeparator) { m_recentCharCount = 0; m_typingTimer.restart(); return; } const bool isSpaceOrTab = lastCh.isSpace(); const bool ignoreWhitespace = Settings::codeCompletionSettings().ignoreWhitespaceInCharCount(); if (!ignoreWhitespace || !isSpaceOrTab) m_recentCharCount += charsAdded; if (m_typingTimer.elapsed() > Settings::codeCompletionSettings().autoCompletionTypingInterval()) { m_recentCharCount = (ignoreWhitespace && isSpaceOrTab) ? 0 : charsAdded; m_typingTimer.restart(); } handleAutoRequestTrigger(widget); }); } bool QodeAssistClient::canOpenProject(ProjectExplorer::Project *project) { return isEnabled(project); } void QodeAssistClient::requestCompletions(TextEditor::TextEditorWidget *editor) { auto project = ProjectManager::projectForFile(editor->textDocument()->filePath()); if (!isEnabled(project)) return; if (m_llmClient->contextManager() ->ignoreManager() ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; } MultiTextCursor cursor = editor->multiTextCursor(); if (cursor.hasMultipleCursors() || cursor.hasSelection() || editor->suggestionVisible()) return; const auto &settings = Settings::codeCompletionSettings(); if (settings.abortAssistOnRequest() && !settings.respectQtcPopup()) editor->abortAssist(); const FilePath filePath = editor->textDocument()->filePath(); GetCompletionRequest request{ {TextDocumentIdentifier(hostPathToServerUri(filePath)), documentVersion(filePath), Position(cursor.mainCursor())}}; if (Settings::codeCompletionSettings().showProgressWidget()) { m_progressHandler.setCancelCallback([this, editor = QPointer(editor)]() { if (editor) { cancelRunningRequest(editor); } }); m_progressHandler.showProgress(editor); } request.setResponseCallback([this, editor = QPointer(editor)]( const GetCompletionRequest::Response &response) { QTC_ASSERT(editor, return); handleCompletions(response, editor); }); m_runningRequests[editor] = request; sendMessage(request); } void QodeAssistClient::requestQuickRefactor( TextEditor::TextEditorWidget *editor, const QString &instructions) { auto project = ProjectManager::projectForFile(editor->textDocument()->filePath()); if (!isEnabled(project)) return; if (m_llmClient->contextManager() ->ignoreManager() ->shouldIgnore(editor->textDocument()->filePath().toUrlishString(), project)) { LOG_MESSAGE(QString("Ignoring file due to .qodeassistignore: %1") .arg(editor->textDocument()->filePath().toUrlishString())); return; } if (!m_refactorHandler) { m_refactorHandler = new QuickRefactorHandler(this); connect( m_refactorHandler, &QuickRefactorHandler::refactoringCompleted, this, &QodeAssistClient::handleRefactoringResult); } m_progressHandler.setCancelCallback([this, editor = QPointer(editor)]() { if (editor && m_refactorHandler) { m_refactorHandler->cancelRequest(); m_progressHandler.hideProgress(); } }); m_progressHandler.showProgress(editor); m_refactorHandler->sendRefactorRequest(editor, instructions); } void QodeAssistClient::scheduleRequest(TextEditor::TextEditorWidget *editor) { if (m_runningRequests.contains(editor)) { if (Settings::codeCompletionSettings().cancelOnInput()) cancelRunningRequest(editor); else return; } auto it = m_scheduledRequests.find(editor); if (it == m_scheduledRequests.end()) { auto timer = new QTimer(this); timer->setSingleShot(true); connect(timer, &QTimer::timeout, this, [this, editor]() { if (!editor || m_runningRequests.contains(editor)) return; if (editor->textCursor().position() != m_scheduledRequests[editor]->property("cursorPosition").toInt()) return; requestCompletions(editor); }); connect(editor, &TextEditorWidget::destroyed, this, [this, editor]() { delete m_scheduledRequests.take(editor); cancelRunningRequest(editor); }); it = m_scheduledRequests.insert(editor, timer); } it.value()->setProperty("cursorPosition", editor->textCursor().position()); it.value()->start(Settings::codeCompletionSettings().startSuggestionTimer()); } void QodeAssistClient::handleCompletions( const GetCompletionRequest::Response &response, TextEditor::TextEditorWidget *editor) { m_progressHandler.hideProgress(); const auto &settings = Settings::codeCompletionSettings(); if (settings.abortAssistOnRequest() && !settings.respectQtcPopup()) editor->abortAssist(); if (response.error()) { log(*response.error()); m_errorHandler .showError(editor, tr("Code completion failed: %1").arg(response.error()->message())); return; } int requestPosition = -1; if (const auto requestParams = m_runningRequests.take(editor).params()) requestPosition = requestParams->position().toPositionInDocument(editor->document()); const MultiTextCursor cursors = editor->multiTextCursor(); if (cursors.hasMultipleCursors() || cursors.hasSelection()) return; const int currentPosition = cursors.mainCursor().position(); if (requestPosition < 0 || currentPosition < requestPosition) return; QString typedSinceRequest; if (currentPosition > requestPosition) { QTextCursor diffCursor(editor->document()); diffCursor.setPosition(requestPosition); diffCursor.setPosition(currentPosition, QTextCursor::KeepAnchor); typedSinceRequest = diffCursor.selectedText(); if (typedSinceRequest.contains(QChar::ParagraphSeparator) || typedSinceRequest.contains(QLatin1Char('\n'))) { return; } } if (const std::optional result = response.result()) { auto isValidCompletion = [](const Completion &completion) { return completion.isValid() && !completion.text().trimmed().isEmpty(); }; QList completions = Utils::filtered(result->completions().toListOrEmpty(), isValidCompletion); QList matchedCompletions; matchedCompletions.reserve(completions.size()); for (Completion &completion : completions) { const LanguageServerProtocol::Range range = completion.range(); if (range.start().line() != range.end().line()) continue; QString completionText = completion.text(); const int end = int(completionText.size()) - 1; int delta = 0; while (delta <= end && completionText[end - delta].isSpace()) ++delta; if (delta > 0) completionText.chop(delta); if (!typedSinceRequest.isEmpty()) { if (!completionText.startsWith(typedSinceRequest)) continue; completionText = completionText.mid(typedSinceRequest.size()); if (completionText.isEmpty()) continue; } completion.setText(completionText); matchedCompletions.append(completion); } if (matchedCompletions.isEmpty()) { LOG_MESSAGE("No valid completions received"); return; } const Text::Position anchor = typedSinceRequest.isEmpty() ? Text::Position{} : Text::Position::fromPositionInDocument(editor->document(), currentPosition); const bool useAnchor = !typedSinceRequest.isEmpty(); auto suggestions = Utils::transform(matchedCompletions, [useAnchor, &anchor](const Completion &c) { auto toTextPos = [](const LanguageServerProtocol::Position pos) { return Text::Position{pos.line() + 1, pos.character()}; }; if (useAnchor) { return TextSuggestion::Data{Text::Range{anchor, anchor}, anchor, c.text()}; } Text::Range range{toTextPos(c.range().start()), toTextPos(c.range().end())}; Text::Position pos{toTextPos(c.position())}; return TextSuggestion::Data{range, pos, c.text()}; }); editor->insertSuggestion(std::make_unique(suggestions, editor->document())); } } void QodeAssistClient::cancelRunningRequest(TextEditor::TextEditorWidget *editor) { const auto it = m_runningRequests.constFind(editor); if (it == m_runningRequests.constEnd()) return; m_progressHandler.hideProgress(); cancelRequest(it->id()); m_runningRequests.erase(it); } bool QodeAssistClient::isEnabled(ProjectExplorer::Project *project) const { if (!project) return Settings::generalSettings().enableQodeAssist(); Settings::ProjectSettings settings(project); return settings.isEnabled(); } void QodeAssistClient::setupConnections() { auto openDoc = [this](IDocument *document) { if (auto *textDocument = qobject_cast(document)) openDocument(textDocument); }; m_documentOpenedConnection = connect(EditorManager::instance(), &EditorManager::documentOpened, this, openDoc); m_documentClosedConnection = connect( EditorManager::instance(), &EditorManager::documentClosed, this, [this](IDocument *document) { if (auto textDocument = qobject_cast(document)) closeDocument(textDocument); }); for (IDocument *doc : DocumentModel::openedDocuments()) openDoc(doc); } void QodeAssistClient::cleanupConnections() { disconnect(m_documentOpenedConnection); disconnect(m_documentClosedConnection); qDeleteAll(m_scheduledRequests); m_scheduledRequests.clear(); } void QodeAssistClient::handleRefactoringResult(const RefactorResult &result) { m_progressHandler.hideProgress(); if (!result.success) { QString errorMessage = result.errorMessage.isEmpty() ? tr("Quick refactor failed") : tr("Quick refactor failed: %1").arg(result.errorMessage); if (result.editor) { m_errorHandler.showError(result.editor, errorMessage); } LOG_MESSAGE(QString("Refactoring failed: %1").arg(result.errorMessage)); return; } if (!result.editor) { LOG_MESSAGE("Refactoring result has no editor"); return; } int displayMode = Settings::quickRefactorSettings().displayMode(); if (displayMode == 0) { displayRefactoringWidget(result); } else { displayRefactoringSuggestion(result); } } void QodeAssistClient::displayRefactoringSuggestion(const RefactorResult &result) { TextEditorWidget *editorWidget = result.editor; Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}; Utils::Text::Position pos = toTextPos(result.insertRange.begin); int startPos = range.begin.toPositionInDocument(editorWidget->document()); int endPos = range.end.toPositionInDocument(editorWidget->document()); if (startPos != endPos) { QTextCursor startCursor(editorWidget->document()); startCursor.setPosition(startPos); if (startCursor.positionInBlock() > 0) { startCursor.movePosition(QTextCursor::StartOfBlock); } QTextCursor endCursor(editorWidget->document()); endCursor.setPosition(endPos); if (endCursor.positionInBlock() > 0) { endCursor.movePosition(QTextCursor::EndOfBlock); if (!endCursor.atEnd()) { endCursor.movePosition(QTextCursor::NextCharacter); } } Utils::Text::Position expandedBegin = Utils::Text::Position::fromPositionInDocument( editorWidget->document(), startCursor.position()); Utils::Text::Position expandedEnd = Utils::Text::Position::fromPositionInDocument( editorWidget->document(), endCursor.position()); range = Utils::Text::Range(expandedBegin, expandedEnd); } TextEditor::TextSuggestion::Data suggestionData{ Utils::Text::Range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}, pos, result.newText}; editorWidget->insertSuggestion( std::make_unique(suggestionData, editorWidget->document())); m_refactorHoverHandler->setSuggestionRange(range); m_refactorHoverHandler->setApplyCallback([this, editorWidget]() { QKeyEvent tabEvent(QEvent::KeyPress, Qt::Key_Tab, Qt::NoModifier); QApplication::sendEvent(editorWidget, &tabEvent); m_refactorHoverHandler->clearSuggestionRange(); }); m_refactorHoverHandler->setDismissCallback([this, editorWidget]() { editorWidget->clearSuggestion(); m_refactorHoverHandler->clearSuggestionRange(); }); LOG_MESSAGE("Displaying refactoring suggestion with hover handler"); } void QodeAssistClient::displayRefactoringWidget(const RefactorResult &result) { TextEditorWidget *editorWidget = result.editor; Utils::Text::Range range{toTextPos(result.insertRange.begin), toTextPos(result.insertRange.end)}; RefactorContext ctx = RefactorContextHelper::extractContext(editorWidget, range); QString displayOriginal; QString displayRefactored; QString textToApply = result.newText; if (ctx.isInsertion) { bool isMultiline = result.newText.contains('\n'); if (isMultiline) { displayOriginal = ctx.textBeforeCursor; displayRefactored = ctx.textBeforeCursor + result.newText; } else { displayOriginal = ctx.textBeforeCursor + ctx.textAfterCursor; displayRefactored = ctx.textBeforeCursor + result.newText + ctx.textAfterCursor; } if (!ctx.textBeforeCursor.isEmpty() || !ctx.textAfterCursor.isEmpty()) { textToApply = result.newText; } } else { displayOriginal = ctx.originalText; displayRefactored = result.newText; } m_refactorWidgetHandler->setApplyCallback([this, editorWidget, result](const QString &editedText) { applyRefactoringEdit(editorWidget, result.insertRange, editedText); }); m_refactorWidgetHandler->setDeclineCallback([]() {}); m_refactorWidgetHandler->showRefactorWidget( editorWidget, displayOriginal, displayRefactored, range, ctx.contextBefore, ctx.contextAfter); m_refactorWidgetHandler->setTextToApply(textToApply); } void QodeAssistClient::applyRefactoringEdit(TextEditor::TextEditorWidget *editor, const Utils::Text::Range &range, const QString &text) { const QTextCursor startCursor = range.begin.toTextCursor(editor->document()); const QTextCursor endCursor = range.end.toTextCursor(editor->document()); const int startPos = startCursor.position(); const int endPos = endCursor.position(); QTextCursor editCursor(editor->document()); editCursor.beginEditBlock(); if (startPos == endPos) { bool isMultiline = text.contains('\n'); editCursor.setPosition(startPos); if (isMultiline) { editCursor.movePosition(QTextCursor::EndOfBlock, QTextCursor::KeepAnchor); editCursor.removeSelectedText(); } editCursor.insertText(text); } else { editCursor.setPosition(startPos); editCursor.setPosition(endPos, QTextCursor::KeepAnchor); editCursor.removeSelectedText(); editCursor.insertText(text); } editCursor.endEditBlock(); } void QodeAssistClient::handleAutoRequestTrigger(TextEditor::TextEditorWidget *widget) { const QTextCursor cursor = widget->textCursor(); const auto &settings = Settings::codeCompletionSettings(); const bool smart = settings.smartContextTrigger(); if (smart && (isInsideIdentifier(cursor) || isAfterMemberAccess(cursor))) return; const bool eager = smart && (isFreshIndentedLine(cursor) || isAfterEagerTrigger(cursor)); const int charThreshold = settings.autoCompletionCharThreshold(); if (eager || m_recentCharCount > charThreshold) scheduleRequest(widget); } bool QodeAssistClient::eventFilter(QObject *watched, QEvent *event) { auto *editor = qobject_cast(watched); if (!editor) return LanguageClient::Client::eventFilter(watched, event); if (event->type() == QEvent::KeyPress) { auto *keyEvent = static_cast(event); if (keyEvent->key() == Qt::Key_Escape) { if (m_runningRequests.contains(editor)) { cancelRunningRequest(editor); } if (m_scheduledRequests.contains(editor)) { auto *timer = m_scheduledRequests.value(editor); if (timer && timer->isActive()) { timer->stop(); } } if (m_refactorHandler && m_refactorHandler->isProcessing()) { m_refactorHandler->cancelRequest(); } m_progressHandler.hideProgress(); } } return LanguageClient::Client::eventFilter(watched, event); } } // namespace QodeAssist