File indexing completed on 2024-05-05 04:39:25

0001 /*
0002     SPDX-FileCopyrightText: 2020 Milian Wolff <mail@milianw.de>
0003 
0004     SPDX-License-Identifier: LGPL-2.0-or-later
0005 */
0006 
0007 #include "cmakefileapi.h"
0008 
0009 #include <QDir>
0010 #include <QFile>
0011 #include <QFileInfo>
0012 #include <QJsonDocument>
0013 #include <QJsonObject>
0014 #include <QJsonArray>
0015 #include <QVersionNumber>
0016 
0017 #include <makefileresolver/makefileresolver.h>
0018 
0019 #include "cmakeutils.h"
0020 #include "cmakeprojectdata.h"
0021 
0022 #include <debug.h>
0023 
0024 using namespace KDevelop;
0025 
0026 namespace {
0027 Path toCanonical(const Path& path)
0028 {
0029     QFileInfo info(path.toLocalFile());
0030     if (!info.exists())
0031         return path;
0032     const auto canonical = info.canonicalFilePath();
0033     if (info.filePath() != canonical) {
0034         return Path(canonical);
0035     } else {
0036         return path;
0037     }
0038 }
0039 
0040 QJsonObject parseFile(const QString& path)
0041 {
0042     QFile file(path);
0043     if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
0044         qCWarning(CMAKE) << "failed to read json file" << path << file.errorString();
0045         return {};
0046     }
0047     QJsonParseError error;
0048     const auto document = QJsonDocument::fromJson(file.readAll(), &error);
0049     if (error.error) {
0050         qCWarning(CMAKE) << "failed to parse json file" << path << error.errorString() << error.offset;
0051         return {};
0052     }
0053     return document.object();
0054 }
0055 
0056 bool isKDevelopClientResponse(const QJsonObject& indexObject)
0057 {
0058     return indexObject.value(QLatin1String("reply")).toObject().contains(QLatin1String("client-kdevelop"));
0059 }
0060 
0061 QString queryDirPath(const QString& buildDirectory)
0062 {
0063     return buildDirectory + QLatin1String("/.cmake/api/v1/query/client-kdevelop/");
0064 }
0065 
0066 QString queryFileName()
0067 {
0068     return QStringLiteral("query.json");
0069 }
0070 }
0071 
0072 namespace CMake {
0073 namespace FileApi {
0074 bool supported(const QString& cmakeExecutable)
0075 {
0076     return QVersionNumber::fromString(cmakeExecutableVersion(cmakeExecutable)) >= QVersionNumber(3, 14);
0077 }
0078 
0079 void writeClientQueryFile(const QString& buildDirectory)
0080 {
0081     const QDir queryDir(queryDirPath(buildDirectory));
0082     if (!queryDir.exists() && !queryDir.mkpath(QStringLiteral("."))) {
0083         qCWarning(CMAKE) << "failed to create file API query dir:" << queryDir.absolutePath();
0084         return;
0085     }
0086 
0087     QFile queryFile(queryDir.absoluteFilePath(queryFileName()));
0088     if (!queryFile.open(QIODevice::WriteOnly | QIODevice::Text)) {
0089         qCWarning(CMAKE) << "failed to open query file for writing:" << queryFile.fileName() << queryFile.errorString();
0090         return;
0091     }
0092 
0093     // Pad output with spaces to align the printed timestamp with others and facilitate comparison.
0094     qCDebug(CMAKE) << "writing API client query file at    " << PrintLastModified{nullptr, QDateTime::currentDateTime()}
0095                    << "- within" << buildDirectory;
0096 
0097     queryFile.write(R"({"requests": [{"kind": "codemodel", "version": 2}, {"kind": "cmakeFiles", "version": 1}]})");
0098 }
0099 
0100 static QDir toReplyDir(const QString& buildDirectory)
0101 {
0102     QDir replyDir(buildDirectory + QLatin1String("/.cmake/api/v1/reply/"));
0103     if (!replyDir.exists()) {
0104         qCWarning(CMAKE) << "cmake-file-api reply directory does not exist:" << replyDir.path();
0105     }
0106     return replyDir;
0107 }
0108 
0109 ReplyIndex findReplyIndexFile(const QString& buildDirectory)
0110 {
0111     const QFileInfo cmakeCacheInfo(buildDirectory + QLatin1String{"/CMakeCache.txt"});
0112     if (!cmakeCacheInfo.isFile()) {
0113         // don't import data when no suitable CMakeCache file exists, which could happen
0114         // because our prune job didn't use to delete the .cmake folder
0115         qCDebug(CMAKE) << "no CMakeCache.txt found in" << cmakeCacheInfo.absolutePath();
0116         return {};
0117     }
0118 
0119     const QFileInfo queryInfo(queryDirPath(buildDirectory) + queryFileName());
0120     if (!queryInfo.isFile()) {
0121         qCWarning(CMAKE) << "no API client query file found at" << queryInfo.absoluteFilePath();
0122         return {};
0123     }
0124 
0125     const auto isReplyOutdated = [&buildDirectory, &cmakeCacheInfo](const QDateTime& queryLastModified,
0126                                                                     const QDateTime& replyLastModified) {
0127         qCDebug(CMAKE) << PrintLastModified{"API client query file", queryLastModified} << "- within" << buildDirectory;
0128         qCDebug(CMAKE) << PrintLastModified{"API reply index file", replyLastModified} << "- within" << buildDirectory;
0129         if (replyLastModified < queryLastModified) {
0130             qCDebug(CMAKE) << "API reply index file is out of date (last modified before the client query file)";
0131             return true;
0132         }
0133 
0134         const auto cmakeCacheLastModified = cmakeCacheInfo.lastModified();
0135         qCDebug(CMAKE) << PrintLastModified{"CMakeCache.txt", cmakeCacheLastModified} << "- within" << buildDirectory;
0136         // CMakeCache.txt can be modified during the CMake configure step - after KDevelop modifies the API
0137         // client query file. The API reply index file is always modified during the CMake generate step.
0138         if (replyLastModified < cmakeCacheLastModified) {
0139             qCDebug(CMAKE) << "API reply index file is out of date (last modified before CMakeCache.txt)";
0140             return true;
0141         }
0142 
0143         return false;
0144     };
0145 
0146     const auto replyDir = toReplyDir(buildDirectory);
0147     const auto fileList =
0148         replyDir.entryInfoList({QStringLiteral("index-*.json")}, QDir::Files, QDir::Name | QDir::Reversed);
0149     for (const auto& entry : fileList) {
0150         const auto object = parseFile(entry.absoluteFilePath());
0151         if (isKDevelopClientResponse(object)) {
0152             ReplyIndex result{queryInfo.lastModified(), object};
0153             if (isReplyOutdated(result.queryLastModified, entry.lastModified())) {
0154                 result.markOutdated();
0155             }
0156             return result;
0157         }
0158     }
0159     qCWarning(CMAKE) << "no cmake-file-api reply index file found in" << replyDir.absolutePath();
0160     return {};
0161 }
0162 
0163 static CMakeTarget parseTarget(const QJsonObject& target, StringInterner& stringInterner,
0164                                PathInterner& sourcePathInterner, PathInterner& buildPathInterner,
0165                                CMakeFilesCompilationData& compilationData)
0166 {
0167     CMakeTarget ret;
0168     ret.name = target.value(QLatin1String("name")).toString();
0169     ret.type = CMakeTarget::typeToEnum(target.value(QLatin1String("type")).toString());
0170     ret.folder = target.value(QLatin1String("folder")).toObject().value(QLatin1String("name")).toString();
0171 
0172     for (const auto& jsonArtifact : target.value(QLatin1String("artifacts")).toArray()) {
0173         const auto artifact = jsonArtifact.toObject();
0174         const auto buildPath = buildPathInterner.internPath(artifact.value(QLatin1String("path")).toString());
0175         if (buildPath.isValid()) {
0176             ret.artifacts.append(buildPath);
0177         }
0178     }
0179 
0180     for (const auto& jsonSource : target.value(QLatin1String("sources")).toArray()) {
0181         const auto source = jsonSource.toObject();
0182         const auto sourcePath = sourcePathInterner.internPath(source.value(QLatin1String("path")).toString());
0183         if (sourcePath.isValid()) {
0184             ret.sources.append(sourcePath);
0185         }
0186     }
0187 
0188     QVector<CMakeFile> compileGroups;
0189     for (const auto& jsonCompileGroup : target.value(QLatin1String("compileGroups")).toArray()) {
0190         CMakeFile cmakeFile;
0191         const auto compileGroup = jsonCompileGroup.toObject();
0192 
0193         cmakeFile.language = compileGroup.value(QLatin1String("language")).toString();
0194 
0195         for (const auto& jsonFragment : compileGroup.value(QLatin1String("compileCommandFragments")).toArray()) {
0196             const auto fragment = jsonFragment.toObject();
0197             cmakeFile.compileFlags += fragment.value(QLatin1String("fragment")).toString();
0198             cmakeFile.compileFlags += QLatin1Char(' ');
0199         }
0200         cmakeFile.compileFlags = stringInterner.internString(cmakeFile.compileFlags);
0201 
0202         for (const auto& jsonDefine : compileGroup.value(QLatin1String("defines")).toArray()) {
0203             const auto define = jsonDefine.toObject();
0204             cmakeFile.addDefine(define.value(QLatin1String("define")).toString());
0205         }
0206         cmakeFile.defines = MakeFileResolver::extractDefinesFromCompileFlags(cmakeFile.compileFlags, stringInterner, cmakeFile.defines);
0207 
0208         for (const auto& jsonInclude : compileGroup.value(QLatin1String("includes")).toArray()) {
0209             const auto include = jsonInclude.toObject(); 
0210             const auto path = sourcePathInterner.internPath(include.value(QLatin1String("path")).toString());
0211             if (path.isValid()) {
0212                 cmakeFile.includes.append(path);
0213             }
0214         }
0215 
0216         compileGroups.append(cmakeFile);
0217     }
0218 
0219     for (const auto& jsonSource : target.value(QLatin1String("sources")).toArray()) {
0220         const auto source = jsonSource.toObject();
0221         const auto compileGroupIndex = source.value(QLatin1String("compileGroupIndex")).toInt(-1);
0222         if (compileGroupIndex < 0 || compileGroupIndex > compileGroups.size()) {
0223             continue;
0224         }
0225         const auto compileGroup = compileGroups.value(compileGroupIndex);
0226         const auto path = sourcePathInterner.internPath(source.value(QLatin1String("path")).toString());
0227         if (path.isValid()) {
0228             compilationData.files[toCanonical(path)] = compileGroup;
0229         }
0230     }
0231     return ret;
0232 }
0233 
0234 static CMakeProjectData parseCodeModel(const QJsonObject& codeModel, const QDir& replyDir,
0235                                        StringInterner&stringInterner, PathInterner& sourcePathInterner, PathInterner& buildPathInterner)
0236 {
0237     CMakeProjectData ret;
0238     // for now, we only use the first available configuration and don't support multi configurations
0239     const auto configuration = codeModel.value(QLatin1String("configurations")).toArray().at(0).toObject();
0240     const auto targets = configuration.value(QLatin1String("targets")).toArray();
0241     const auto directories = configuration.value(QLatin1String("directories")).toArray();
0242     for (const auto& directoryValue : directories) {
0243         const auto directory = directoryValue.toObject();
0244         if (!directory.contains(QLatin1String("targetIndexes"))) {
0245             continue;
0246         }
0247         const auto dirSourcePath = sourcePathInterner.internPath(directory.value(QLatin1String("source")).toString());
0248         auto& dirTargets = ret.targets[dirSourcePath];
0249         for (const auto& targetIndex : directory.value(QLatin1String("targetIndexes")).toArray()) {
0250             const auto jsonTarget = targets.at(targetIndex.toInt(-1)).toObject();
0251             if (jsonTarget.isEmpty()) {
0252                 continue;
0253             }
0254             const auto targetFile = jsonTarget.value(QLatin1String("jsonFile")).toString();
0255             const auto target = parseTarget(parseFile(replyDir.absoluteFilePath(targetFile)),
0256                                             stringInterner, sourcePathInterner, buildPathInterner,
0257                                             ret.compilationData);
0258             if (target.name.isEmpty()) {
0259                 continue;
0260             }
0261             dirTargets.append(target);
0262         }
0263     }
0264     ret.compilationData.isValid = !codeModel.isEmpty();
0265     ret.compilationData.rebuildFileForFolderMapping();
0266     if (!ret.compilationData.isValid) {
0267         qCWarning(CMAKE) << "failed to parse code model" << codeModel;
0268     }
0269     return ret;
0270 }
0271 
0272 /// @return source CMake files (e.g. CMakeLists.txt, *.cmake), modifying which should trigger reloading the project.
0273 static QSet<Path> parseCMakeFiles(const QJsonObject& cmakeFiles, PathInterner& sourcePathInterner,
0274                                   QDateTime* lastModifiedCMakeFile)
0275 {
0276     Q_ASSERT(lastModifiedCMakeFile);
0277     QSet<Path> ret;
0278     for (const auto& jsonInput : cmakeFiles.value(QLatin1String("inputs")).toArray()) {
0279         const auto input = jsonInput.toObject();
0280         const auto path = sourcePathInterner.internPath(input.value(QLatin1String("path")).toString());
0281         const auto isGenerated = input.value(QLatin1String("isGenerated")).toBool();
0282         const auto isExternal = input.value(QLatin1String("isExternal")).toBool();
0283 
0284         // Generated CMake files can be modified during the CMake configure step - after KDevelop
0285         // modifies the API client query file. Don't take into account last modified timestamps of
0286         // generated files to prevent wrongly considering up-to-date data outdated. The user is
0287         // not supposed to modify the generated files manually, so ignoring them should be fine.
0288         if (!isGenerated && path.isLocalFile()) {
0289             const auto info = QFileInfo(path.toLocalFile());
0290             *lastModifiedCMakeFile = std::max(info.lastModified(), *lastModifiedCMakeFile);
0291         }
0292 
0293         // Reloading the project when a generated CMake file is changed can cause a reload loop, and the user is not
0294         // supposed to modify the generated files manually. External files are not under the top-level source or build
0295         // directories, so AbstractFileManagerPlugin::projectWatcher() never watches them. Watching each individual
0296         // external CMake file can be costly and cause issues elsewhere if a watched item count limit is reached.
0297         if (!isGenerated && !isExternal) {
0298             ret.insert(path);
0299         }
0300     }
0301     return ret;
0302 }
0303 
0304 CMakeProjectData parseReplyIndexFile(const ReplyIndex& replyIndex, const Path& sourceDirectory,
0305                                      const Path& buildDirectory)
0306 {
0307     const auto reply = replyIndex.data.value(QLatin1String("reply")).toObject();
0308     const auto clientKDevelop = reply.value(QLatin1String("client-kdevelop")).toObject();
0309     const auto query = clientKDevelop.value(QLatin1String("query.json")).toObject();
0310     const auto responses = query.value(QLatin1String("responses")).toArray();
0311     const auto replyDir = toReplyDir(buildDirectory.toLocalFile());
0312 
0313     StringInterner stringInterner;
0314     PathInterner sourcePathInterner(toCanonical(sourceDirectory));
0315     PathInterner buildPathInterner(buildDirectory);
0316 
0317     CMakeProjectData codeModel;
0318     QSet<Path> cmakeFiles;
0319 
0320     bool isOutdated = true; // consider the data outdated if the cmakeFiles object is absent or replyIndex is outdated
0321     for (const auto& responseValue : responses) {
0322         const auto response = responseValue.toObject();
0323         const auto kind = response.value(QLatin1String("kind"));
0324         const auto jsonFile = response.value(QLatin1String("jsonFile")).toString();
0325         const auto jsonFilePath = replyDir.absoluteFilePath(jsonFile);
0326         if (kind == QLatin1String("codemodel")) {
0327             codeModel = parseCodeModel(parseFile(jsonFilePath), replyDir,
0328                                        stringInterner, sourcePathInterner, buildPathInterner);
0329             if (!codeModel.compilationData.isValid) {
0330                 break; // skip to printing a warning and the early return under the loop
0331             }
0332         } else if (kind == QLatin1String("cmakeFiles")) {
0333             QDateTime lastModifiedCMakeFile;
0334             cmakeFiles = parseCMakeFiles(parseFile(jsonFilePath), sourcePathInterner, &lastModifiedCMakeFile);
0335             qCDebug(CMAKE) << PrintLastModified{"source CMake file", lastModifiedCMakeFile} << "- within"
0336                            << buildDirectory;
0337             if (!replyIndex.isOutdated()) {
0338                 // KDevelop always writes the API client query file shortly before running the CMake configure step.
0339                 // Use its last modified timestamp as the time when the last CMake configure step started. This
0340                 // timestamp is normally a slight underestimate. However, when the user runs the CMake configure step
0341                 // manually while KDevelop is not running, the API client query file is not touched. Up-to-date project
0342                 // data can be marked outdated here in this case. KDevelop would reconfigure CMake needlessly then.
0343                 // TODO: determine when the last CMake configure step started more reliably, even if KDevelop is not
0344                 // running at the time. KDevelop always writes the same API client query, so the API reply written by
0345                 // CMake during the generate step is correct even if KDevelop does not rewrite the query file. If a
0346                 // future KDevelop version changes its API client query, the query file contents can be read and
0347                 // compared to determine whether the API reply is correct.
0348                 isOutdated = replyIndex.queryLastModified < lastModifiedCMakeFile;
0349                 if (isOutdated) {
0350                     qCDebug(CMAKE) << "API client query file is out of date (last modified before a source CMake file)";
0351                 }
0352             }
0353         }
0354     }
0355 
0356     if (!codeModel.compilationData.isValid) {
0357         qCWarning(CMAKE) << "failed to find code model in reply index" << sourceDirectory << buildDirectory
0358                          << replyIndex.data;
0359         return {};
0360     }
0361 
0362     codeModel.isOutdated = isOutdated;
0363     codeModel.cmakeFiles = cmakeFiles;
0364     return codeModel;
0365 }
0366 }
0367 }