/*
* Copyright (C) 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 "FindFileTool.hpp"
#include "ToolExceptions.hpp"
#include
#include
#include
#include
#include
#include
#include
#include
#include
namespace QodeAssist::Tools {
FindFileTool::FindFileTool(QObject *parent)
: BaseTool(parent)
, m_ignoreManager(new Context::IgnoreManager(this))
{}
QString FindFileTool::name() const
{
return "find_file";
}
QString FindFileTool::stringName() const
{
return {"Finding file in project"};
}
QString FindFileTool::description() const
{
return "Search for files in the project by filename, partial name, or path. "
"Searches both in CMake-registered files and filesystem (finds .gitignore, Python scripts, README, etc.). "
"Supports exact/partial filename match, relative/absolute paths, file extension filtering, "
"and case-insensitive search. "
"Returns matching files with absolute and relative paths.";
}
QJsonObject FindFileTool::getDefinition(LLMCore::ToolSchemaFormat format) const
{
QJsonObject properties;
QJsonObject queryProperty;
queryProperty["type"] = "string";
queryProperty["description"]
= "The filename, partial filename, or path to search for (case-insensitive). "
"Finds ALL files in project directory including .gitignore, README.md, Python scripts, "
"config files, etc., even if not in CMake build system";
properties["query"] = queryProperty;
QJsonObject filePatternProperty;
filePatternProperty["type"] = "string";
filePatternProperty["description"]
= "Optional file pattern to filter results (e.g., '*.cpp', '*.h', '*.qml')";
properties["file_pattern"] = filePatternProperty;
QJsonObject maxResultsProperty;
maxResultsProperty["type"] = "integer";
maxResultsProperty["description"]
= "Maximum number of results to return (default: 50, max: 200)";
maxResultsProperty["default"] = DEFAULT_MAX_RESULTS;
properties["max_results"] = maxResultsProperty;
QJsonObject definition;
definition["type"] = "object";
definition["properties"] = properties;
QJsonArray required;
required.append("query");
definition["required"] = required;
switch (format) {
case LLMCore::ToolSchemaFormat::OpenAI:
return customizeForOpenAI(definition);
case LLMCore::ToolSchemaFormat::Claude:
return customizeForClaude(definition);
case LLMCore::ToolSchemaFormat::Ollama:
return customizeForOllama(definition);
case LLMCore::ToolSchemaFormat::Google:
return customizeForGoogle(definition);
}
return definition;
}
LLMCore::ToolPermissions FindFileTool::requiredPermissions() const
{
return LLMCore::ToolPermission::FileSystemRead;
}
QFuture FindFileTool::executeAsync(const QJsonObject &input)
{
return QtConcurrent::run([this, input]() -> QString {
QString query = input["query"].toString().trimmed();
if (query.isEmpty()) {
QString error = "Error: query parameter is required and cannot be empty";
throw ToolInvalidArgument(error);
}
QString filePattern = input["file_pattern"].toString().trimmed();
int maxResults = input["max_results"].toInt(DEFAULT_MAX_RESULTS);
maxResults = qBound(1, maxResults, 200);
LOG_MESSAGE(QString("FindFileTool: Searching for '%1'%2 (max: %3)")
.arg(query)
.arg(
filePattern.isEmpty() ? QString("")
: QString(" with pattern '%1'").arg(filePattern))
.arg(maxResults));
QFileInfo queryInfo(query);
if (queryInfo.isAbsolute() && queryInfo.exists() && queryInfo.isFile()) {
QString canonicalPath = queryInfo.canonicalFilePath();
bool isInProject = isFileInProject(canonicalPath);
// Check if reading outside project is allowed
if (!isInProject) {
const auto &settings = Settings::generalSettings();
if (!settings.allowReadOutsideProject()) {
QString error = QString("Error: File '%1' exists but is outside the project scope. "
"Enable 'Allow reading files outside project' in settings to access this file.")
.arg(canonicalPath);
throw std::runtime_error(error.toStdString());
}
LOG_MESSAGE(QString("Finding file outside project scope: %1").arg(canonicalPath));
}
auto project = isInProject ? ProjectExplorer::ProjectManager::projectForFile(
Utils::FilePath::fromString(canonicalPath)) : nullptr;
if (!isInProject || (project && !m_ignoreManager->shouldIgnore(canonicalPath, project))) {
FileMatch match;
match.absolutePath = canonicalPath;
match.relativePath = isInProject && project
? QDir(project->projectDirectory().toFSPathString()).relativeFilePath(canonicalPath)
: canonicalPath;
match.projectName = isInProject && project ? project->displayName() : "External";
match.matchType = FileMatch::PathMatch;
QList matches;
matches.append(match);
return formatResults(matches, 1, maxResults);
}
}
QList matches = findMatchingFiles(query, maxResults);
int totalFound = matches.size();
if (matches.isEmpty()) {
QString error = QString(
"Error: No files found matching '%1'%2 in the project. "
"Try using a different search term or check the file name.")
.arg(query)
.arg(
filePattern.isEmpty()
? QString("")
: QString(" with pattern '%1'").arg(filePattern));
throw std::runtime_error(error.toStdString());
}
return formatResults(matches, totalFound, maxResults);
});
}
QList FindFileTool::findMatchingFiles(const QString &query,
int maxResults) const
{
QList matches;
QList projects = ProjectExplorer::ProjectManager::projects();
if (projects.isEmpty()) {
LOG_MESSAGE("FindFileTool: No projects are currently open");
return matches;
}
QString lowerQuery = query.toLower();
for (auto project : projects) {
if (!project)
continue;
Utils::FilePaths projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
Utils::FilePath projectDir = project->projectDirectory();
QString projectName = project->displayName();
for (const auto &filePath : projectFiles) {
if (matches.size() >= maxResults) {
break;
}
QString absolutePath = filePath.toFSPathString();
if (m_ignoreManager->shouldIgnore(absolutePath, project)) {
continue;
}
QFileInfo fileInfo(absolutePath);
QString fileName = fileInfo.fileName();
QString relativePath = QDir(projectDir.toFSPathString()).relativeFilePath(absolutePath);
FileMatch match;
match.absolutePath = absolutePath;
match.relativePath = relativePath;
match.projectName = projectName;
if (fileName.toLower() == lowerQuery) {
match.matchType = FileMatch::ExactName;
matches.append(match);
continue;
}
if (relativePath.toLower().contains(lowerQuery)) {
match.matchType = FileMatch::PathMatch;
matches.append(match);
continue;
}
if (fileName.toLower().contains(lowerQuery)) {
match.matchType = FileMatch::PartialName;
matches.append(match);
continue;
}
}
if (matches.size() >= maxResults) {
break;
}
}
// If we didn't find enough matches in project files, search the filesystem
if (matches.size() < maxResults) {
LOG_MESSAGE(QString("FindFileTool: Extending search to filesystem (found %1 matches so far)")
.arg(matches.size()));
for (auto project : projects) {
if (!project)
continue;
if (matches.size() >= maxResults) {
break;
}
Utils::FilePath projectDir = project->projectDirectory();
QString projectName = project->displayName();
QString projectDirStr = projectDir.toFSPathString();
int depth = 0;
searchInFileSystem(projectDirStr, lowerQuery, projectName, projectDirStr,
project, matches, maxResults, depth);
}
}
std::sort(matches.begin(), matches.end());
return matches;
}
void FindFileTool::searchInFileSystem(const QString &dirPath,
const QString &query,
const QString &projectName,
const QString &projectDir,
ProjectExplorer::Project *project,
QList &matches,
int maxResults,
int ¤tDepth,
int maxDepth) const
{
if (currentDepth > maxDepth || matches.size() >= maxResults) {
return;
}
currentDepth++;
QDir dir(dirPath);
if (!dir.exists()) {
currentDepth--;
return;
}
// Get all entries (files and directories)
QFileInfoList entries = dir.entryInfoList(QDir::Files | QDir::Dirs | QDir::NoDotAndDotDot | QDir::Hidden);
for (const QFileInfo &entry : entries) {
if (matches.size() >= maxResults) {
break;
}
QString absolutePath = entry.absoluteFilePath();
// Check if should be ignored
if (project && m_ignoreManager->shouldIgnore(absolutePath, project)) {
continue;
}
// Skip common build/cache directories
QString fileName = entry.fileName();
if (entry.isDir()) {
// Skip common build/cache directories
if (fileName == "build" || fileName == ".git" || fileName == "node_modules" ||
fileName == "__pycache__" || fileName == ".venv" || fileName == "venv" ||
fileName == ".cmake" || fileName == "CMakeFiles" || fileName.startsWith(".qt")) {
continue;
}
// Recurse into subdirectory
searchInFileSystem(absolutePath, query, projectName, projectDir,
project, matches, maxResults, currentDepth, maxDepth);
continue;
}
// Check if already in matches (avoid duplicates from project files)
bool alreadyAdded = false;
for (const auto &match : matches) {
if (match.absolutePath == absolutePath) {
alreadyAdded = true;
break;
}
}
if (alreadyAdded) {
continue;
}
// Match logic
QString lowerFileName = fileName.toLower();
QString relativePath = QDir(projectDir).relativeFilePath(absolutePath);
QString lowerRelativePath = relativePath.toLower();
FileMatch match;
match.absolutePath = absolutePath;
match.relativePath = relativePath;
match.projectName = projectName;
if (lowerFileName == query) {
match.matchType = FileMatch::ExactName;
matches.append(match);
} else if (lowerRelativePath.contains(query)) {
match.matchType = FileMatch::PathMatch;
matches.append(match);
} else if (lowerFileName.contains(query)) {
match.matchType = FileMatch::PartialName;
matches.append(match);
}
}
currentDepth--;
}
bool FindFileTool::matchesFilePattern(const QString &fileName, const QString &pattern) const
{
if (pattern.isEmpty()) {
return true;
}
if (pattern.startsWith("*.")) {
QString extension = pattern.mid(1); // Remove the '*'
return fileName.endsWith(extension, Qt::CaseInsensitive);
}
return fileName.compare(pattern, Qt::CaseInsensitive) == 0;
}
QString FindFileTool::formatResults(const QList &matches,
int totalFound,
int maxResults) const
{
QString result;
bool wasTruncated = totalFound > matches.size();
if (matches.size() == 1) {
const FileMatch &match = matches.first();
result = QString("Found 1 file:\n\n");
result += QString("File: %1\n").arg(match.relativePath);
result += QString("Absolute path: %2\n").arg(match.absolutePath);
result += QString("Project: %3").arg(match.projectName);
} else {
result = QString("Found %1 file%2%3:\n\n")
.arg(totalFound)
.arg(totalFound == 1 ? QString("") : QString("s"))
.arg(wasTruncated ? QString(" (showing first %1)").arg(matches.size()) : "");
QString currentProject;
for (const FileMatch &match : matches) {
if (currentProject != match.projectName) {
if (!currentProject.isEmpty()) {
result += "\n";
}
result += QString("Project '%1':\n").arg(match.projectName);
currentProject = match.projectName;
}
result += QString(" - %1\n").arg(match.relativePath);
result += QString(" Absolute path: %2\n").arg(match.absolutePath);
}
if (wasTruncated) {
result += QString(
"\n(Note: %1 additional file%2 not shown. "
"Use 'max_results' parameter to see more.)")
.arg(totalFound - matches.size())
.arg(totalFound - matches.size() == 1 ? QString("") : QString("s"));
}
}
return result.trimmed();
}
bool FindFileTool::isFileInProject(const QString &filePath) const
{
QList projects = ProjectExplorer::ProjectManager::projects();
Utils::FilePath targetPath = Utils::FilePath::fromString(filePath);
for (auto project : projects) {
if (!project)
continue;
Utils::FilePaths projectFiles = project->files(ProjectExplorer::Project::SourceFiles);
for (const auto &projectFile : std::as_const(projectFiles)) {
if (projectFile == targetPath) {
return true;
}
}
Utils::FilePath projectDir = project->projectDirectory();
if (targetPath.isChildOf(projectDir)) {
return true;
}
}
return false;
}
} // namespace QodeAssist::Tools