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"