Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
47 changes: 43 additions & 4 deletions src/QRegexSearch.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
25 changes: 25 additions & 0 deletions tests/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -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)
93 changes: 93 additions & 0 deletions tests/tst_QRegexSearch.cpp
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/

#include "QRegexSearch.h"

#include <QTest>

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"
Loading