|
| 1 | +/* |
| 2 | + * This file is part of Notepad Next. |
| 3 | + * Copyright 2026 NotepadAI contributors |
| 4 | + * |
| 5 | + * Notepad Next is free software: you can redistribute it and/or modify |
| 6 | + * it under the terms of the GNU General Public License as published by |
| 7 | + * the Free Software Foundation, either version 3 of the License, or |
| 8 | + * (at your option) any later version. |
| 9 | + * |
| 10 | + * Notepad Next is distributed in the hope that it will be useful, |
| 11 | + * but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 12 | + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 13 | + * GNU General Public License for more details. |
| 14 | + * |
| 15 | + * You should have received a copy of the GNU General Public License |
| 16 | + * along with Notepad Next. If not, see <https://www.gnu.org/licenses/>. |
| 17 | + */ |
| 18 | + |
| 19 | +#include "AcpDiffStat.h" |
| 20 | + |
| 21 | +#include <QByteArray> |
| 22 | +#include <QJsonArray> |
| 23 | +#include <QJsonObject> |
| 24 | +#include <QList> |
| 25 | +#include <QStringView> |
| 26 | + |
| 27 | +#include "git/BufferDiffEngine.h" |
| 28 | + |
| 29 | +namespace AcpDiffStat { |
| 30 | + |
| 31 | +namespace { |
| 32 | + |
| 33 | +constexpr int kMaxBlocks = 256; |
| 34 | + |
| 35 | +// Calls fn(line) for every line of text, without its '\r\n' / '\n'. A final |
| 36 | +// newline does not start an extra empty line. |
| 37 | +template <typename Fn> |
| 38 | +void forEachLine(QStringView text, Fn &&fn) |
| 39 | +{ |
| 40 | + qsizetype start = 0; |
| 41 | + const qsizetype n = text.size(); |
| 42 | + while (start < n) { |
| 43 | + qsizetype end = text.indexOf(QLatin1Char('\n'), start); |
| 44 | + if (end < 0) |
| 45 | + end = n; |
| 46 | + QStringView line = text.sliced(start, end - start); |
| 47 | + if (line.endsWith(QLatin1Char('\r'))) |
| 48 | + line.chop(1); |
| 49 | + fn(line); |
| 50 | + start = end + 1; |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +int lineCount(QStringView text) |
| 55 | +{ |
| 56 | + if (text.isEmpty()) |
| 57 | + return 0; |
| 58 | + const int newlines = static_cast<int>(text.count(QLatin1Char('\n'))); |
| 59 | + return text.endsWith(QLatin1Char('\n')) ? newlines : newlines + 1; |
| 60 | +} |
| 61 | + |
| 62 | +enum class Mark : quint8 { None, Context, Add, Remove }; |
| 63 | + |
| 64 | +// omp's numbered row, as AcpToolCallCard parses it: "[ ]*[+-]?N[ ]*(|or│)text". |
| 65 | +Mark numberedMark(QStringView line) |
| 66 | +{ |
| 67 | + qsizetype i = 0; |
| 68 | + const qsizetype n = line.size(); |
| 69 | + while (i < n && line.at(i) == QLatin1Char(' ')) |
| 70 | + ++i; |
| 71 | + if (i >= n) |
| 72 | + return Mark::None; |
| 73 | + Mark mark = Mark::Context; |
| 74 | + if (line.at(i) == QLatin1Char('+')) { |
| 75 | + mark = Mark::Add; |
| 76 | + ++i; |
| 77 | + } else if (line.at(i) == QLatin1Char('-')) { |
| 78 | + mark = Mark::Remove; |
| 79 | + ++i; |
| 80 | + } |
| 81 | + const qsizetype digits = i; |
| 82 | + bool nonZero = false; |
| 83 | + while (i < n && line.at(i) >= QLatin1Char('0') && line.at(i) <= QLatin1Char('9')) { |
| 84 | + nonZero = nonZero || line.at(i) != QLatin1Char('0'); |
| 85 | + ++i; |
| 86 | + } |
| 87 | + if (i == digits || !nonZero) |
| 88 | + return Mark::None; |
| 89 | + while (i < n && line.at(i) == QLatin1Char(' ')) |
| 90 | + ++i; |
| 91 | + if (i >= n || (line.at(i) != QLatin1Char('|') && line.at(i) != QChar(0x2502))) |
| 92 | + return Mark::None; |
| 93 | + return mark; |
| 94 | +} |
| 95 | + |
| 96 | +// The compact `diff` string: omp numbered rows when the first non-empty line |
| 97 | +// is one, else unified. False when it holds no diff rows (the card then falls |
| 98 | +// back to oldText/newText, and so does the count). |
| 99 | +bool countCompact(QStringView diff, Lines *out) |
| 100 | +{ |
| 101 | + bool decided = false; |
| 102 | + bool numbered = false; |
| 103 | + bool anyRow = false; |
| 104 | + Lines lines; |
| 105 | + forEachLine(diff, [&](QStringView line) { |
| 106 | + if (line.isEmpty()) |
| 107 | + return; |
| 108 | + if (!decided) { |
| 109 | + decided = true; |
| 110 | + numbered = numberedMark(line) != Mark::None; |
| 111 | + } |
| 112 | + if (numbered) { |
| 113 | + switch (numberedMark(line)) { |
| 114 | + case Mark::Add: ++lines.added; anyRow = true; break; |
| 115 | + case Mark::Remove: ++lines.removed; anyRow = true; break; |
| 116 | + case Mark::Context: anyRow = true; break; |
| 117 | + case Mark::None: break; |
| 118 | + } |
| 119 | + return; |
| 120 | + } |
| 121 | + if (line.startsWith(QLatin1String("+++")) || line.startsWith(QLatin1String("---"))) |
| 122 | + return; |
| 123 | + switch (line.at(0).unicode()) { |
| 124 | + case '+': ++lines.added; anyRow = true; break; |
| 125 | + case '-': ++lines.removed; anyRow = true; break; |
| 126 | + case ' ': |
| 127 | + case '\t': anyRow = true; break; |
| 128 | + default: break; |
| 129 | + } |
| 130 | + }); |
| 131 | + if (anyRow) |
| 132 | + *out = lines; |
| 133 | + return anyRow; |
| 134 | +} |
| 135 | + |
| 136 | +QList<QStringView> splitLines(QStringView text) |
| 137 | +{ |
| 138 | + QList<QStringView> out; |
| 139 | + out.reserve(lineCount(text)); |
| 140 | + forEachLine(text, [&out](QStringView line) { out.append(line); }); |
| 141 | + return out; |
| 142 | +} |
| 143 | + |
| 144 | +// Linear fallback for huge texts: common prefix and suffix are unchanged, the |
| 145 | +// middle counts as one replace (what the card renders for them too). |
| 146 | +Lines prefixSuffix(QStringView oldText, QStringView newText) |
| 147 | +{ |
| 148 | + const QList<QStringView> a = splitLines(oldText); |
| 149 | + const QList<QStringView> b = splitLines(newText); |
| 150 | + qsizetype pre = 0; |
| 151 | + while (pre < a.size() && pre < b.size() && a.at(pre) == b.at(pre)) |
| 152 | + ++pre; |
| 153 | + qsizetype oldEnd = a.size(); |
| 154 | + qsizetype newEnd = b.size(); |
| 155 | + while (oldEnd > pre && newEnd > pre && a.at(oldEnd - 1) == b.at(newEnd - 1)) { |
| 156 | + --oldEnd; |
| 157 | + --newEnd; |
| 158 | + } |
| 159 | + return {static_cast<int>(newEnd - pre), static_cast<int>(oldEnd - pre)}; |
| 160 | +} |
| 161 | + |
| 162 | +Lines countText(const QString &oldText, const QString &newText) |
| 163 | +{ |
| 164 | + if (oldText.size() > kMaxExactDiffChars || newText.size() > kMaxExactDiffChars) |
| 165 | + return prefixSuffix(oldText, newText); |
| 166 | + QByteArray a = oldText.toUtf8(); |
| 167 | + QByteArray b = newText.toUtf8(); |
| 168 | + // Edit snippets usually lack a final newline; without one on both sides |
| 169 | + // xdiff would see the old last line as changed when a line is appended. |
| 170 | + if (!a.endsWith('\n')) |
| 171 | + a.append('\n'); |
| 172 | + if (!b.endsWith('\n')) |
| 173 | + b.append('\n'); |
| 174 | + Lines lines; |
| 175 | + for (const BufferDiffEngine::Hunk &h : BufferDiffEngine::diff(a, b)) { |
| 176 | + lines.removed += h.oldCount; |
| 177 | + lines.added += h.newCount; |
| 178 | + } |
| 179 | + return lines; |
| 180 | +} |
| 181 | + |
| 182 | +} // namespace |
| 183 | + |
| 184 | +Lines forBlock(const QJsonObject &block) |
| 185 | +{ |
| 186 | + const QJsonValue compact = block.value(QLatin1String("diff")); |
| 187 | + if (compact.isString()) { |
| 188 | + Lines lines; |
| 189 | + if (countCompact(compact.toString(), &lines)) |
| 190 | + return lines; |
| 191 | + } |
| 192 | + const QJsonValue oldVal = block.value(QLatin1String("oldText")); |
| 193 | + const QJsonValue newVal = block.value(QLatin1String("newText")); |
| 194 | + const QString oldText = oldVal.isString() ? oldVal.toString() : QString(); |
| 195 | + const QString newText = newVal.isString() ? newVal.toString() : QString(); |
| 196 | + if (oldText.isEmpty()) |
| 197 | + return {lineCount(newText), 0}; |
| 198 | + if (newText.isEmpty()) |
| 199 | + return {0, lineCount(oldText)}; |
| 200 | + return countText(oldText, newText); |
| 201 | +} |
| 202 | + |
| 203 | +Lines forContent(const QJsonArray &content) |
| 204 | +{ |
| 205 | + Lines total; |
| 206 | + const qsizetype n = qMin<qsizetype>(content.size(), kMaxBlocks); |
| 207 | + for (qsizetype i = 0; i < n; ++i) { |
| 208 | + const QJsonValue item = content.at(i); |
| 209 | + if (!item.isObject()) |
| 210 | + continue; |
| 211 | + const QJsonObject block = item.toObject(); |
| 212 | + if (block.value(QLatin1String("type")).toString() == QLatin1String("diff")) |
| 213 | + total += forBlock(block); |
| 214 | + } |
| 215 | + return total; |
| 216 | +} |
| 217 | + |
| 218 | +Lines forToolCall(const AcpProtocol::AcpToolCall &tc) |
| 219 | +{ |
| 220 | + if (tc.status != QLatin1String("completed")) |
| 221 | + return {}; |
| 222 | + return forContent(tc.content); |
| 223 | +} |
| 224 | + |
| 225 | +QString label(const Lines &lines) |
| 226 | +{ |
| 227 | + if (lines.isEmpty()) |
| 228 | + return {}; |
| 229 | + return QStringLiteral("+%1 -%2").arg(lines.added).arg(lines.removed); |
| 230 | +} |
| 231 | + |
| 232 | +} // namespace AcpDiffStat |
0 commit comments