// Copyright (C) 2016 The Qt Company Ltd. // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0 #include "outputformatter.h" #include "algorithm.h" #include "ansiescapecodehandler.h" #include "fileinprojectfinder.h" #include "link.h" #include "qtcassert.h" #include "stringutils.h" #include "stylehelper.h" #include "theme/theme.h" #include #include #include #include #include #include #ifdef WITH_TESTS #include #endif #include namespace Utils { class OutputLineParser::Private { public: FilePaths searchDirs; QPointer redirectionDetector; bool skipFileExistsCheck = false; bool demoteErrorsToWarnings = false; FileInProjectFinder *fileFinder = nullptr; }; OutputLineParser::OutputLineParser() : d(new Private) { } OutputLineParser::~OutputLineParser() { delete d; } Q_GLOBAL_STATIC_WITH_ARGS(QString, linkPrefix, {"olpfile://"}) Q_GLOBAL_STATIC_WITH_ARGS(QString, linkSep, {"::"}) QString OutputLineParser::createLinkTarget(const FilePath &filePath, int line = -1, int column = -1) { return *linkPrefix() + filePath.toUrlishString() + *linkSep() + QString::number(line) + *linkSep() + QString::number(column); } bool OutputLineParser::isLinkTarget(const QString &target) { return target.startsWith(*linkPrefix()); } Link OutputLineParser::parseLinkTarget(const QString &target) { const QStringList parts = target.mid(linkPrefix()->size()).split(*linkSep()); if (parts.isEmpty()) return {}; return Link(FilePath::fromString(parts.first()), parts.length() > 1 ? parts.at(1).toInt() : 0, parts.length() > 2 ? parts.at(2).toInt() - 1 : 0); } // The redirection mechanism is needed for broken build tools (e.g. xcodebuild) that get invoked // indirectly as part of the build process and redirect their child processes' stderr output // to stdout. A parser might be able to detect this condition and inform interested // other parsers that they need to interpret stdout data as stderr. void OutputLineParser::setRedirectionDetector(const OutputLineParser *detector) { d->redirectionDetector = detector; } bool OutputLineParser::needsRedirection() const { return d->redirectionDetector && (d->redirectionDetector->hasDetectedRedirection() || d->redirectionDetector->needsRedirection()); } void OutputLineParser::addSearchDir(const FilePath &dir) { d->searchDirs << dir; } void OutputLineParser::dropSearchDir(const FilePath &dir) { const int idx = d->searchDirs.lastIndexOf(dir); // TODO: This apparently triggers. Find out why and either remove the assertion (if it's legit) // or fix the culprit. QTC_ASSERT(idx != -1, return); d->searchDirs.removeAt(idx); } const FilePaths OutputLineParser::searchDirectories() const { return d->searchDirs; } void OutputLineParser::setFileFinder(FileInProjectFinder *finder) { d->fileFinder = finder; } void OutputLineParser::setDemoteErrorsToWarnings(bool demote) { d->demoteErrorsToWarnings = demote; } bool OutputLineParser::demoteErrorsToWarnings() const { return d->demoteErrorsToWarnings; } FilePath OutputLineParser::absoluteFilePath(const FilePath &filePath) const { if (filePath.isEmpty()) return filePath; if (filePath.isAbsolutePath()) return filePath.cleanPath(); FilePaths candidates; for (const FilePath &dir : searchDirectories()) { FilePath candidate = dir.resolvePath(filePath); if (candidate.exists() || d->skipFileExistsCheck) { candidate = candidate.cleanPath(); if (!candidates.contains(candidate)) candidates << candidate; } } if (candidates.count() == 1) return candidates.first(); QString fp = filePath.toUrlishString(); while (fp.startsWith("../")) fp.remove(0, 3); bool found = false; candidates = d->fileFinder->findFile(QUrl::fromLocalFile(fp), &found); if (found && candidates.size() == 1) return candidates.first(); return filePath; } void OutputLineParser::addLinkSpecForAbsoluteFilePath( OutputLineParser::LinkSpecs &linkSpecs, const FilePath &filePath, int lineNo, int column, int pos, int len) { if (filePath.isAbsolutePath()) linkSpecs.append({pos, len, createLinkTarget(filePath, lineNo, column)}); } void OutputLineParser::addLinkSpecForAbsoluteFilePath( OutputLineParser::LinkSpecs &linkSpecs, const FilePath &filePath, int lineNo, int column, const QRegularExpressionMatch &match, int capIndex) { addLinkSpecForAbsoluteFilePath(linkSpecs, filePath, lineNo, column, match.capturedStart(capIndex), match.capturedLength(capIndex)); } void OutputLineParser::addLinkSpecForAbsoluteFilePath( OutputLineParser::LinkSpecs &linkSpecs, const FilePath &filePath, int lineNo, int column, const QRegularExpressionMatch &match, const QString &capName) { addLinkSpecForAbsoluteFilePath(linkSpecs, filePath, lineNo, column, match.capturedStart(capName), match.capturedLength(capName)); } bool Utils::OutputLineParser::fileExists(const FilePath &fp) const { #ifdef WITH_TESTS if (d->skipFileExistsCheck) return !fp.isEmpty(); #endif return fp.exists(); } QString OutputLineParser::rightTrimmed(const QString &in) { int pos = in.size(); for (; pos > 0; --pos) { if (!in.at(pos - 1).isSpace()) break; } return in.mid(0, pos); } #ifdef WITH_TESTS void OutputLineParser::skipFileExistsCheck() { d->skipFileExistsCheck = true; } #endif class OutputFormatter::Private { public: QPlainTextEdit *plainTextEdit = nullptr; QTextCharFormat formats[NumberOfFormats]; QTextCursor cursor; AnsiEscapeCodeHandler escapeCodeHandler; QPair incompleteLine; std::optional formatOverride; QList lineParsers; OutputLineParser *nextParser = nullptr; FileInProjectFinder fileFinder; PostPrintAction postPrintAction; bool boldFontEnabled = true; bool prependCarriageReturn = false; bool forwardStdOutToStdError = false; QColor explicitBackground; }; OutputFormatter::OutputFormatter() : d(new Private) { } OutputFormatter::~OutputFormatter() { qDeleteAll(d->lineParsers); delete d; } QPlainTextEdit *OutputFormatter::plainTextEdit() const { return d->plainTextEdit; } void OutputFormatter::setPlainTextEdit(QPlainTextEdit *plainText) { d->plainTextEdit = plainText; d->cursor = plainText ? plainText->textCursor() : QTextCursor(); d->cursor.movePosition(QTextCursor::End); initFormats(); } void OutputFormatter::setLineParsers(const QList &parsers) { flush(); qDeleteAll(d->lineParsers); d->lineParsers.clear(); d->nextParser = nullptr; addLineParsers(parsers); } void OutputFormatter::addLineParsers(const QList &parsers) { for (OutputLineParser * const p : std::as_const(parsers)) addLineParser(p); } void OutputFormatter::addLineParser(OutputLineParser *parser) { setupLineParser(parser); d->lineParsers << parser; } void OutputFormatter::setupLineParser(OutputLineParser *parser) { parser->setFileFinder(&d->fileFinder); connect(parser, &OutputLineParser::newSearchDirFound, this, &OutputFormatter::addSearchDir); connect(parser, &OutputLineParser::searchDirExpired, this, &OutputFormatter::dropSearchDir); } void OutputFormatter::setFileFinder(const FileInProjectFinder &finder) { d->fileFinder = finder; } void OutputFormatter::setDemoteErrorsToWarnings(bool demote) { for (OutputLineParser * const p : std::as_const(d->lineParsers)) p->setDemoteErrorsToWarnings(demote); } void OutputFormatter::overridePostPrintAction(const PostPrintAction &postPrintAction) { d->postPrintAction = postPrintAction; } static void checkAndFineTuneColors(QTextCharFormat *format, const QColor &background) { QTC_ASSERT(format, return); const QColor bgColor = background.isValid() ? background : (format->hasProperty(QTextCharFormat::BackgroundBrush) ? format->background().color() : Utils::creatorColor(Theme::PaletteBase)); const QColor fgColor = StyleHelper::ensureReadableOn(bgColor, format->foreground().color()); format->setForeground(fgColor); } void OutputFormatter::doAppendMessage(const QString &text, OutputFormat format, LineStatus lineStatus) { QTextCharFormat charFmt = charFormat(format); const auto addNewlineIfApplicable = [&] { if (lineStatus == LineStatus::Complete) append("\n", charFmt); }; QList formattedText = parseAnsi(text, charFmt); const QString cleanLine = std::accumulate(formattedText.begin(), formattedText.end(), QString(), [](const FormattedText &t1, const FormattedText &t2) -> QString { return t1.text + t2.text; }); QList involvedParsers; const OutputLineParser::Result res = handleMessage(cleanLine, format, involvedParsers); // If the line was recognized by a parser and a redirection was detected for that parser, // then our formatting should reflect that redirection as well, i.e. print in red // even if the nominal format is stdout. if (!involvedParsers.isEmpty()) { const OutputFormat formatForParser = res.formatOverride ? *res.formatOverride : outputTypeForParser(involvedParsers.last(), format); if (formatForParser != format && cleanLine == text && formattedText.length() == 1) { charFmt = charFormat(formatForParser); checkAndFineTuneColors(&charFmt, d->explicitBackground); formattedText.first().format = charFmt; } } if (res.newContent) { append(*res.newContent, charFmt); addNewlineIfApplicable(); return; } const QList linkified = linkifiedText(formattedText, res.linkSpecs); for (FormattedText output : linkified) { checkAndFineTuneColors(&output.format, d->explicitBackground); append(output.text, output.format); charFmt = output.format; } addNewlineIfApplicable(); for (OutputLineParser * const p : std::as_const(involvedParsers)) { if (d->postPrintAction) d->postPrintAction(p); else p->runPostPrintActions(plainTextEdit()); } } OutputLineParser::Result OutputFormatter::handleMessage(const QString &text, OutputFormat format, QList &involvedParsers) { // We only invoke the line parsers for stdout and stderr // Bad: on Windows we may get stdout and stdErr only as DebugFormat as e.g. GUI applications // print them Windows-internal and we retrieve this separately if (format != StdOutFormat && format != StdErrFormat && format != DebugFormat) return OutputLineParser::Status::NotHandled; const OutputLineParser * const oldNextParser = d->nextParser; if (d->nextParser) { involvedParsers << d->nextParser; const OutputLineParser::Result res = d->nextParser->handleLine(text, outputTypeForParser(d->nextParser, format)); switch (res.status) { case OutputLineParser::Status::Done: d->nextParser->flush(); d->nextParser = nullptr; return res; case OutputLineParser::Status::InProgress: return res; case OutputLineParser::Status::NotHandled: d->nextParser = nullptr; break; } } QTC_CHECK(!d->nextParser); for (OutputLineParser * const parser : std::as_const(d->lineParsers)) { if (parser == oldNextParser) // We tried that one already. continue; const OutputLineParser::Result res = parser->handleLine(text, outputTypeForParser(parser, format)); switch (res.status) { case OutputLineParser::Status::Done: parser->flush(); involvedParsers << parser; return res; case OutputLineParser::Status::InProgress: involvedParsers << parser; d->nextParser = parser; return res; case OutputLineParser::Status::NotHandled: break; } } return OutputLineParser::Status::NotHandled; } QTextCharFormat OutputFormatter::charFormat(OutputFormat format) const { return d->formatOverride ? d->formatOverride.value() : d->formats[format]; } QList OutputFormatter::parseAnsi(const QString &text, const QTextCharFormat &format) { return d->escapeCodeHandler.parseText(FormattedText(text, format)); } const QList OutputFormatter::linkifiedText( const QList &text, const OutputLineParser::LinkSpecs &linkSpecs) { if (linkSpecs.isEmpty()) return text; QList linkified; int totalTextLengthSoFar = 0; int nextLinkSpecIndex = 0; for (const FormattedText &t : text) { const int totalPreviousTextLength = totalTextLengthSoFar; // There is no more linkification work to be done. Just copy the text as-is. if (nextLinkSpecIndex >= linkSpecs.size()) { linkified << t; continue; } for (int nextLocalTextPos = 0; nextLocalTextPos < t.text.size(); ) { const auto copyRestOfSegmentAsIs = [&] { linkified << FormattedText(t.text.mid(nextLocalTextPos), t.format); totalTextLengthSoFar += t.text.size() - nextLocalTextPos; }; // We are out of links. if (nextLinkSpecIndex >= linkSpecs.size()) { copyRestOfSegmentAsIs(); break; } const OutputLineParser::LinkSpec &linkSpec = linkSpecs.at(nextLinkSpecIndex); const int localLinkStartPos = linkSpec.startPos - totalPreviousTextLength; // There are more links, but not in this segment. if (localLinkStartPos >= t.text.size()) { copyRestOfSegmentAsIs(); break; } ++nextLinkSpecIndex; // We ignore links that would cross format boundaries. if (localLinkStartPos < nextLocalTextPos || localLinkStartPos + linkSpec.length > t.text.size()) { copyRestOfSegmentAsIs(); break; } // Now we know we have a link that is fully inside this part of the text. // Split the text so that the link part gets the appropriate format. const int prefixLength = localLinkStartPos - nextLocalTextPos; const QString textBeforeLink = t.text.mid(nextLocalTextPos, prefixLength); linkified << FormattedText(textBeforeLink, t.format); const QString linkedText = t.text.mid(localLinkStartPos, linkSpec.length); linkified << FormattedText(linkedText, linkFormat(t.format, linkSpec.target)); nextLocalTextPos = localLinkStartPos + linkSpec.length; totalTextLengthSoFar += prefixLength + linkSpec.length; } } return linkified; } void OutputFormatter::append(const QString &text, const QTextCharFormat &format) { if (!plainTextEdit()) return; int startPos = 0; int crPos = -1; while ((crPos = text.indexOf('\r', startPos)) >= 0) { d->cursor.insertText(text.mid(startPos, crPos - startPos), format); d->cursor.clearSelection(); d->cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor); startPos = crPos + 1; } if (startPos < text.size()) d->cursor.insertText(text.mid(startPos), format); } QTextCharFormat OutputFormatter::linkFormat(const QTextCharFormat &inputFormat, const QString &href) { QTextCharFormat result = inputFormat; result.setForeground(creatorColor(Theme::TextColorLink)); result.setUnderlineStyle(QTextCharFormat::SingleUnderline); result.setAnchor(true); result.setAnchorHref(href); return result; } #ifdef WITH_TESTS void OutputFormatter::overrideTextCharFormat(const QTextCharFormat &fmt) { d->formatOverride = fmt; } QList OutputFormatter::lineParsers() const { return d->lineParsers; } // Handles all lines starting with "A" and the following ones up to and including the next // one starting with "A". class TestFormatterA : public OutputLineParser { private: Result handleLine(const QString &text, OutputFormat) override { static const QString replacement = "handled by A"; if (m_handling) { if (text.startsWith("A")) { m_handling = false; return {Status::Done, {}, replacement}; } return {Status::InProgress, {}, replacement}; } if (text.startsWith("A")) { m_handling = true; return {Status::InProgress, {}, replacement}; } return Status::NotHandled; } bool m_handling = false; }; // Handles all lines starting with "B". No continuation logic. class TestFormatterB : public OutputLineParser { private: Result handleLine(const QString &text, OutputFormat) override { if (text.startsWith("B")) return {Status::Done, {}, QString("handled by B")}; return Status::NotHandled; } }; class OutputFormatterTest final : public QObject { Q_OBJECT private slots: void testOutputFormatter(); }; void OutputFormatterTest::testOutputFormatter() { const QString input = "B to be handled by B\r\r\n" "not to be handled\n\n\n\n" "A to be handled by A\n" "continuation for A\r\n" "B looks like B, but still continuation for A\r\n" "A end of A\n" "A next A\n" "A end of next A\n" " A trick\r\n" "line with \r embedded carriage return\n" "B to be handled by B\n"; const QString output = "handled by B\n" "not to be handled\n\n\n\n" "handled by A\n" "handled by A\n" "handled by A\n" "handled by A\n" "handled by A\n" "handled by A\n" " A trick\n" " embedded carriage return\n" "handled by B\n"; // Stress-test the implementation by providing the input in chunks, splitting at all possible // offsets. for (int i = 0; i < input.size(); ++i) { OutputFormatter formatter; QPlainTextEdit textEdit; formatter.setPlainTextEdit(&textEdit); formatter.setLineParsers({new TestFormatterB, new TestFormatterA}); formatter.appendMessage(input.left(i), StdOutFormat); formatter.appendMessage(input.mid(i), StdOutFormat); formatter.flush(); QCOMPARE(textEdit.toPlainText(), output); } } QObject *createOutputFormatterTest() { return new OutputFormatterTest; } #endif // WITH_TESTS void OutputFormatter::clearLastLine() { // Note that this approach will fail if the text edit is not read-only and users // have messed with the last line between programmatic inputs. // We live with this risk, as all the alternatives are worse. if (!d->cursor.atEnd()) d->cursor.movePosition(QTextCursor::End); d->cursor.movePosition(QTextCursor::StartOfBlock, QTextCursor::KeepAnchor); d->cursor.removeSelectedText(); } void OutputFormatter::initFormats() { if (!plainTextEdit()) return; d->formats[NormalMessageFormat].setForeground(creatorColor(Theme::OutputPanes_NormalMessageTextColor)); d->formats[ErrorMessageFormat].setForeground(creatorColor(Theme::OutputPanes_ErrorMessageTextColor)); d->formats[LogMessageFormat].setForeground(creatorColor(Theme::OutputPanes_WarningMessageTextColor)); d->formats[StdOutFormat].setForeground(creatorColor(Theme::OutputPanes_StdOutTextColor)); d->formats[StdErrFormat].setForeground(creatorColor(Theme::OutputPanes_StdErrTextColor)); d->formats[DebugFormat].setForeground(creatorColor(Theme::OutputPanes_DebugTextColor)); d->formats[GeneralMessageFormat].setForeground(creatorColor(Theme::OutputPanes_DebugTextColor)); setBoldFontEnabled(d->boldFontEnabled); } void OutputFormatter::flushIncompleteLine() { clearLastLine(); doAppendMessage(d->incompleteLine.first, d->incompleteLine.second, LineStatus::Incomplete); d->incompleteLine.first.clear(); } void OutputFormatter::dumpIncompleteLine(const QString &line, OutputFormat format) { if (line.isEmpty()) return; append(line, charFormat(format)); d->incompleteLine.first.append(line); d->incompleteLine.second = format; } bool OutputFormatter::handleFileLink(const QString &href) { if (!OutputLineParser::isLinkTarget(href)) return false; Link link = OutputLineParser::parseLinkTarget(href); QTC_ASSERT(!link.targetFilePath.isEmpty(), return false); emit openInEditorRequested(link); return true; } void OutputFormatter::handleLink(const QString &href) { QTC_ASSERT(!href.isEmpty(), return); // We can handle absolute file paths ourselves. Other types of references are forwarded // to the line parsers. if (handleFileLink(href)) return; for (OutputLineParser * const f : std::as_const(d->lineParsers)) { if (f->handleLink(href)) return; } } void OutputFormatter::clear() { if (plainTextEdit()) plainTextEdit()->clear(); } void OutputFormatter::reset() { d->prependCarriageReturn = false; d->incompleteLine.first.clear(); d->nextParser = nullptr; qDeleteAll(d->lineParsers); d->lineParsers.clear(); d->fileFinder = FileInProjectFinder(); d->formatOverride.reset(); d->escapeCodeHandler = AnsiEscapeCodeHandler(); } void OutputFormatter::setBoldFontEnabled(bool enabled) { d->boldFontEnabled = enabled; const QFont::Weight fontWeight = enabled ? QFont::Bold : QFont::Normal; d->formats[NormalMessageFormat].setFontWeight(fontWeight); d->formats[ErrorMessageFormat].setFontWeight(fontWeight); } void OutputFormatter::setForwardStdOutToStdError(bool enabled) { d->forwardStdOutToStdError = enabled; } void Utils::OutputFormatter::setExplicitBackgroundColor(const QColor &color) { d->explicitBackground = color; } void OutputFormatter::flush() { if (!d->incompleteLine.first.isEmpty()) flushIncompleteLine(); d->escapeCodeHandler.endFormatScope(); for (OutputLineParser * const p : std::as_const(d->lineParsers)) p->flush(); if (d->nextParser) d->nextParser->runPostPrintActions(plainTextEdit()); } bool OutputFormatter::hasFatalErrors() const { return anyOf(d->lineParsers, [](const OutputLineParser *p) { return p->hasFatalErrors(); }); } void OutputFormatter::addSearchDir(const FilePath &dir) { for (OutputLineParser * const p : std::as_const(d->lineParsers)) p->addSearchDir(dir); } void OutputFormatter::dropSearchDir(const FilePath &dir) { for (OutputLineParser * const p : std::as_const(d->lineParsers)) p->dropSearchDir(dir); } OutputFormat OutputFormatter::outputTypeForParser(const OutputLineParser *parser, OutputFormat type) const { if (type == StdOutFormat && (parser->needsRedirection() || d->forwardStdOutToStdError)) return StdErrFormat; return type; } void OutputFormatter::appendMessage(const QString &text, OutputFormat format) { if (text.isEmpty()) return; // If we have an existing incomplete line and its format is different from this one, // then we consider the two messages unrelated. We re-insert the previous incomplete line, // possibly formatted now, and start from scratch with the new input. if (!d->incompleteLine.first.isEmpty() && d->incompleteLine.second != format) flushIncompleteLine(); QString out = text; if (d->prependCarriageReturn) { d->prependCarriageReturn = false; out.prepend('\r'); } out = Utils::normalizeNewlines(out); if (out.endsWith('\r')) { d->prependCarriageReturn = true; out.chop(1); } // If the input is a single incomplete line, we do not forward it to the specialized // formatting code, but simply dump it as-is. Once it becomes complete or it needs to // be flushed for other reasons, we remove the unformatted part and re-insert it, this // time with proper formatting. if (!out.contains('\n')) { dumpIncompleteLine(out, format); return; } // We have at least one complete line, so let's remove the previously dumped // incomplete line and prepend it to the first line of our new input. if (!d->incompleteLine.first.isEmpty()) { clearLastLine(); out.prepend(d->incompleteLine.first); d->incompleteLine.first.clear(); } // Forward all complete lines to the specialized formatting code, and handle a // potential trailing incomplete line the same way as above. d->cursor.beginEditBlock(); for (int startPos = 0; ;) { const int eolPos = out.indexOf('\n', startPos); if (eolPos == -1) { dumpIncompleteLine(out.mid(startPos), format); break; } doAppendMessage(out.mid(startPos, eolPos - startPos), format, LineStatus::Complete); startPos = eolPos + 1; } d->cursor.endEditBlock(); } } // namespace Utils #include "outputformatter.moc"