// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module; #include module Crafter.Build:Agents_impl; import std; import :Agents; import :Clang; import :External; import :Platform; import :Progress; namespace fs = std::filesystem; using namespace Crafter; namespace { constexpr std::string_view BeginMarker = ""; constexpr std::string_view EndMarker = ""; // Right under the begin marker, for whoever opens the file: an edit made // inside the section is gone after the next build. constexpr std::string_view Preamble = ""; constexpr std::string_view NotesFileName = "AGENTS.consumer.md"; void Warn(std::string_view message) { Progress::Diagnostic(std::format("warning: {}", message)); } struct Line { std::string_view text; // without the newline and a '\r' before it std::size_t begin = 0; // offset of the line's first byte std::size_t next = 0; // offset of the line after it }; std::vector SplitLines(std::string_view text) { std::vector lines; std::size_t pos = 0; while (pos < text.size()) { const std::size_t newline = text.find('\n', pos); const std::size_t end = newline == std::string_view::npos ? text.size() : newline; std::string_view line = text.substr(pos, end - pos); if (line.ends_with('\r')) line.remove_suffix(1); const std::size_t next = newline == std::string_view::npos ? text.size() : newline + 1; lines.push_back({ .text = line, .begin = pos, .next = next }); pos = next; } return lines; } std::string_view TrimEnd(std::string_view text) { const std::size_t last = text.find_last_not_of(" \t"); return last == std::string_view::npos ? std::string_view() : text.substr(0, last + 1); } // Fenced code blocks as CommonMark has them: a run of three or more // backticks or tildes, after at most three spaces, opens one, and a run // of the same character at least as long, with nothing after it but // spaces, closes it. Tracking the character and the length is what lets // a ~~~ block quote a ``` one, the usual way to show a fenced example. class CodeFences { public: // Feed the lines in order. True for a fence and for every line // inside a block. bool InCode(std::string_view line) { std::size_t pos = 0; while (pos < line.size() && pos < 3 && line[pos] == ' ') ++pos; const char ch = pos < line.size() ? line[pos] : '\0'; std::size_t run = 0; if (ch == '`' || ch == '~') { while (pos + run < line.size() && line[pos + run] == ch) ++run; } const bool fence = run >= 3; if (open_ == '\0') { if (!fence) return false; open_ = ch; length_ = run; return true; } if (fence && ch == open_ && run >= length_ && TrimEnd(line.substr(pos + run)).empty()) open_ = '\0'; return true; } bool Open() const { return open_ != '\0'; } private: char open_ = '\0'; std::size_t length_ = 0; }; // Where a text's marker lines are, outside code blocks, as indices into // `lines`. struct MarkerScan { std::vector lines; std::vector begins; std::vector ends; // The line a code block that never closes opens on. std::optional unclosedBlock; }; MarkerScan ScanMarkers(std::string_view text) { MarkerScan scan; scan.lines = SplitLines(text); CodeFences fences; for (std::size_t i = 0; i < scan.lines.size(); ++i) { const bool wasOpen = fences.Open(); if (fences.InCode(scan.lines[i].text)) { if (!wasOpen) scan.unclosedBlock = i; continue; } const std::string_view line = TrimEnd(scan.lines[i].text); if (line == BeginMarker) scan.begins.push_back(i); if (line == EndMarker) scan.ends.push_back(i); } if (!fences.Open()) scan.unclosedBlock.reset(); return scan; } // Why `text` cannot go into the section as it is; empty when it can. A // marker line of its own would end or restart the section once the file // is read back, and a code block left open would hide the end marker: // either way the next build could no longer find what this one wrote. std::string NoteProblem(std::string_view text) { const MarkerScan scan = ScanMarkers(text); if (!scan.begins.empty() || !scan.ends.empty()) { std::size_t line = scan.lines.size(); if (!scan.begins.empty()) line = scan.begins[0]; if (!scan.ends.empty()) line = std::min(line, scan.ends[0]); return std::format("line {} is a dependency notes marker", line + 1); } if (scan.unclosedBlock) return std::format("line {} opens a code block that never closes", *scan.unclosedBlock + 1); return ""; } std::string RenderSection(std::span notes) { std::string section = std::format("{}\n{}\n", BeginMarker, Preamble); for (const DependencyNote& note : notes) section += std::format("\n\n{}\n", note.source, note.text); section += std::format("\n{}\n", EndMarker); return section; } // Without leading blank lines and trailing whitespace, so the section // spaces its notes the same whatever the files end with. std::string TrimBlank(std::string_view text) { const std::size_t last = text.find_last_not_of(" \t\r\n"); if (last == std::string_view::npos) return ""; const std::size_t first = text.find_first_not_of(" \t\r\n"); const std::size_t newline = text.rfind('\n', first); const std::size_t begin = newline == std::string_view::npos ? 0 : newline + 1; return std::string(text.substr(begin, last + 1 - begin)); } std::optional ReadFile(const fs::path& file) { std::ifstream in(file, std::ios::binary); if (!in) return std::nullopt; std::string content((std::istreambuf_iterator(in)), std::istreambuf_iterator()); if (in.bad()) return std::nullopt; return content; } // Replace the content of `file` through a temporary beside it: a write // cut short (a full disk, a crash) leaves the old AGENTS.md rather than // part of the new one, and a reader never sees a half-written file. The // rename lands on the symlink's target, so an AGENTS.md that is a link // stays one, and the target's permissions are carried over. bool ReplaceFile(const fs::path& file, std::string_view content) { std::error_code ec; const fs::path target = fs::canonical(file, ec); if (ec) return false; const fs::path temporary = target.parent_path() / std::format(".{}.{}.tmp", target.filename().string(), getpid()); { std::ofstream out(temporary, std::ios::binary | std::ios::trunc); out.write(content.data(), static_cast(content.size())); out.close(); if (!out) { fs::remove(temporary, ec); return false; } } if (const fs::file_status status = fs::status(target, ec); !ec) fs::permissions(temporary, status.permissions(), ec); fs::rename(temporary, target, ec); if (ec) { std::error_code ignored; fs::remove(temporary, ignored); return false; } return true; } // The note in `file`, beside the project file of a dependency loaded // from `url` (empty for a local one); nullopt when there is none or it // cannot be used. std::optional ReadNote(const fs::path& file, const std::string& url, const fs::path& projectDir) { std::error_code ec; if (!fs::is_regular_file(file, ec)) return std::nullopt; const std::optional content = ReadFile(file); if (!content) { Warn(std::format("{} could not be read, so its notes are left out", file.string())); return std::nullopt; } std::string text = TrimBlank(*content); if (text.empty()) return std::nullopt; // On the file as it is, so the line numbers are the author's. if (const std::string problem = NoteProblem(*content); !problem.empty()) { Warn(std::format("{}: {}, so its notes are left out", file.string(), problem)); return std::nullopt; } // A URL names the dependency the same on every machine, which a path // into the cache or a sibling checkout does not, and the section is // committed along with the rest of AGENTS.md. std::string source = url.empty() ? file.lexically_relative(projectDir).generic_string() : url; return DependencyNote{ .source = std::move(source), .text = std::move(text) }; } // The AGENTS.consumer.md of every project `project` depends on, as // project.dependencyNotes selects them. Breadth-first from the project's // `#pragma crafter dependency` targets, its dependencies and its tests' // dependencies, so the projects it names itself come first, in the // order it names them, and a library several of them use is described // once, where it is first reached. // // A loaded project, one that GitProject or LocalProject returned, // contributes the file beside its project file. Every other // Configuration, the project's own library or a dependency's internal // one, is only walked through. Direct stops at the first loaded project // on each path. A dependency's own pragma tooling is never reached, // being no `dependencies` edge, and a dependency's tests are never // walked: nothing builds them for the consumer, and they may point at a // Configuration their project file has since moved out of. std::vector CollectNotes(const Configuration& project, const fs::path& absProject) { const fs::path projectDir = absProject.parent_path(); std::deque queue; // Seen from the start: a test may list the project itself. std::unordered_set seen = { &project }; auto enqueue = [&](const Configuration* cfg) { if (cfg && seen.insert(cfg).second) queue.push_back(cfg); }; for (const Configuration* dep : PragmaDependencies(absProject)) enqueue(dep); for (const Configuration* dep : project.dependencies) enqueue(dep); for (const Test& test : project.tests) { for (const Configuration* dep : test.config.dependencies) enqueue(dep); } std::vector notes; std::unordered_set files; while (!queue.empty()) { const Configuration* cfg = queue.front(); queue.pop_front(); if (const std::optional loaded = LoadedProjectOf(*cfg)) { const fs::path file = loaded->projectFile.parent_path() / NotesFileName; if (files.insert(file).second) { if (std::optional note = ReadNote(file, loaded->url, projectDir)) notes.push_back(std::move(*note)); } if (project.dependencyNotes == DependencyNotes::Direct) continue; } for (const Configuration* dep : cfg->dependencies) enqueue(dep); } return notes; } } std::string Crafter::MergeDependencyNotes(std::string_view agentsMd, std::span notes) { for (const DependencyNote& note : notes) { if (const std::string problem = NoteProblem(note.text); !problem.empty()) throw std::runtime_error(std::format("the notes from {}: {}", note.source, problem)); } const MarkerScan scan = ScanMarkers(agentsMd); if (scan.unclosedBlock) throw std::runtime_error(std::format("line {}: a code block opens here and never closes", *scan.unclosedBlock + 1)); if (scan.begins.size() > 1) throw std::runtime_error(std::format("line {}: a second dependency notes begin marker (the first is on line {})", scan.begins[1] + 1, scan.begins[0] + 1)); if (scan.ends.size() > 1) throw std::runtime_error(std::format("line {}: a second dependency notes end marker (the first is on line {})", scan.ends[1] + 1, scan.ends[0] + 1)); if (!scan.ends.empty() && (scan.begins.empty() || scan.ends[0] < scan.begins[0])) throw std::runtime_error(std::format("line {}: a dependency notes end marker with no begin marker before it", scan.ends[0] + 1)); if (!scan.begins.empty() && scan.ends.empty()) throw std::runtime_error(std::format("line {}: a dependency notes begin marker with no end marker after it", scan.begins[0] + 1)); const std::string section = notes.empty() ? std::string() : RenderSection(notes); if (scan.begins.empty()) { if (notes.empty()) return std::string(agentsMd); std::string merged(agentsMd); if (!merged.empty()) merged += merged.ends_with('\n') ? "\n" : "\n\n"; return merged + section; } std::string_view before = agentsMd.substr(0, scan.lines[scan.begins[0]].begin); const std::string_view after = agentsMd.substr(scan.lines[scan.ends[0]].next); // The blank line the section was appended after goes with it. if (notes.empty() && before.ends_with("\n\n")) before.remove_suffix(1); return std::format("{}{}{}", before, section, after); } bool Crafter::UpdateAgentsMd(const Configuration& project, const fs::path& projectFile) { std::error_code ec; const fs::path absProject = fs::canonical(projectFile, ec); if (ec) return false; const fs::path agents = absProject.parent_path() / "AGENTS.md"; if (!fs::is_regular_file(agents, ec)) return false; const std::optional current = ReadFile(agents); if (!current) { Warn(std::format("{} could not be read, so its dependency notes were not updated", agents.string())); return false; } const std::vector notes = project.dependencyNotes == DependencyNotes::None ? std::vector() : CollectNotes(project, absProject); std::string merged; try { merged = MergeDependencyNotes(*current, notes); } catch (const std::exception& e) { Warn(std::format("{}: {}, so its dependency notes were not updated", agents.string(), e.what())); return false; } if (merged == *current) return false; if (!ReplaceFile(agents, merged)) { Warn(std::format("{} could not be written, so its dependency notes were not updated", agents.string())); return false; } // A build that edits a file the user commits says so. if (Progress::GetVerbosity() != Progress::Verbosity::Quiet) { Progress::Clear(); if (notes.empty()) { std::println("Removed the dependency notes from {}", agents.string()); } else { std::println("Updated the dependency notes in {} ({} {})", agents.string(), notes.size(), notes.size() == 1 ? "dependency" : "dependencies"); } } return true; }