================
@@ -0,0 +1,568 @@
+//===- SrcEditMerge.cpp
---------------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM
Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+//
+// clang-ssaf-src-edit-merge: per-LU source-edit YAML merge tool.
+//
+// Reads N per-TU clang::tooling::TranslationUnitReplacements YAML files,
+// deduplicates and merges them via the in-tree clang-apply-replacements
+// library (specifically clang::replace::mergeAndDeduplicate), and writes a
+// single merged YAML. The tool does NOT write source files — applying the
+// merge result is the caller's responsibility (typically clang-reforge
+// invokes `clang-apply-replacements` after this tool returns).
+//
+// Conflict policy: this tool implements a drop-all policy. After
+// mergeAndDeduplicate runs, the tool computes conflict clusters from the
+// input keys — a cluster being a maximal connected component of input
+// Replacements (within one file) whose [offset, offset+length) byte ranges
+// transitively overlap, restricted to length > 0 entries. For each
+// reportable cluster (one whose members intersect the library's
+// merged-output keys), every member is removed from the merged YAML,
+// one summary line is emitted to stderr, and the tool exits 0.
+//
+// Zero-length insertions are out of scope for the drop-all policy in this
+// change; they continue to follow clang-apply-replacements' own
+// IgnoreInsertConflict=false (first-registered) policy.
+//
+//===----------------------------------------------------------------------===//
+
+#include "clang-apply-replacements/Tooling/ApplyReplacements.h"
+#include "clang/Basic/Diagnostic.h"
+#include "clang/Basic/DiagnosticOptions.h"
+#include "clang/Basic/FileManager.h"
+#include "clang/Basic/SourceManager.h"
+#include "clang/Basic/Version.h"
+#include "clang/Tooling/ReplacementsYaml.h" // IWYU pragma: keep
+#include "llvm/ADT/ArrayRef.h"
+#include "llvm/ADT/STLExtras.h"
+#include "llvm/ADT/SmallString.h"
+#include "llvm/ADT/StringRef.h"
+#include "llvm/Support/CommandLine.h"
+#include "llvm/Support/Error.h"
+#include "llvm/Support/ErrorOr.h"
+#include "llvm/Support/FileSystem.h"
+#include "llvm/Support/FormatVariadic.h"
+#include "llvm/Support/InitLLVM.h"
+#include "llvm/Support/JSON.h"
+#include "llvm/Support/MemoryBuffer.h"
+#include "llvm/Support/Path.h"
+#include "llvm/Support/YAMLTraits.h"
+#include "llvm/Support/raw_ostream.h"
+
+#include <algorithm>
+#include <map>
+#include <set>
+#include <string>
+#include <utility>
+#include <vector>
+
+namespace {
+
+namespace cl = llvm::cl;
+
+cl::OptionCategory MergeCategory("clang-ssaf-src-edit-merge options");
+
+cl::list<std::string> InputFiles(cl::Positional, cl::OneOrMore,
+ cl::desc("<input.yaml>..."),
+ cl::cat(MergeCategory));
+
+cl::opt<std::string> OutputFile("o", cl::Required, cl::value_desc("path"),
+ cl::desc("Output path for the merged YAML."),
+ cl::cat(MergeCategory));
+
+cl::opt<std::string> SarifConflictsOut(
+ "sarif-conflicts-out", cl::value_desc("path"),
+ cl::desc("Optional path. When supplied, write a SARIF 2.1.0 document "
+ "listing conflict clusters dropped from the merged output."),
+ cl::cat(MergeCategory));
+
+/// Read one input YAML into a TranslationUnitReplacements.
+///
+/// Returns true on success. On failure, prints a one-line diagnostic to
+/// stderr and returns false; the caller surfaces this as a non-zero exit.
+bool readInput(llvm::StringRef Path,
+ clang::tooling::TranslationUnitReplacements &Out) {
+ llvm::ErrorOr<std::unique_ptr<llvm::MemoryBuffer>> Buffer =
+ llvm::MemoryBuffer::getFile(Path);
+ if (std::error_code EC = Buffer.getError()) {
+ llvm::errs() << "clang-ssaf-src-edit-merge: cannot read " << Path << ": "
+ << EC.message() << "\n";
+ return false;
+ }
+ llvm::yaml::Input YAML(Buffer.get()->getBuffer());
+ YAML >> Out;
+ if (YAML.error()) {
+ llvm::errs() << "clang-ssaf-src-edit-merge: " << Path
+ << ": invalid TranslationUnitReplacements YAML\n";
+ return false;
+ }
+ return true;
+}
+
+/// Identifying tuple for a replacement, used for both conflict-cluster
+/// analysis (input side) and drop-all filtering against the library's
+/// merged-output keys.
+struct ReplacementKey {
+ std::string FilePath;
+ unsigned Offset;
+ unsigned Length;
+ std::string Text;
+
+ bool operator<(const ReplacementKey &Other) const {
+ if (FilePath != Other.FilePath)
+ return FilePath < Other.FilePath;
+ if (Offset != Other.Offset)
+ return Offset < Other.Offset;
+ if (Length != Other.Length)
+ return Length < Other.Length;
+ return Text < Other.Text;
+ }
+};
+
+ReplacementKey makeKey(const clang::tooling::Replacement &R) {
+ return ReplacementKey{R.getFilePath().str(), R.getOffset(), R.getLength(),
+ R.getReplacementText().str()};
+}
+
+/// Pull every Replacement out of a FileToChangesMap into a flat ordered
+/// vector. Within each AtomicChange, the underlying Replacements are
+/// already (file, offset)-ordered by the library; across files we order
+/// by file path for determinism.
+std::vector<clang::tooling::Replacement>
+flattenFileChanges(const clang::replace::FileToChangesMap &Changes) {
+ // Group by file first so the output is deterministic across runs.
+ std::map<std::string, std::vector<clang::tooling::Replacement>> ByFile;
+ for (const auto &Entry : Changes) {
+ const std::string Path = Entry.first.getName().str();
+ auto &Bucket = ByFile[Path];
+ for (const clang::tooling::AtomicChange &AC : Entry.second) {
+ for (const clang::tooling::Replacement &R : AC.getReplacements())
+ Bucket.push_back(R);
+ }
+ }
+
+ std::vector<clang::tooling::Replacement> Flat;
+ for (auto &Entry : ByFile) {
+ auto &Bucket = Entry.second;
+ // Sort within a file by (offset, length, text) so per-file order is
+ // deterministic regardless of input order.
+ llvm::sort(Bucket, [](const clang::tooling::Replacement &A,
+ const clang::tooling::Replacement &B) {
+ if (A.getOffset() != B.getOffset())
+ return A.getOffset() < B.getOffset();
+ if (A.getLength() != B.getLength())
+ return A.getLength() < B.getLength();
+ return A.getReplacementText() < B.getReplacementText();
+ });
+ for (const auto &R : Bucket)
+ Flat.push_back(R);
+ }
+ return Flat;
+}
+
+/// Compute the shared MainSourceFile across inputs.
+///
+/// Per spec: if every input declares the same MainSourceFile, use that;
+/// otherwise use the empty string.
+std::string computeMainSourceFile(
+ const std::vector<clang::tooling::TranslationUnitReplacements> &TUs) {
+ if (TUs.empty())
+ return "";
+ const std::string &First = TUs.front().MainSourceFile;
+ for (const auto &TU : TUs)
+ if (TU.MainSourceFile != First)
+ return "";
+ return First;
+}
+
+/// Build the conflict cluster list from the merged input key set.
+///
+/// A cluster is a maximal connected component of input replacements (within
+/// one file) whose [offset, offset+length) byte ranges transitively overlap.
+/// Only length > 0 keys participate; zero-length insertions are out of scope
+/// for the drop-all policy per spec and continue to follow the library's
+/// existing IgnoreInsertConflict=false (first-registered) policy.
+///
+/// The walk groups by file, sorts each file's keys by (offset, length), and
+/// merges into the current cluster whenever
+/// key.offset < lastEnd, where
+/// lastEnd = max(member.offset + member.length) across cluster members.
+/// Otherwise the current cluster closes and a new one opens.
+///
+/// Only clusters of size > 1 are returned — singletons are not conflicts.
+///
+/// Each returned cluster's member list is sorted by (offset, length, text);
+/// the cluster list itself is sorted by (file, min-offset) ascending. This
+/// pins iteration order for both stderr cluster lines and (in a follow-on
+/// task) the SARIF results array, satisfying the spec's argv-permutation
+/// invariance promise.
+std::vector<std::vector<ReplacementKey>>
+buildConflictClusters(const std::set<ReplacementKey> &InputKeys) {
+ // Group length > 0 keys by file.
+ std::map<std::string, std::vector<ReplacementKey>> ByFile;
+ for (const ReplacementKey &K : InputKeys) {
+ if (K.Length == 0)
+ continue;
+ ByFile[K.FilePath].push_back(K);
+ }
+
+ std::vector<std::vector<ReplacementKey>> Clusters;
+ for (auto &Entry : ByFile) {
+ auto &Keys = Entry.second;
+ // InputKeys is a std::set sorted by (file, offset, length, text), so the
+ // per-file vector is already in (offset, length, text) order. Sort
+ // defensively for clarity.
+ llvm::sort(Keys, [](const ReplacementKey &A, const ReplacementKey &B) {
+ if (A.Offset != B.Offset)
+ return A.Offset < B.Offset;
+ if (A.Length != B.Length)
+ return A.Length < B.Length;
+ return A.Text < B.Text;
+ });
+
+ std::vector<ReplacementKey> Current;
+ unsigned LastEnd = 0;
+ auto Flush = [&]() {
+ if (Current.size() > 1)
+ Clusters.push_back(std::move(Current));
+ Current.clear();
+ LastEnd = 0;
+ };
+
+ for (const ReplacementKey &K : Keys) {
+ if (Current.empty()) {
+ Current.push_back(K);
+ LastEnd = K.Offset + K.Length;
+ continue;
+ }
+ if (K.Offset < LastEnd) {
+ Current.push_back(K);
+ LastEnd = std::max(LastEnd, K.Offset + K.Length);
+ } else {
+ Flush();
+ Current.push_back(K);
+ LastEnd = K.Offset + K.Length;
+ }
+ }
+ Flush();
+ }
+
+ // Pin cluster-list order by (file, min-offset) ascending.
+ llvm::sort(Clusters, [](const std::vector<ReplacementKey> &A,
+ const std::vector<ReplacementKey> &B) {
+ if (A.front().FilePath != B.front().FilePath)
+ return A.front().FilePath < B.front().FilePath;
+ return A.front().Offset < B.front().Offset;
+ });
+
+ return Clusters;
+}
+
+/// Emit one stderr line per reportable conflict cluster.
+///
+/// Format (preserved verbatim from the prior implementation):
+/// conflict: skipped <count> overlapping replacement(s) at <file>:<offset>
+/// where <count> is |cluster| (the full number of dropped members — under
+/// drop-all this is the cluster size, NOT cluster size − 1 as it was when
+/// first-registered let one survive) and <offset> is the cluster's minimum
+/// offset.
+///
+/// Caller is responsible for passing only reportable clusters
+/// (cluster ∩ OutputKeys ≠ ∅) and for the (file, min-offset) sort.
+void emitConflictClusterLines(
+ const std::vector<std::vector<ReplacementKey>> &Clusters) {
+ for (const auto &Cluster : Clusters) {
+ llvm::errs() << "conflict: skipped " << Cluster.size()
+ << " overlapping replacement(s) at "
+ << Cluster.front().FilePath << ":" << Cluster.front().Offset
+ << "\n";
+ }
+}
+
+/// Canonicalize a Replacement's `FilePath` into an absolute `file://` URI.
+///
+/// Fallback chain:
+/// 1. `llvm::sys::fs::real_path` — resolves symlinks and yields an
+/// absolute path. Only succeeds if the file exists on disk.
+/// 2. `llvm::sys::fs::make_absolute` — succeeds for non-existent paths
+/// too; used for synthetic test fixtures whose FilePath may name a
+/// file that the merger never opened.
+/// 3. Raw `FilePath` — last-resort fallback if both of the above fail.
+/// Emits a syntactically valid `file://` URI even if the underlying
+/// path is relative, matching the SARIF requirement's "absolute"
+/// promise loosely (downstream tooling that needs strict absolute
+/// URIs SHOULD canonicalize on its end if the disk state permits).
+std::string canonicalizeToFileUri(llvm::StringRef FilePath) {
+ llvm::SmallString<256> Buf;
+ if (!llvm::sys::fs::real_path(FilePath, Buf))
+ return "file://" + llvm::sys::path::convert_to_slash(Buf);
+ Buf.assign(FilePath.begin(), FilePath.end());
+ if (!llvm::sys::fs::make_absolute(Buf))
+ return "file://" + llvm::sys::path::convert_to_slash(Buf);
+ return "file://" + llvm::sys::path::convert_to_slash(FilePath);
+}
+
+/// Emit a SARIF 2.1.0 document at `Path` listing every conflict cluster.
+///
+/// `Clusters` SHALL be pre-sorted by `(file, min-offset)` ascending by the
+/// caller; this emitter walks them in order to populate
+/// `runs[0].results[]`. Within each cluster, `relatedLocations[]` is
+/// sorted locally by `(byteLength, candidate-text)` ascending per the
+/// "SARIF conflict report" requirement — this is a different sort from
+/// the cluster's outer `(offset, length, text)` ordering, so members are
+/// re-sorted inside this helper.
+///
+/// Even when `Clusters` is empty, this writes a well-formed SARIF
+/// document with `runs[0].results: []`. The file's presence is the
+/// "merger ran with conflict reporting requested" signal.
+llvm::Error
+emitConflictSarif(llvm::StringRef Path,
+ llvm::ArrayRef<std::vector<ReplacementKey>> Clusters) {
+ llvm::json::Array Results;
+ Results.reserve(Clusters.size());
+
+ for (const auto &Cluster : Clusters) {
+ const ReplacementKey &Min = Cluster.front();
+ std::string Uri = canonicalizeToFileUri(Min.FilePath);
+
+ // Re-sort cluster members locally by (byteLength, text) ascending for
+ // argv-permutation invariance of relatedLocations[].
+ std::vector<ReplacementKey> Sorted(Cluster.begin(), Cluster.end());
+ llvm::sort(Sorted, [](const ReplacementKey &A, const ReplacementKey &B) {
+ if (A.Length != B.Length)
+ return A.Length < B.Length;
+ return A.Text < B.Text;
+ });
+
+ llvm::json::Array RelatedLocations;
+ RelatedLocations.reserve(Sorted.size());
+ for (size_t I = 0; I < Sorted.size(); ++I) {
+ const ReplacementKey &K = Sorted[I];
+ RelatedLocations.push_back(llvm::json::Object{
+ {"id", static_cast<int64_t>(I + 1)},
+ {"physicalLocation",
+ llvm::json::Object{
+ {"artifactLocation", llvm::json::Object{{"uri", Uri}}},
+ {"region",
+ llvm::json::Object{
+ {"byteOffset", static_cast<int64_t>(K.Offset)},
+ {"byteLength", static_cast<int64_t>(K.Length)}}}}},
+ {"message", llvm::json::Object{
+ {"text", ("candidate edit: \"" + K.Text + "\"")}}}});
+ }
+
+ std::string MessageText =
+ llvm::formatv("{0} overlapping replacement(s) at {1} byte {2} were "
+ "dropped; resolve manually.",
+ Cluster.size(), Uri, Min.Offset)
+ .str();
+
+ Results.push_back(llvm::json::Object{
+ {"ruleId", "clang-reforge-replacement-conflict"},
+ {"level", "error"},
+ {"message", llvm::json::Object{{"text", MessageText}}},
+ {"locations",
+ llvm::json::Array{llvm::json::Object{
+ {"physicalLocation",
+ llvm::json::Object{
+ {"artifactLocation", llvm::json::Object{{"uri", Uri}}},
+ {"region",
+ llvm::json::Object{
+ {"byteOffset", static_cast<int64_t>(Min.Offset)}}}}}}}},
+ {"relatedLocations", std::move(RelatedLocations)}});
+ }
+
+ llvm::json::Value Doc = llvm::json::Object{
+ {"version", "2.1.0"},
+ {"$schema", "https://json.schemastore.org/sarif-2.1.0.json"},
+ {"runs",
+ llvm::json::Array{llvm::json::Object{
+ {"tool",
+ llvm::json::Object{
+ {"driver",
+ llvm::json::Object{{"name", "clang-ssaf-src-edit-merge"},
+ {"version", CLANG_VERSION_STRING}}}}},
+ {"results", std::move(Results)}}}}};
+
+ std::error_code EC;
+ llvm::raw_fd_ostream OS(Path, EC, llvm::sys::fs::OF_Text);
+ if (EC)
+ return llvm::createStringError(EC, "cannot write " + Path);
+ // Pretty-print with indent 2 via the json::Value format_provider.
+ OS << llvm::formatv("{0:2}", Doc) << "\n";
+ OS.flush();
+ if (OS.has_error())
+ return llvm::createStringError(OS.error(), "write error on " + Path);
+ return llvm::Error::success();
+}
+
+} // namespace
+
+int main(int argc, const char **argv) {
+ llvm::InitLLVM X(argc, argv);
+ cl::HideUnrelatedOptions(MergeCategory);
+ cl::ParseCommandLineOptions(
+ argc, argv,
+ "clang-ssaf-src-edit-merge: merge per-TU TranslationUnitReplacements "
+ "YAML files for one link unit into a single merged YAML. Does not "
+ "write source files; the apply step is the caller's responsibility.\n");
+
+ // Read all inputs.
+ std::vector<clang::tooling::TranslationUnitReplacements> TUs;
+ TUs.reserve(InputFiles.size());
+ for (const std::string &Path : InputFiles) {
+ clang::tooling::TranslationUnitReplacements TU;
+ if (!readInput(Path, TU))
+ return 1;
+ TUs.push_back(std::move(TU));
+ }
+
+ // Pre-deduplicate identical replacements across all input TUs before the
+ // library merge. clang-apply-replacements' groupReplacements only dedups
+ // TUDiagnostics-sourced replacements; plain TUReplacements are appended
+ // unconditionally, so identical zero-length inserts at the same offset
+ // (e.g., a `.data()` rewrite in a header included by N TUs, or an
+ // `addr_of(...)` wrap closing-paren) get stacked by AtomicChange::replace
+ // into runaway `.data().data()...` or `))))` chains. The first occurrence
+ // (in input-file order, then within-file order) wins; later duplicates are
+ // byte-identical to it in (file, offset, length, text), so the choice is
+ // observationally moot.
+ {
+ std::set<ReplacementKey> SeenKeys;
+ for (auto &TU : TUs) {
+ std::vector<clang::tooling::Replacement> Unique;
+ Unique.reserve(TU.Replacements.size());
+ for (const clang::tooling::Replacement &R : TU.Replacements) {
+ if (SeenKeys.insert(makeKey(R)).second)
+ Unique.push_back(R);
+ }
+ TU.Replacements = std::move(Unique);
+ }
+ }
+
+ // Pre-compute the input-side replacement set for conflict reporting.
+ std::set<ReplacementKey> InputKeys;
+ for (const auto &TU : TUs)
+ for (const auto &R : TU.Replacements)
+ InputKeys.insert(makeKey(R));
+
+ // Build a SourceManager for mergeAndDeduplicate.
+ clang::DiagnosticOptions DiagOpts;
+ clang::DiagnosticsEngine Diagnostics(clang::DiagnosticIDs::create(),
+ DiagOpts);
+ clang::FileManager Files((clang::FileSystemOptions()));
+ clang::SourceManager SM(Diagnostics, Files);
+
+ // Run the library's merge. mergeAndDeduplicate's return value (true = no
+ // overlap detected, false = at least one overlap dropped) is no longer
+ // consulted directly. The tool's drop-all policy operates on its own
+ // cluster analysis below; the library's first-registered behavior is
+ // overridden by removing every cluster member from OutDoc.Replacements.
+ // The library's return value still drives its own per-Replacement stderr
+ // diagnostics, which we leave intact.
+ clang::replace::FileToChangesMap FileChanges;
+ const clang::replace::TUDiagnostics NoDiagnostics;
+ (void)clang::replace::mergeAndDeduplicate(TUs, NoDiagnostics, FileChanges,
SM,
+ /*IgnoreInsertConflict=*/false);
+
+ // Flatten the merged FileChanges back into a TranslationUnitReplacements.
+ clang::tooling::TranslationUnitReplacements OutDoc;
+ OutDoc.MainSourceFile = computeMainSourceFile(TUs);
+ OutDoc.Replacements = flattenFileChanges(FileChanges);
+
+ // Drop-all conflict handling.
+ //
+ // Step 1: build conflict clusters from the input key set.
+ std::vector<std::vector<ReplacementKey>> Clusters =
+ buildConflictClusters(InputKeys);
+
+ // Step 2: compute OutputKeys = set of ReplacementKey for every entry in
+ // the library's merged FileChanges, BEFORE drop-all filtering. This is
+ // the cluster-eligibility predicate's right-hand side.
+ std::set<ReplacementKey> OutputKeys;
+ for (const auto &R : OutDoc.Replacements)
+ OutputKeys.insert(makeKey(R));
+
+ // Step 3: a cluster is reportable iff at least one of its members appears
----------------
t-rasmud wrote:
Yes, it'll be "" most of the time in practice (any two TUs disagreeing is
enough), but we can't drop it or make it optional-when-empty.
MappingTraits<TranslationUnitReplacements> in ReplacementsYaml.h maps it with
mapRequired, and llvm::yaml::Input::preflightKey treats a missing required key
as a hard parse error ("missing required key 'MainSourceFile'"). Since
clang-apply-replacements reads our output through that same required-key
contract, omitting the field when it's empty would make it reject the merged
file outright in the common case.
https://github.com/llvm/llvm-project/pull/216183
_______________________________________________
cfe-commits mailing list
[email protected]
https://lists.llvm.org/cgi-bin/mailman/listinfo/cfe-commits