#include "LinkScan.h" #include #include #include #include namespace { constexpr QLatin1String kPathChars = QLatin1String("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123" "456789_./~+@%#=:"); bool isUriStartChar(QChar ch) { return (ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z'); } bool isSchemeChar(QChar ch) { return isUriStartChar(ch) || (ch >= '0' && ch <= '9') || ch == '+' || ch == '.' || ch == '-'; } bool isUriBodyChar(QChar ch) { static const QString forbidden = QStringLiteral("<>\"'`"); return !ch.isSpace() && !forbidden.contains(ch); } bool isPathChar(QChar ch) { return kPathChars.contains(ch); } // One URI match: `scheme://body` or `mailto:body`. Returns -1 when no URI // starts at `from`. int uriEndAt(const QString& text, int from) { const int len = static_cast(text.size()); int i = from; if (!isUriStartChar(text.at(i))) { return -1; } ++i; while (i < len && isSchemeChar(text.at(i))) { ++i; } const QStringView scheme(text.constData() + from, i - from); const bool doubleSlash = i + 1 < len && text.at(i) == ':' && text.at(i + 1) == '/' && i + 2 < len && text.at(i + 2) == '/'; const bool mailto = scheme == QLatin1String("mailto") && i < len && text.at(i) == ':'; if (!doubleSlash && !mailto) { return -1; } ++i; // the ':' if (doubleSlash) { i += 2; // the '//' } while (i < len && isUriBodyChar(text.at(i))) { ++i; } return i; } bool isTrimPunct(QChar ch) { static const QString punct = QStringLiteral(".,;:!?"); return punct.contains(ch); } // True when the trailing closer of `text` has no opener left to balance it. // Only that case trims; a closer that matches an opening bracket inside the // token stays. The caller guarantees the last character is `close`. bool trailingCloserUnmatched(const QString& text, QChar open, QChar close) { int depth = 0; const QStringView body = QStringView(text).first(text.size() - 1); for (const QChar ch : body) { if (ch == open) { ++depth; } else if (ch == close && depth > 0) { --depth; } } return depth == 0; } // A trailing closer is trimmed only when it has no opener to balance. bool unmatchedCloserAtEnd(const QString& text) { const QChar last = text.back(); if (last == ')') { return trailingCloserUnmatched(text, '(', ')'); } if (last == ']') { return trailingCloserUnmatched(text, '[', ']'); } if (last == '}') { return trailingCloserUnmatched(text, '{', '}'); } return false; } // Trim trailing punctuation: .,;:!? an unmatched ) ] }, and a trailing ' or ". // Keeps a balanced closer and a trailing '/'. int trimEnd(const QString& token) { int end = static_cast(token.size()); while (end > 0) { const QChar last = token.at(end - 1); if (isTrimPunct(last) || last == '\'' || last == '"' || unmatchedCloserAtEnd(token.left(end))) { --end; continue; } break; } return end; } } // namespace QVector scanRowLinks(const QString& text, const QVector& columnOfChar) { QVector links; const int len = static_cast(text.size()); if (len == 0) { return links; } // Pass 1: URIs. `i` is a cursor that resumes past each match, so this is // the hand-written-iteration exception; the match ranges let pass 2 skip // them. QVector> uriRanges; int i = 0; while (i < len) { if (isUriStartChar(text.at(i))) { const int end = uriEndAt(text, i); if (end > i) { uriRanges.append({i, end}); i = end; continue; } } ++i; } const auto inUri = [&uriRanges](int pos) { return std::ranges::any_of( uriRanges, [pos](const std::pair& range) { return pos >= range.first && pos < range.second; }); }; for (const auto& [s, e] : uriRanges) { const int end = trimEnd(text.mid(s, e - s)) + s; if (end <= s) { continue; } ScannedLink link; link.startCol = columnOfChar.value(s); link.endCol = columnOfChar.value(end - 1); link.text = text.mid(s, end - s); link.isUri = true; links.append(link); } // Pass 2: paths — a run of path characters containing '/', skipping URI // ranges. `i` is again the cursor: it resumes at each match's end, so tokens // never overlap. i = 0; while (i < len) { if (inUri(i) || !isPathChar(text.at(i))) { ++i; continue; } int j = i; while (j < len && !inUri(j) && isPathChar(text.at(j))) { ++j; } const QStringView run(text.constData() + i, j - i); if (run.contains('/')) { const int end = trimEnd(run.toString()) + i; if (end > i) { const QString token = text.mid(i, end - i); // Anchored paths only: absolute, home-relative, or explicitly // relative. A bare relative chain such as app/src/main.cpp is how // source code, grep results, diff headers, and log lines spell // module and target names, so on its own it is not a filesystem // path and must not become a link. The consumer still requires the // resolved path to exist, so this only removes false positives. const bool anchored = token.startsWith(QLatin1Char('/')) || token.startsWith(QLatin1String("~/")) || token.startsWith(QLatin1String("./")) || token.startsWith(QLatin1String("../")); if (!anchored) { i = j; continue; } ScannedLink link; link.startCol = columnOfChar.value(i); link.endCol = columnOfChar.value(end - 1); link.text = token; link.isUri = false; links.append(link); i = end; continue; } } i = j; } return links; } PathToken splitLineColumn(const QString& token) { PathToken result; // A colon starts a suffix only when digits follow it. const auto candidates = std::views::iota(qsizetype{0}, token.size() - 1); const auto colon = std::ranges::find_if(candidates, [&token](qsizetype i) { return token.at(i) == QLatin1Char(':') && token.at(i + 1).isDigit(); }); if (colon == candidates.end()) { result.path = token; return result; } result.path = token.left(*colon); const QStringView rest = QStringView(token).mid(*colon + 1); const int secondColon = static_cast(rest.indexOf(':')); const QStringView linePart = secondColon < 0 ? rest : rest.left(secondColon); result.line = linePart.toInt(); if (secondColon >= 0) { const QStringView colPart = rest.mid(secondColon + 1); bool allDigits = !colPart.isEmpty(); for (const QChar ch : colPart) { if (!ch.isDigit()) { allDigits = false; break; } } if (allDigits) { result.column = colPart.toInt(); } } return result; } QString resolveDisplayedPath(const QString& path, const QString& baseDir) { QString expanded = path; if (expanded == QLatin1String("~") || expanded.startsWith(QLatin1String("~/"))) { expanded = QDir::homePath() + expanded.mid(1); } if (QDir::isAbsolutePath(expanded)) { return QDir::cleanPath(expanded); } if (baseDir.isEmpty()) { return expanded; } return QDir::cleanPath(baseDir + QLatin1Char('/') + expanded); }