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 }