Replace New Tab Menu Match Profiles functionality with regex support (#18654)

## Summary of the Pull Request
Updates the New Tab Menu's Match Profiles entry to support regex instead
of doing a direct match. Also adds validation to ensure the regex is
valid. Updated the UI to help make it more clear that this supports
regexes and even added a link to some helpful docs.

## Validation Steps Performed
 Invalid regex displays a warning
 Valid regex works nicely
 profile matcher with source=`Windows.Terminal.VisualStudio` still
works as expected

## PR Checklist
Closes #18553
This commit is contained in:
Carlos Zamora 2025-05-14 10:30:05 -07:00 committed by GitHub
parent 4d094df508
commit 4d67453c02
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 160 additions and 46 deletions

View File

@ -400,17 +400,6 @@ Microsoft::Console::ICU::unique_utext Microsoft::Console::ICU::UTextFromTextBuff
return ut;
}
Microsoft::Console::ICU::unique_uregex Microsoft::Console::ICU::CreateRegex(const std::wstring_view& pattern, uint32_t flags, UErrorCode* status) noexcept
{
#pragma warning(suppress : 26490) // Don't use reinterpret_cast (type.1).
const auto re = uregex_open(reinterpret_cast<const char16_t*>(pattern.data()), gsl::narrow_cast<int32_t>(pattern.size()), flags, nullptr, status);
// ICU describes the time unit as being dependent on CPU performance and "typically [in] the order of milliseconds",
// but this claim seems highly outdated already. On my CPU from 2021, a limit of 4096 equals roughly 600ms.
uregex_setTimeLimit(re, 4096, status);
uregex_setStackLimit(re, 4 * 1024 * 1024, status);
return unique_uregex{ re };
}
// Returns a half-open [beg,end) range given a text start and end position.
// This function is designed to be used with uregex_start64/uregex_end64.
til::point_span Microsoft::Console::ICU::BufferRangeFromMatch(UText* ut, URegularExpression* re)

View File

@ -9,10 +9,8 @@ class TextBuffer;
namespace Microsoft::Console::ICU
{
using unique_uregex = wistd::unique_ptr<URegularExpression, wil::function_deleter<decltype(&uregex_close), &uregex_close>>;
using unique_utext = wil::unique_struct<UText, decltype(&utext_close), &utext_close>;
unique_utext UTextFromTextBuffer(const TextBuffer& textBuffer, til::CoordType rowBeg, til::CoordType rowEnd) noexcept;
unique_uregex CreateRegex(const std::wstring_view& pattern, uint32_t flags, UErrorCode* status) noexcept;
til::point_span BufferRangeFromMatch(UText* ut, URegularExpression* re);
}

View File

@ -10,6 +10,7 @@
#include "../../types/inc/CodepointWidthDetector.hpp"
#include "../renderer/base/renderer.hpp"
#include "../types/inc/utils.hpp"
#include <til/regex.h>
#include "search.h"
// BODGY: Misdiagnosis in MSVC 17.11: Referencing global constants in the member
@ -3353,7 +3354,7 @@ std::optional<std::vector<til::point_span>> TextBuffer::SearchText(const std::ws
}
UErrorCode status = U_ZERO_ERROR;
const auto re = ICU::CreateRegex(needle, icuFlags, &status);
const auto re = til::ICU::CreateRegex(needle, icuFlags, &status);
if (status > U_ZERO_ERROR)
{
return std::nullopt;

View File

@ -944,4 +944,7 @@
<data name="TabMoveRight" xml:space="preserve">
<value>Move right</value>
</data>
<data name="InvalidRegex" xml:space="preserve">
<value>An invalid regex was found.</value>
</data>
</root>

View File

@ -55,6 +55,7 @@ static const std::array settingsLoadWarningsLabels{
USES_RESOURCE(L"UnknownTheme"),
USES_RESOURCE(L"DuplicateRemainingProfilesEntry"),
USES_RESOURCE(L"InvalidUseOfContent"),
USES_RESOURCE(L"InvalidRegex"),
};
static_assert(settingsLoadWarningsLabels.size() == static_cast<size_t>(SettingsLoadWarnings::WARNINGS_SIZE));

View File

@ -12,6 +12,7 @@
#include "../../buffer/out/UTextAdapter.h"
#include <til/hash.h>
#include <til/regex.h>
#include <winrt/Microsoft.Terminal.Core.h>
using namespace winrt::Microsoft::Terminal::Core;
@ -1375,7 +1376,7 @@ struct URegularExpressionInterner
//
// An alternative approach would be to not make this method thread-safe and give each
// Terminal instance its own cache. I'm not sure which approach would have been better.
ICU::unique_uregex Intern(const std::wstring_view& pattern)
til::ICU::unique_uregex Intern(const std::wstring_view& pattern)
{
UErrorCode status = U_ZERO_ERROR;
@ -1383,14 +1384,14 @@ struct URegularExpressionInterner
const auto guard = _lock.lock_shared();
if (const auto it = _cache.find(pattern); it != _cache.end())
{
return ICU::unique_uregex{ uregex_clone(it->second.re.get(), &status) };
return til::ICU::unique_uregex{ uregex_clone(it->second.re.get(), &status) };
}
}
// Even if the URegularExpression creation failed, we'll insert it into the cache, because there's no point in retrying.
// (Apart from OOM but in that case this application will crash anyways in 3.. 2.. 1..)
auto re = ICU::CreateRegex(pattern, 0, &status);
ICU::unique_uregex clone{ uregex_clone(re.get(), &status) };
auto re = til::ICU::CreateRegex(pattern, 0, &status);
til::ICU::unique_uregex clone{ uregex_clone(re.get(), &status) };
std::wstring key{ pattern };
const auto guard = _lock.lock_exclusive();
@ -1412,7 +1413,7 @@ struct URegularExpressionInterner
private:
struct CacheValue
{
ICU::unique_uregex re;
til::ICU::unique_uregex re;
size_t generation = 0;
};

View File

@ -449,6 +449,8 @@
FontIconGlyph="&#xE748;"
Style="{StaticResource ExpanderSettingContainerStyleWithIcon}">
<StackPanel Spacing="10">
<HyperlinkButton x:Uid="NewTabMenu_AddMatchProfiles_Help"
NavigateUri="https://learn.microsoft.com/en-us/dotnet/standard/base-types/regular-expression-language-quick-reference" />
<TextBox x:Uid="NewTabMenu_AddMatchProfiles_Name"
Text="{x:Bind ViewModel.ProfileMatcherName, Mode=TwoWay}" />
<TextBox x:Uid="NewTabMenu_AddMatchProfiles_Source"

View File

@ -2105,7 +2105,7 @@
<comment>Header for a control that adds any remaining profiles to the new tab menu.</comment>
</data>
<data name="NewTabMenu_AddMatchProfiles.HelpText" xml:space="preserve">
<value>Add a group of profiles that match at least one of the defined properties</value>
<value>Add a group of profiles that match at least one of the defined regex properties</value>
<comment>Additional information for a control that adds a terminal profile matcher to the new tab menu. Presented near "NewTabMenu_AddMatchProfiles".</comment>
</data>
<data name="NewTabMenu_AddRemainingProfiles.HelpText" xml:space="preserve">
@ -2121,15 +2121,15 @@
<comment>Header for a control that adds a folder to the new tab menu.</comment>
</data>
<data name="NewTabMenu_AddMatchProfiles_Name.Header" xml:space="preserve">
<value>Profile name</value>
<value>Profile name (Regex)</value>
<comment>Header for a text box used to define a regex for the names of profiles to add.</comment>
</data>
<data name="NewTabMenu_AddMatchProfiles_Source.Header" xml:space="preserve">
<value>Profile source</value>
<value>Profile source (Regex)</value>
<comment>Header for a text box used to define a regex for the sources of profiles to add.</comment>
</data>
<data name="NewTabMenu_AddMatchProfiles_Commandline.Header" xml:space="preserve">
<value>Commandline</value>
<value>Commandline (Regex)</value>
<comment>Header for a text box used to define a regex for the commandlines of profiles to add.</comment>
</data>
<data name="NewTabMenu_AddMatchProfilesTextBlock.Text" xml:space="preserve">
@ -2344,6 +2344,9 @@
<value>This option is managed by enterprise policy and cannot be changed here.</value>
<comment>This is displayed in concordance with Globals_StartOnUserLogin if the enterprise administrator has taken control of this setting.</comment>
</data>
<data name="NewTabMenu_AddMatchProfiles_Help.Content" xml:space="preserve">
<value>Learn more about regular expressions</value>
</data>
<data name="Appearance_BackgroundImageNone" xml:space="preserve">
<value>None</value>
<comment>Text displayed when the background image path is not defined.</comment>

View File

@ -4,6 +4,7 @@
#include "pch.h"
#include "CascadiaSettings.h"
#include "CascadiaSettings.g.cpp"
#include "MatchProfilesEntry.h"
#include "DefaultTerminal.h"
#include "FileUtils.h"
@ -429,6 +430,7 @@ void CascadiaSettings::_validateSettings()
_validateColorSchemesInCommands();
_validateThemeExists();
_validateProfileEnvironmentVariables();
_validateRegexes();
}
// Method Description:
@ -583,6 +585,41 @@ void CascadiaSettings::_validateProfileEnvironmentVariables()
}
}
// Returns true if all regexes in the new tab menu are valid, false otherwise
static bool _validateNTMEntries(const IVector<Model::NewTabMenuEntry>& entries)
{
if (!entries)
{
return true;
}
for (const auto& ntmEntry : entries)
{
if (const auto& folderEntry = ntmEntry.try_as<Model::FolderEntry>())
{
if (!_validateNTMEntries(folderEntry.RawEntries()))
{
return false;
}
}
if (const auto& matchProfilesEntry = ntmEntry.try_as<Model::MatchProfilesEntry>())
{
if (!winrt::get_self<Model::implementation::MatchProfilesEntry>(matchProfilesEntry)->ValidateRegexes())
{
return false;
}
}
}
return true;
}
void CascadiaSettings::_validateRegexes()
{
if (!_validateNTMEntries(_globals->NewTabMenu()))
{
_warnings.Append(SettingsLoadWarnings::InvalidRegex);
}
}
// Method Description:
// - Helper to get the GUID of a profile, given an optional index and a possible
// "profile" value to override that.

View File

@ -175,6 +175,7 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
void _validateColorSchemesInCommands() const;
bool _hasInvalidColorScheme(const Model::Command& command) const;
void _validateThemeExists();
void _validateRegexes();
void _researchOnLoad();

View File

@ -36,41 +36,71 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
auto entry = winrt::make_self<MatchProfilesEntry>();
JsonUtils::GetValueForKey(json, NameKey, entry->_Name);
entry->_validateName();
JsonUtils::GetValueForKey(json, CommandlineKey, entry->_Commandline);
entry->_validateCommandline();
JsonUtils::GetValueForKey(json, SourceKey, entry->_Source);
entry->_validateSource();
return entry;
}
// Returns true if all regexes are valid, false otherwise
bool MatchProfilesEntry::ValidateRegexes() const
{
return !(_invalidName || _invalidCommandline || _invalidSource);
}
#define DEFINE_VALIDATE_FUNCTION(name) \
void MatchProfilesEntry::_validate##name() noexcept \
{ \
_invalid##name = false; \
if (_##name.empty()) \
{ \
/* empty field is valid*/ \
return; \
} \
UErrorCode status = U_ZERO_ERROR; \
_##name##Regex = til::ICU::CreateRegex(_##name, 0, &status); \
if (U_FAILURE(status)) \
{ \
_invalid##name = true; \
_##name##Regex.reset(); \
} \
}
DEFINE_VALIDATE_FUNCTION(Name);
DEFINE_VALIDATE_FUNCTION(Commandline);
DEFINE_VALIDATE_FUNCTION(Source);
bool MatchProfilesEntry::MatchesProfile(const Model::Profile& profile)
{
// We use an optional here instead of a simple bool directly, since there is no
// sensible default value for the desired semantics: the first property we want
// to match on should always be applied (so one would set "true" as a default),
// but if none of the properties are set, the default return value should be false
// since this entry type is expected to behave like a positive match/whitelist.
//
// The easiest way to deal with this neatly is to use an optional, then for any
// property to match we consider a null value to be "true", and for the return
// value of the function we consider the null value to be "false".
auto isMatching = std::optional<bool>{};
auto isMatch = [](const til::ICU::unique_uregex& regex, std::wstring_view text) {
if (text.empty())
{
return false;
}
UErrorCode status = U_ZERO_ERROR;
uregex_setText(regex.get(), reinterpret_cast<const UChar*>(text.data()), static_cast<int32_t>(text.size()), &status);
const auto match = uregex_matches(regex.get(), 0, &status);
return status == U_ZERO_ERROR && match;
};
if (!_Name.empty())
if (!_Name.empty() && isMatch(_NameRegex, profile.Name()))
{
isMatching = { isMatching.value_or(true) && _Name == profile.Name() };
return true;
}
if (!_Source.empty())
else if (!_Source.empty() && isMatch(_SourceRegex, profile.Source()))
{
isMatching = { isMatching.value_or(true) && _Source == profile.Source() };
return true;
}
if (!_Commandline.empty())
else if (!_Commandline.empty() && isMatch(_CommandlineRegex, profile.Commandline()))
{
isMatching = { isMatching.value_or(true) && _Commandline == profile.Commandline() };
return true;
}
return isMatching.value_or(false);
return false;
}
Model::NewTabMenuEntry MatchProfilesEntry::Copy() const

View File

@ -17,6 +17,30 @@ Author(s):
#include "ProfileCollectionEntry.h"
#include "MatchProfilesEntry.g.h"
#include <til/regex.h>
// This macro defines the getter and setter for a regex property.
// The setter tries to instantiate the regex immediately and caches
// it if successful. If it fails, it sets a boolean flag to track that
// it failed.
#define DEFINE_MATCH_PROFILE_REGEX_PROPERTY(name) \
public: \
hstring name() const noexcept \
{ \
return _##name; \
} \
void name(const hstring& value) noexcept \
{ \
_##name = value; \
_validate##name(); \
} \
\
private: \
void _validate##name() noexcept; \
\
hstring _##name; \
til::ICU::unique_uregex _##name##Regex; \
bool _invalid##name{ false };
namespace winrt::Microsoft::Terminal::Settings::Model::implementation
{
@ -30,11 +54,12 @@ namespace winrt::Microsoft::Terminal::Settings::Model::implementation
Json::Value ToJson() const override;
static com_ptr<NewTabMenuEntry> FromJson(const Json::Value& json);
bool ValidateRegexes() const;
bool MatchesProfile(const Model::Profile& profile);
WINRT_PROPERTY(winrt::hstring, Name);
WINRT_PROPERTY(winrt::hstring, Commandline);
WINRT_PROPERTY(winrt::hstring, Source);
DEFINE_MATCH_PROFILE_REGEX_PROPERTY(Name)
DEFINE_MATCH_PROFILE_REGEX_PROPERTY(Commandline)
DEFINE_MATCH_PROFILE_REGEX_PROPERTY(Source)
};
}

View File

@ -25,6 +25,7 @@ namespace Microsoft.Terminal.Settings.Model
UnknownTheme,
DuplicateRemainingProfilesEntry,
InvalidUseOfContent,
InvalidRegex,
WARNINGS_SIZE // IMPORTANT: This MUST be the last value in this enum. It's an unused placeholder.
};

22
src/inc/til/regex.h Normal file
View File

@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
#pragma once
#include <icu.h>
namespace til::ICU // Terminal Implementation Library. Also: "Today I Learned"
{
using unique_uregex = wistd::unique_ptr<URegularExpression, wil::function_deleter<decltype(&uregex_close), &uregex_close>>;
_TIL_INLINEPREFIX unique_uregex CreateRegex(const std::wstring_view& pattern, uint32_t flags, UErrorCode* status) noexcept
{
#pragma warning(suppress : 26490) // Don't use reinterpret_cast (type.1).
const auto re = uregex_open(reinterpret_cast<const char16_t*>(pattern.data()), gsl::narrow_cast<int32_t>(pattern.size()), flags, nullptr, status);
// ICU describes the time unit as being dependent on CPU performance and "typically [in] the order of milliseconds",
// but this claim seems highly outdated already. On my CPU from 2021, a limit of 4096 equals roughly 600ms.
uregex_setTimeLimit(re, 4096, status);
uregex_setStackLimit(re, 4 * 1024 * 1024, status);
return unique_uregex{ re };
}
}