diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a4babcd6..5d1b48fad 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -169,6 +169,11 @@ jobs: shell: bash run: cmake --build build --parallel + - name: Run Tests + shell: bash + working-directory: build + run: ctest --output-on-failure + - name: Build Windows Targets if: matrix.platform == 'windows' run: cmake --build build --target package zip installer --parallel diff --git a/CMakeLists.txt b/CMakeLists.txt index f91212f71..0d43c9e80 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -47,4 +47,7 @@ set(CPM_SOURCE_CACHE "${CMAKE_SOURCE_DIR}/.cpm-cache" CACHE PATH "CPM source cac add_subdirectory(thirdparty) add_subdirectory(src) +enable_testing() +add_subdirectory(tests) + include(${CMAKE_SOURCE_DIR}/cmake/Packaging.cmake) diff --git a/src/QRegexSearch.cpp b/src/QRegexSearch.cpp index 96a98a8bc..c6d139776 100644 --- a/src/QRegexSearch.cpp +++ b/src/QRegexSearch.cpp @@ -112,16 +112,55 @@ const char *QRegexSearch::SubstituteByPosition(Document *doc, const char *text, Q_ASSERT(match.isValid()); Q_ASSERT(match.hasMatch()); - // Get the captured text and replace the match - QString newString = match.captured(); - newString.replace(match.regularExpression(), QByteArray(text, *length)); + // Build the replacement using the already-computed match's captured groups, rather than + // re-running the search pattern against just the matched text: that previous approach broke + // whenever the pattern relied on context outside the match itself (anchors, lookaround), and + // gave no way to reference the whole match, since Qt has no "$0"/"\0" token of its own here. + // + // \0-\9 refer to the whole match and capture groups 1-9, matching Notepad++'s own replace + // syntax. \n, \r, \t and \\ are also expanded, matching the "Extended" search mode's escapes, + // since users expect those to work in a regex replacement too. + QByteArray result; + + for (Sci::Position i = 0; i < *length; i++) { + if (text[i] == '\\' && i + 1 < *length) { + const char next = text[++i]; + + if (next >= '0' && next <= '9') { + result += match.captured(next - '0').toUtf8(); + } + else { + switch (next) { + case 'n': + result += '\n'; + break; + case 'r': + result += '\r'; + break; + case 't': + result += '\t'; + break; + case '\\': + result += '\\'; + break; + default: + result += '\\'; + result += next; + break; + } + } + } + else { + result += text[i]; + } + } // TODO: figure out why this has to be new'd and can't be an instantiated class member if (substituted) { delete substituted; } - substituted = new QByteArray(newString.toUtf8()); + substituted = new QByteArray(result); *length = substituted->length(); return substituted->data(); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 000000000..f8b7e991b --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,25 @@ +find_package(Qt6 COMPONENTS Test QUIET) + +if(NOT TARGET Qt6::Test) + message(STATUS "Qt6::Test not found, skipping tests") + return() +endif() + +qt_add_executable(tst_QRegexSearch + tst_QRegexSearch.cpp + ${CMAKE_SOURCE_DIR}/src/QRegexSearch.cpp +) + +target_include_directories(tst_QRegexSearch + PRIVATE + ${CMAKE_SOURCE_DIR}/src +) + +target_link_libraries(tst_QRegexSearch + PRIVATE + Qt6::Core + Qt6::Test + scintilla +) + +add_test(NAME tst_QRegexSearch COMMAND tst_QRegexSearch) diff --git a/tests/tst_QRegexSearch.cpp b/tests/tst_QRegexSearch.cpp new file mode 100644 index 000000000..f26e7c191 --- /dev/null +++ b/tests/tst_QRegexSearch.cpp @@ -0,0 +1,93 @@ +/* + * This file is part of Notepad Next. + * Copyright 2026 Notepad Next contributors + * + * Notepad Next is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * Notepad Next is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with Notepad Next. If not, see . + */ + +#include "QRegexSearch.h" + +#include + +using namespace Scintilla; +using namespace Scintilla::Internal; + +class tst_QRegexSearch : public QObject +{ + Q_OBJECT + +private slots: + void wholeMatchAndNewline(); + void captureGroupsAreReordered(); + void dollarSyntaxStaysLiteral(); + void lookbehindPattern(); + +private: + // Runs the same FindText()+SubstituteByPosition() sequence Scintilla performs for a regex + // "Replace", against the given document text, and returns the produced replacement bytes. + QByteArray substitute(const QString &docText, const QString &pattern, const QByteArray &replaceTemplate); +}; + +QByteArray tst_QRegexSearch::substitute(const QString &docText, const QString &pattern, const QByteArray &replaceTemplate) +{ + Document doc(DocumentOption::Default); + const QByteArray docBytes = docText.toUtf8(); + doc.InsertString(0, docBytes.constData(), docBytes.length()); + + const QByteArray patternBytes = pattern.toUtf8(); + + QRegexSearch search; + Sci::Position matchLength = 0; + const Sci::Position matchStart = search.FindText(&doc, 0, doc.Length(), patternBytes.constData(), + true, false, false, FindOption::RegExp, &matchLength); + + if (matchStart < 0) { + return QByteArray(); + } + + Sci::Position templateLength = replaceTemplate.length(); + const char *result = search.SubstituteByPosition(&doc, replaceTemplate.constData(), &templateLength); + + return QByteArray(result, templateLength); +} + +void tst_QRegexSearch::wholeMatchAndNewline() +{ + // \0 = whole match, \r\n = real CRLF: this is the correct Notepad++ syntax for the + // originally reported repro (Find "Example", Replace "$0\r\n", regex enabled). + QCOMPARE(substitute("Example", "Example", "\\0\\r\\n"), QByteArray("Example\r\n")); +} + +void tst_QRegexSearch::captureGroupsAreReordered() +{ + QCOMPARE(substitute("Example", "(Exa)(mple)", "\\2\\1"), QByteArray("mpleExa")); +} + +void tst_QRegexSearch::dollarSyntaxStaysLiteral() +{ + // $0 is not a supported backreference token (neither here nor in real Notepad++), so it + // must be left as literal text; \r and \n are still recognized independently of it. + QCOMPARE(substitute("Example", "Example", "$0\\r\\n"), QByteArray("$0\r\n")); +} + +void tst_QRegexSearch::lookbehindPattern() +{ + // The previous implementation re-ran the search pattern against the isolated matched + // substring, which broke any pattern relying on context outside the match itself. + QCOMPARE(substitute(" Example", "(?<=\\s)Example", "[\\0]"), QByteArray("[Example]")); +} + +QTEST_APPLESS_MAIN(tst_QRegexSearch) + +#include "tst_QRegexSearch.moc"