From d7383418537c73fd140dd1cd7b11f2dba17580ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:35:28 +0000 Subject: [PATCH 1/9] Initial plan From 0bcb2df940790205153bd4090db1706764588aa7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:48:54 +0000 Subject: [PATCH 2/9] Add snippet variables feature with configuration-based variable expansion (closes #83) Agent-Logs-Url: https://github.com/neptuo/Productivity.SnippetManager/sessions/2c520484-66d7-4527-8bf6-0796b2109012 Co-authored-by: maraf <10020471+maraf@users.noreply.github.com> --- README.md | 27 ++ src/Directory.Packages.props | 1 + .../App.axaml.cs | 5 +- .../Navigator.cs | 11 +- .../Views/DesignData/ViewModelLocator.cs | 11 +- .../Configuration.cs | 3 + .../Neptuo.Productivity.SnippetManager.csproj | 1 + .../CompositeVariableValueResolver.cs | 16 + .../ConfigurationVariableValueResolver.cs | 13 + .../Variables/ISnippetTextExpander.cs | 6 + .../Variables/ISnippetVariableScanner.cs | 6 + .../Variables/IVariableValueResolver.cs | 6 + .../Variables/SnippetExpansionPipeline.cs | 23 ++ .../Variables/TokenSnippetTextExpander.cs | 47 +++ .../Variables/TokenSnippetVariableScanner.cs | 35 +++ .../Variables/VariableReference.cs | 3 + .../Variables/VariablesConfiguration.cs | 9 + .../Commands/ApplySnippetCommand.cs | 5 +- .../ViewModels/Commands/CopySnippetCommand.cs | 5 +- .../CopySnippetCommandTests.cs | 13 +- .../SnippetVariablesTests.cs | 276 ++++++++++++++++++ 21 files changed, 510 insertions(+), 12 deletions(-) create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/CompositeVariableValueResolver.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/ConfigurationVariableValueResolver.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/IVariableValueResolver.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/VariableReference.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs create mode 100644 test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs diff --git a/README.md b/README.md index 122ea54..3c95aaa 100644 --- a/README.md +++ b/README.md @@ -85,3 +85,30 @@ Enables snippets you declare in XML syntax. The `FilePath` is a path to the root ### Snippets In addition to the XML snippets, you can declare some snippets using `Snippets` object. The probably main difference is that the XML is more readable for longer snippets and better escapes special characters in CDATA sections. Use these as you wish. + +### Variables + +The `Variables` section lets you declare named values that are substituted into snippet text at apply/copy time. This makes it easy to share snippets across machines or operating systems. + +**Syntax:** use `{VariableName}` placeholders inside any snippet text. To include a literal brace, escape it as `{{` or `}}`. + +**Configuration example:** + +```json +{ + "Variables": { + "ShellExt": "ps1" + }, + "Snippets": { + "Install": "Run install.{ShellExt} to get started." + } +} +``` + +On macOS/Linux you would set `"ShellExt": "sh"`, on Windows `"ShellExt": "ps1"`. The snippet text `"Run install.{ShellExt} to get started."` expands to the correct value at apply/copy time, while the snippet source stays unchanged. + +**Notes:** +- Variable names are matched exactly as declared (case-sensitive). +- Unknown tokens (variables not declared in `Variables`) are left verbatim in the expanded text. +- If a snippet text fails to parse (e.g. contains JSON-like `{...}` syntax), the text is returned unchanged. +- Snippet search always runs against the raw (unexpanded) template text. diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index a1fca79..7a314a6 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -3,6 +3,7 @@ 10.0.0 + diff --git a/src/Neptuo.Productivity.SnippetManager.Avalonia/App.axaml.cs b/src/Neptuo.Productivity.SnippetManager.Avalonia/App.axaml.cs index 56048f2..e07d84e 100644 --- a/src/Neptuo.Productivity.SnippetManager.Avalonia/App.axaml.cs +++ b/src/Neptuo.Productivity.SnippetManager.Avalonia/App.axaml.cs @@ -6,6 +6,7 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; +using Neptuo.Productivity.SnippetManager.Variables; namespace Neptuo.Productivity.SnippetManager; @@ -68,7 +69,8 @@ public override void OnFrameworkInitializationCompleted() enabled => configurationWatcher?.EnableRaisingEventsFromConfigurationWatcher(enabled), shutdown, GetExampleConfiguration, - GetCurrentHotkey + GetCurrentHotkey, + configuration.Variables ); private void RequestShutdown(IClassicDesktopStyleApplicationLifetime desktop) @@ -119,6 +121,7 @@ private Configuration GetExampleConfiguration() { var example = new Configuration(); example.General = GeneralConfiguration.Example; + example.Variables = VariablesConfiguration.Example; snippetProviders.AddExampleConfigurations(example.Providers); return example; } diff --git a/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs b/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs index e951984..04fac7e 100644 --- a/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs +++ b/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs @@ -8,6 +8,7 @@ using Avalonia.Media; using Avalonia.Threading; using Neptuo.Productivity.SnippetManager.Services; +using Neptuo.Productivity.SnippetManager.Variables; using Neptuo.Productivity.SnippetManager.ViewModels; using Neptuo.Productivity.SnippetManager.ViewModels.Commands; using Neptuo.Productivity.SnippetManager.Views; @@ -24,9 +25,10 @@ public class Navigator : IClipboardService, ISendTextService private readonly Func getExampleConfiguration; private readonly Func getCurrentHotkey; private readonly ConfigurationRepository configurationRepository; + private readonly SnippetExpansionPipeline expansionPipeline; private int? lastExternalProcessId; - public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository configurationRepository, Action setConfigChangeEnabled, Action shutdown, Func getExampleConfiguration, Func getCurrentHotkey) + public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository configurationRepository, Action setConfigChangeEnabled, Action shutdown, Func getExampleConfiguration, Func getCurrentHotkey, VariablesConfiguration? variables) { this.snippetProvider = snippetProvider; this.configurationRepository = configurationRepository; @@ -36,6 +38,11 @@ public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository confi this.getCurrentHotkey = getCurrentHotkey; this.snippetProviderContext = new(); this.snippetProviderContext.Changed += OnModelsChanged; + this.expansionPipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(variables), + new TokenSnippetTextExpander() + ); snippetProviderInitializeTask = snippetProvider.InitializeAsync(snippetProviderContext); } @@ -58,7 +65,7 @@ public void OpenMain(bool stickToActiveCaret = true) { main = new MainWindow(); main.Closed += (sender, e) => { main = null; }; - main.ViewModel = new MainViewModel(snippetProviderContext, new ApplySnippetCommand(this), new CopySnippetCommand(this)); + main.ViewModel = new MainViewModel(snippetProviderContext, new ApplySnippetCommand(this, expansionPipeline), new CopySnippetCommand(this, expansionPipeline)); UpdateWindowPositionAnchor(main, stickToActiveCaret); shouldRefreshSnippets = true; } diff --git a/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs b/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs index 5b20816..b26fdba 100644 --- a/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs +++ b/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs @@ -1,4 +1,5 @@ using Neptuo.Productivity.SnippetManager.Models; +using Neptuo.Productivity.SnippetManager.Variables; using Neptuo.Productivity.SnippetManager.ViewModels; using Neptuo.Productivity.SnippetManager.ViewModels.Commands; @@ -6,6 +7,12 @@ namespace Neptuo.Productivity.SnippetManager.Views.DesignData; internal class ViewModelLocator { + private static readonly SnippetExpansionPipeline emptyPipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(null), + new TokenSnippetTextExpander() + ); + private static MainViewModel? mainViewModel; public static MainViewModel MainViewModel { @@ -17,8 +24,8 @@ public static MainViewModel MainViewModel mainViewModel = new MainViewModel( tree, - new ApplySnippetCommand(InteropService.Instance), - new CopySnippetCommand(InteropService.Instance) + new ApplySnippetCommand(InteropService.Instance, emptyPipeline), + new CopySnippetCommand(InteropService.Instance, emptyPipeline) ); mainViewModel.Snippets.AddRange(tree.GetRoots()); diff --git a/src/Neptuo.Productivity.SnippetManager/Configuration.cs b/src/Neptuo.Productivity.SnippetManager/Configuration.cs index eecdd3f..cce5d3b 100644 --- a/src/Neptuo.Productivity.SnippetManager/Configuration.cs +++ b/src/Neptuo.Productivity.SnippetManager/Configuration.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using Neptuo.Productivity.SnippetManager.Variables; namespace Neptuo.Productivity.SnippetManager; @@ -6,6 +7,8 @@ public class Configuration { public GeneralConfiguration? General { get; set; } + public VariablesConfiguration? Variables { get; set; } + [JsonIgnore] public Dictionary Providers { get; } = new(); } diff --git a/src/Neptuo.Productivity.SnippetManager/Neptuo.Productivity.SnippetManager.csproj b/src/Neptuo.Productivity.SnippetManager/Neptuo.Productivity.SnippetManager.csproj index 7fbc6b2..ad7c2e0 100644 --- a/src/Neptuo.Productivity.SnippetManager/Neptuo.Productivity.SnippetManager.csproj +++ b/src/Neptuo.Productivity.SnippetManager/Neptuo.Productivity.SnippetManager.csproj @@ -1,6 +1,7 @@  + diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/CompositeVariableValueResolver.cs b/src/Neptuo.Productivity.SnippetManager/Variables/CompositeVariableValueResolver.cs new file mode 100644 index 0000000..e3956fd --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/CompositeVariableValueResolver.cs @@ -0,0 +1,16 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public class CompositeVariableValueResolver(IEnumerable resolvers) : IVariableValueResolver +{ + public bool TryGetValue(string name, out string? value) + { + foreach (var resolver in resolvers) + { + if (resolver.TryGetValue(name, out value)) + return true; + } + + value = null; + return false; + } +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/ConfigurationVariableValueResolver.cs b/src/Neptuo.Productivity.SnippetManager/Variables/ConfigurationVariableValueResolver.cs new file mode 100644 index 0000000..15f9a18 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/ConfigurationVariableValueResolver.cs @@ -0,0 +1,13 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public class ConfigurationVariableValueResolver(VariablesConfiguration? configuration) : IVariableValueResolver +{ + public bool TryGetValue(string name, out string? value) + { + if (configuration != null && configuration.TryGetValue(name, out value)) + return true; + + value = null; + return false; + } +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs new file mode 100644 index 0000000..b5822df --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs @@ -0,0 +1,6 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public interface ISnippetTextExpander +{ + string Expand(string text, IReadOnlyDictionary values); +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs new file mode 100644 index 0000000..342532a --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs @@ -0,0 +1,6 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public interface ISnippetVariableScanner +{ + IReadOnlyList Scan(string text); +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/IVariableValueResolver.cs b/src/Neptuo.Productivity.SnippetManager/Variables/IVariableValueResolver.cs new file mode 100644 index 0000000..57b7230 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/IVariableValueResolver.cs @@ -0,0 +1,6 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public interface IVariableValueResolver +{ + bool TryGetValue(string name, out string? value); +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs b/src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs new file mode 100644 index 0000000..b31a4dc --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs @@ -0,0 +1,23 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public class SnippetExpansionPipeline( + ISnippetVariableScanner scanner, + IVariableValueResolver resolver, + ISnippetTextExpander expander) +{ + public string Apply(string text) + { + var references = scanner.Scan(text); + if (references.Count == 0) + return text; + + var values = new Dictionary(); + foreach (var reference in references) + { + resolver.TryGetValue(reference.Name, out var value); + values[reference.Name] = value; + } + + return expander.Expand(text, values); + } +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs new file mode 100644 index 0000000..bc98f1d --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs @@ -0,0 +1,47 @@ +using System.Text; +using Neptuo.Text.Tokens; + +namespace Neptuo.Productivity.SnippetManager.Variables; + +public class TokenSnippetTextExpander : ISnippetTextExpander +{ + public string Expand(string text, IReadOnlyDictionary values) + { + var parser = CreateParser(); + var parsedTokens = new List(); + parser.OnParsedToken += (sender, e) => parsedTokens.Add(e); + + if (!parser.Parse(text) || parsedTokens.Count == 0) + return text; + + var sb = new StringBuilder(); + int lastEnd = 0; + + foreach (var tokenEvent in parsedTokens) + { + sb.Append(text, lastEnd, tokenEvent.StartPosition - lastEnd); + + if (values.TryGetValue(tokenEvent.Token.Fullname, out var value) && value != null) + sb.Append(value); + else + sb.Append(text, tokenEvent.StartPosition, tokenEvent.EndPosition - tokenEvent.StartPosition); + + lastEnd = tokenEvent.EndPosition; + } + + sb.Append(text, lastEnd, text.Length - lastEnd); + + return sb.ToString(); + } + + private static TokenParser CreateParser() + { + var parser = new TokenParser(); + parser.Configuration.AllowTextContent = true; + parser.Configuration.AllowMultipleTokens = true; + parser.Configuration.AllowEscapeSequence = true; + parser.Configuration.AllowAttributes = false; + parser.Configuration.AllowDefaultAttributes = false; + return parser; + } +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs new file mode 100644 index 0000000..714a400 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs @@ -0,0 +1,35 @@ +using Neptuo.Text.Tokens; + +namespace Neptuo.Productivity.SnippetManager.Variables; + +public class TokenSnippetVariableScanner : ISnippetVariableScanner +{ + public IReadOnlyList Scan(string text) + { + var parser = CreateParser(); + var names = new HashSet(); + var references = new List(); + + parser.OnParsedToken += (sender, e) => + { + if (names.Add(e.Token.Fullname)) + references.Add(new VariableReference(e.Token.Fullname)); + }; + + if (!parser.Parse(text)) + return Array.Empty(); + + return references; + } + + private static TokenParser CreateParser() + { + var parser = new TokenParser(); + parser.Configuration.AllowTextContent = true; + parser.Configuration.AllowMultipleTokens = true; + parser.Configuration.AllowEscapeSequence = true; + parser.Configuration.AllowAttributes = false; + parser.Configuration.AllowDefaultAttributes = false; + return parser; + } +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/VariableReference.cs b/src/Neptuo.Productivity.SnippetManager/Variables/VariableReference.cs new file mode 100644 index 0000000..d2ec8d0 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/VariableReference.cs @@ -0,0 +1,3 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public record VariableReference(string Name); diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs b/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs new file mode 100644 index 0000000..31b4cb1 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs @@ -0,0 +1,9 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public class VariablesConfiguration : Dictionary +{ + public static VariablesConfiguration Example => new() + { + ["ShellExt"] = "sh" + }; +} diff --git a/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/ApplySnippetCommand.cs b/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/ApplySnippetCommand.cs index f387efc..70a09a3 100644 --- a/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/ApplySnippetCommand.cs +++ b/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/ApplySnippetCommand.cs @@ -1,11 +1,12 @@ using Neptuo.Productivity.SnippetManager.Models; using Neptuo.Productivity.SnippetManager.Services; +using Neptuo.Productivity.SnippetManager.Variables; namespace Neptuo.Productivity.SnippetManager.ViewModels.Commands { - public class ApplySnippetCommand(ISendTextService sendText) : UseSnippetCommand + public class ApplySnippetCommand(ISendTextService sendText, SnippetExpansionPipeline pipeline) : UseSnippetCommand { public override void Execute(SnippetModel parameter) - => sendText.Send(parameter.Text); + => sendText.Send(pipeline.Apply(parameter.Text)); } } diff --git a/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/CopySnippetCommand.cs b/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/CopySnippetCommand.cs index 1a73b3b..3171f6c 100644 --- a/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/CopySnippetCommand.cs +++ b/src/Neptuo.Productivity.SnippetManager/ViewModels/Commands/CopySnippetCommand.cs @@ -1,11 +1,12 @@ using Neptuo.Productivity.SnippetManager.Models; using Neptuo.Productivity.SnippetManager.Services; +using Neptuo.Productivity.SnippetManager.Variables; namespace Neptuo.Productivity.SnippetManager.ViewModels.Commands { - public class CopySnippetCommand(IClipboardService clipboard) : UseSnippetCommand + public class CopySnippetCommand(IClipboardService clipboard, SnippetExpansionPipeline pipeline) : UseSnippetCommand { public override void Execute(SnippetModel parameter) - => clipboard.SetText(parameter.Text); + => clipboard.SetText(pipeline.Apply(parameter.Text)); } } diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs index 1044c2a..eb9f19a 100644 --- a/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs +++ b/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs @@ -1,16 +1,23 @@ using System.Windows.Input; using Neptuo.Productivity.SnippetManager.Models; using Neptuo.Productivity.SnippetManager.Services; +using Neptuo.Productivity.SnippetManager.Variables; using Neptuo.Productivity.SnippetManager.ViewModels.Commands; namespace Neptuo.Productivity.SnippetManager.Tests; public class CopySnippetCommandTests { + private static SnippetExpansionPipeline EmptyPipeline => new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(null), + new TokenSnippetTextExpander() + ); + [Fact] public void CopyCommand_CanExecute_ReturnsFalseForNullParameter() { - ICommand command = new CopySnippetCommand(new TestClipboardService()); + ICommand command = new CopySnippetCommand(new TestClipboardService(), EmptyPipeline); bool canExecute = command.CanExecute(null); @@ -20,7 +27,7 @@ public void CopyCommand_CanExecute_ReturnsFalseForNullParameter() [Fact] public void CopyCommand_CanExecute_ReturnsFalseForShadowSnippet() { - var command = new CopySnippetCommand(new TestClipboardService()); + var command = new CopySnippetCommand(new TestClipboardService(), EmptyPipeline); bool canExecute = command.CanExecute(new SnippetModel("GitHub")); @@ -30,7 +37,7 @@ public void CopyCommand_CanExecute_ReturnsFalseForShadowSnippet() [Fact] public void CopyCommand_CanExecute_ReturnsTrueForFilledSnippet() { - var command = new CopySnippetCommand(new TestClipboardService()); + var command = new CopySnippetCommand(new TestClipboardService(), EmptyPipeline); bool canExecute = command.CanExecute(new SnippetModel("GitHub - dotnet", "https://github.com/dotnet")); diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs new file mode 100644 index 0000000..996948c --- /dev/null +++ b/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs @@ -0,0 +1,276 @@ +using Neptuo.Productivity.SnippetManager.Variables; + +namespace Neptuo.Productivity.SnippetManager.Tests; + +public class SnippetVariablesTests +{ + #region Scanner tests + + [Fact] + public void Scanner_ReturnsEmptyForTextWithNoTokens() + { + var scanner = new TokenSnippetVariableScanner(); + var result = scanner.Scan("Hello, World!"); + Assert.Empty(result); + } + + [Fact] + public void Scanner_ReturnsEmptyForEmptyText() + { + var scanner = new TokenSnippetVariableScanner(); + var result = scanner.Scan(string.Empty); + Assert.Empty(result); + } + + [Fact] + public void Scanner_ReturnsDistinctNames() + { + var scanner = new TokenSnippetVariableScanner(); + var result = scanner.Scan("{A} and {A} and {B}"); + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.Name == "A"); + Assert.Contains(result, r => r.Name == "B"); + } + + [Fact] + public void Scanner_ReturnsSingleToken() + { + var scanner = new TokenSnippetVariableScanner(); + var result = scanner.Scan("Hello, {Name}!"); + Assert.Single(result); + Assert.Equal("Name", result[0].Name); + } + + [Fact] + public void Scanner_ReturnsEmptyForMalformedInput() + { + var scanner = new TokenSnippetVariableScanner(); + // JSON-like input causes parse failure + var result = scanner.Scan("{\"key\": \"value\"}"); + Assert.Empty(result); + } + + [Fact] + public void Scanner_ReturnsEmptyForAttributeToken() + { + var scanner = new TokenSnippetVariableScanner(); + // Attributes disabled → parse fails + var result = scanner.Scan("{Name key=value}"); + Assert.Empty(result); + } + + [Fact] + public void Scanner_DoesNotReturnEscapedTokens() + { + var scanner = new TokenSnippetVariableScanner(); + var result = scanner.Scan("{{escaped}} and {Real}"); + Assert.Single(result); + Assert.Equal("Real", result[0].Name); + } + + #endregion + + #region Expander tests + + [Fact] + public void Expander_ReplacesKnownToken() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary { ["ShellExt"] = "ps1" }; + var result = expander.Expand("install.{ShellExt}", values); + Assert.Equal("install.ps1", result); + } + + [Fact] + public void Expander_PassesThroughUnknownToken() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary(); + var result = expander.Expand("install.{OtherExt}", values); + Assert.Equal("install.{OtherExt}", result); + } + + [Fact] + public void Expander_PassesThroughNullValue() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary { ["ShellExt"] = null }; + var result = expander.Expand("install.{ShellExt}", values); + Assert.Equal("install.{ShellExt}", result); + } + + [Fact] + public void Expander_PreservesEscapeSequence() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary { ["ShellExt"] = "ps1" }; + var result = expander.Expand("{{literal}} and {ShellExt}", values); + Assert.Equal("{{literal}} and ps1", result); + } + + [Fact] + public void Expander_PreservesTextWithNoTokens() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary(); + var result = expander.Expand("Hello, World!", values); + Assert.Equal("Hello, World!", result); + } + + [Fact] + public void Expander_ReturnsMalformedInputUnchanged() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary(); + var result = expander.Expand("{\"key\": \"value\"}", values); + Assert.Equal("{\"key\": \"value\"}", result); + } + + [Fact] + public void Expander_IsCaseSensitive() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary { ["ShellExt"] = "ps1" }; + var result = expander.Expand("{shellext}", values); + // "shellext" is not the same as "ShellExt" → pass through + Assert.Equal("{shellext}", result); + } + + [Fact] + public void Expander_ReplacesMultipleTokens() + { + var expander = new TokenSnippetTextExpander(); + var values = new Dictionary { ["A"] = "hello", ["B"] = "world" }; + var result = expander.Expand("{A} and {B}", values); + Assert.Equal("hello and world", result); + } + + #endregion + + #region Resolver tests + + [Fact] + public void ConfigurationResolver_ReturnsTrueForKnownName() + { + var config = new VariablesConfiguration { ["ShellExt"] = "sh" }; + var resolver = new ConfigurationVariableValueResolver(config); + bool found = resolver.TryGetValue("ShellExt", out var value); + Assert.True(found); + Assert.Equal("sh", value); + } + + [Fact] + public void ConfigurationResolver_ReturnsFalseForUnknownName() + { + var config = new VariablesConfiguration { ["ShellExt"] = "sh" }; + var resolver = new ConfigurationVariableValueResolver(config); + bool found = resolver.TryGetValue("Other", out var value); + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public void ConfigurationResolver_ReturnsFalseForNullConfiguration() + { + var resolver = new ConfigurationVariableValueResolver(null); + bool found = resolver.TryGetValue("ShellExt", out var value); + Assert.False(found); + Assert.Null(value); + } + + [Fact] + public void CompositeResolver_UsesFirstMatchWins() + { + var first = new VariablesConfiguration { ["ShellExt"] = "sh" }; + var second = new VariablesConfiguration { ["ShellExt"] = "ps1", ["Other"] = "x" }; + var composite = new CompositeVariableValueResolver(new IVariableValueResolver[] + { + new ConfigurationVariableValueResolver(first), + new ConfigurationVariableValueResolver(second) + }); + + composite.TryGetValue("ShellExt", out var shellExt); + composite.TryGetValue("Other", out var other); + + Assert.Equal("sh", shellExt); // first wins + Assert.Equal("x", other); // only in second + } + + #endregion + + #region Pipeline end-to-end tests + + [Fact] + public void Pipeline_ExpandsVariables() + { + var config = new VariablesConfiguration { ["ShellExt"] = "ps1" }; + var pipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(config), + new TokenSnippetTextExpander() + ); + + var result = pipeline.Apply("Run install.{ShellExt}"); + Assert.Equal("Run install.ps1", result); + } + + [Fact] + public void Pipeline_PassesThroughTextWithNoTokens() + { + var pipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(null), + new TokenSnippetTextExpander() + ); + + var result = pipeline.Apply("Hello, World!"); + Assert.Equal("Hello, World!", result); + } + + [Fact] + public void Pipeline_PassesThroughUnknownTokens() + { + var config = new VariablesConfiguration { ["ShellExt"] = "ps1" }; + var pipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(config), + new TokenSnippetTextExpander() + ); + + var result = pipeline.Apply("install.{OtherExt}"); + Assert.Equal("install.{OtherExt}", result); + } + + [Fact] + public void Pipeline_DollarPrefixExpandsWhenVariableDefined() + { + // ${PATH} — the $ is plain text content before the {PATH} token. + // When PATH is defined, {PATH} expands and $ is preserved as-is, + // yielding "export $" + resolved-value = "export $/usr/local/bin". + var config = new VariablesConfiguration { ["PATH"] = "/usr/local/bin" }; + var pipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(config), + new TokenSnippetTextExpander() + ); + + var result = pipeline.Apply("export ${PATH}"); + Assert.Equal("export $/usr/local/bin", result); + } + + [Fact] + public void Pipeline_DollarPrefixPassesThroughWhenVariableNotDefined() + { + // ${PATH} — PATH not defined → pass through verbatim + var pipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(null), + new TokenSnippetTextExpander() + ); + + var result = pipeline.Apply("export ${PATH}"); + Assert.Equal("export ${PATH}", result); + } + + #endregion +} From cde2a8922d4b045558f3ad346c80be9c0ada5e5c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 18:23:38 +0000 Subject: [PATCH 3/9] Apply PR #124 review feedback: WPF wiring, README clarification, ApplySnippetCommand tests Agent-Logs-Url: https://github.com/neptuo/Productivity.SnippetManager/sessions/9d0e6b49-1143-4b85-9926-fed59d0a0154 Co-authored-by: maraf <10020471+maraf@users.noreply.github.com> --- README.md | 5 +- .../App.xaml.cs | 5 +- .../Navigator.cs | 11 ++- .../Views/DesignData/ViewModelLocator.cs | 11 ++- .../ApplySnippetCommandTests.cs | 71 +++++++++++++++++++ 5 files changed, 96 insertions(+), 7 deletions(-) create mode 100644 test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs diff --git a/README.md b/README.md index 3c95aaa..f86f049 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ In addition to the XML snippets, you can declare some snippets using `Snippets` The `Variables` section lets you declare named values that are substituted into snippet text at apply/copy time. This makes it easy to share snippets across machines or operating systems. -**Syntax:** use `{VariableName}` placeholders inside any snippet text. To include a literal brace, escape it as `{{` or `}}`. +**Syntax:** use `{VariableName}` placeholders inside any snippet text. **Configuration example:** @@ -109,6 +109,7 @@ On macOS/Linux you would set `"ShellExt": "sh"`, on Windows `"ShellExt": "ps1"`. **Notes:** - Variable names are matched exactly as declared (case-sensitive). -- Unknown tokens (variables not declared in `Variables`) are left verbatim in the expanded text. +- Unknown tokens (variables not declared in `Variables`) are left verbatim in the expanded text — you typically don't need to escape literal `{Name}` content unless `Name` happens to match a declared variable. +- To prevent a `{Name}` span from being expanded (when the name collides with a declared variable), wrap it as `{{Name}}`. The braces are preserved in the output. - If a snippet text fails to parse (e.g. contains JSON-like `{...}` syntax), the text is returned unchanged. - Snippet search always runs against the raw (unexpanded) template text. diff --git a/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs b/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs index a6b6b70..71461e9 100644 --- a/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs +++ b/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs @@ -2,6 +2,7 @@ using System.IO; using System.Text.Json; using System.Windows; +using Neptuo.Productivity.SnippetManager.Variables; using Application = System.Windows.Application; using MessageBox = System.Windows.MessageBox; @@ -55,7 +56,8 @@ protected override void OnStartup(StartupEventArgs e) configurationRepository, enabled => configurationWatcher.EnableRaisingEventsFromConfigurationWatcher(enabled), Shutdown, - GetExampleConfiguration + GetExampleConfiguration, + configuration.Variables ); private string GetXmlConfigurationPath() @@ -73,6 +75,7 @@ private Configuration GetExampleConfiguration() { var example = new Configuration(); example.General = GeneralConfiguration.Example; + example.Variables = VariablesConfiguration.Example; snippetProviders.AddExampleConfigurations(example.Providers); return example; diff --git a/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs b/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs index aee1017..b5b7607 100644 --- a/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs +++ b/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs @@ -4,6 +4,7 @@ using System.Windows.Forms; using System.Windows.Input; using Neptuo.Productivity.SnippetManager.Services; +using Neptuo.Productivity.SnippetManager.Variables; using Neptuo.Productivity.SnippetManager.ViewModels; using Neptuo.Productivity.SnippetManager.ViewModels.Commands; using Neptuo.Productivity.SnippetManager.Views; @@ -24,8 +25,9 @@ public class Navigator : IClipboardService, ISendTextService private readonly Action shutdown; private readonly Func getExampleConfiguration; private readonly ConfigurationRepository configurationRepository; + private readonly SnippetExpansionPipeline expansionPipeline; - public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository configurationRepository, Action setConfigChangeEnabled, Action shutdown, Func getExampleConfiguration) + public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository configurationRepository, Action setConfigChangeEnabled, Action shutdown, Func getExampleConfiguration, VariablesConfiguration? variables) { this.snippetProvider = snippetProvider; this.configurationRepository = configurationRepository; @@ -34,6 +36,11 @@ public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository confi this.getExampleConfiguration = getExampleConfiguration; this.snippetProviderContext = new(); this.snippetProviderContext.Changed += OnModelsChanged; + this.expansionPipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(variables), + new TokenSnippetTextExpander() + ); snippetProviderInitializeTask = snippetProvider.InitializeAsync(snippetProviderContext); } @@ -52,7 +59,7 @@ public void OpenMain(bool stickToActiveCaret = true) { main = new MainWindow(); main.Closed += (sender, e) => { main = null; }; - main.ViewModel = new MainViewModel(snippetProviderContext, new ApplySnippetCommand(this), new CopySnippetCommand(this)); + main.ViewModel = new MainViewModel(snippetProviderContext, new ApplySnippetCommand(this, expansionPipeline), new CopySnippetCommand(this, expansionPipeline)); UpdateWindowStickPointToCaret(main, stickToActiveCaret); _ = UpdateSnippetsAsync(main.ViewModel); diff --git a/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs b/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs index ba3e1dc..cf5ab74 100644 --- a/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs +++ b/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs @@ -1,4 +1,5 @@ using Neptuo.Productivity.SnippetManager.Models; +using Neptuo.Productivity.SnippetManager.Variables; using Neptuo.Productivity.SnippetManager.ViewModels; using Neptuo.Productivity.SnippetManager.ViewModels.Commands; @@ -6,6 +7,12 @@ namespace Neptuo.Productivity.SnippetManager.Views.DesignData; internal class ViewModelLocator { + private static readonly SnippetExpansionPipeline emptyPipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(null), + new TokenSnippetTextExpander() + ); + private static MainViewModel? mainViewModel; public static MainViewModel MainViewModel { @@ -17,8 +24,8 @@ public static MainViewModel MainViewModel mainViewModel = new MainViewModel( tree, - new ApplySnippetCommand(InteropService.Instance), - new CopySnippetCommand(InteropService.Instance) + new ApplySnippetCommand(InteropService.Instance, emptyPipeline), + new CopySnippetCommand(InteropService.Instance, emptyPipeline) ); var gitHub = tree.GetRoots().First(s => s.Title == "GitHub"); diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs new file mode 100644 index 0000000..2fb4e5d --- /dev/null +++ b/test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs @@ -0,0 +1,71 @@ +using System.Windows.Input; +using Neptuo.Productivity.SnippetManager.Models; +using Neptuo.Productivity.SnippetManager.Services; +using Neptuo.Productivity.SnippetManager.Variables; +using Neptuo.Productivity.SnippetManager.ViewModels.Commands; + +namespace Neptuo.Productivity.SnippetManager.Tests; + +public class ApplySnippetCommandTests +{ + private static SnippetExpansionPipeline EmptyPipeline => new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(null), + new TokenSnippetTextExpander() + ); + + [Fact] + public void ApplyCommand_CanExecute_ReturnsFalseForNullParameter() + { + ICommand command = new ApplySnippetCommand(new TestSendTextService(), EmptyPipeline); + + bool canExecute = command.CanExecute(null); + + Assert.False(canExecute); + } + + [Fact] + public void ApplyCommand_CanExecute_ReturnsFalseForShadowSnippet() + { + var command = new ApplySnippetCommand(new TestSendTextService(), EmptyPipeline); + + bool canExecute = command.CanExecute(new SnippetModel("GitHub")); + + Assert.False(canExecute); + } + + [Fact] + public void ApplyCommand_CanExecute_ReturnsTrueForFilledSnippet() + { + var command = new ApplySnippetCommand(new TestSendTextService(), EmptyPipeline); + + bool canExecute = command.CanExecute(new SnippetModel("GitHub - dotnet", "https://github.com/dotnet")); + + Assert.True(canExecute); + } + + [Fact] + public void ApplyCommand_Execute_ExpandsVariablesBeforeSend() + { + var service = new TestSendTextService(); + var config = new VariablesConfiguration { ["ShellExt"] = "ps1" }; + var pipeline = new SnippetExpansionPipeline( + new TokenSnippetVariableScanner(), + new ConfigurationVariableValueResolver(config), + new TokenSnippetTextExpander() + ); + var command = new ApplySnippetCommand(service, pipeline); + + command.Execute(new SnippetModel("Install", "install.{ShellExt}")); + + Assert.Equal("install.ps1", service.LastText); + } + + private sealed class TestSendTextService : ISendTextService + { + public string? LastText { get; private set; } + + public void Send(string text) + => LastText = text; + } +} From 64254def7ecb650e569e81460af6ab4b4520d50c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Fri, 17 Apr 2026 20:37:09 +0200 Subject: [PATCH 4/9] Parse snippet text once via ISnippetTemplateCompiler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the scanner + expander pair with a single compile step that returns an ISnippetTemplate capturing the parsed token positions and the list of variable references. SnippetExpansionPipeline now calls Compile once, resolves values for Template.Variables, and renders using the already-captured positions — no second parse pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Navigator.cs | 5 +- .../Views/DesignData/ViewModelLocator.cs | 5 +- .../Navigator.cs | 5 +- .../Views/DesignData/ViewModelLocator.cs | 5 +- .../Variables/ISnippetTemplate.cs | 7 + .../Variables/ISnippetTemplateCompiler.cs | 6 + .../Variables/ISnippetTextExpander.cs | 6 - .../Variables/ISnippetVariableScanner.cs | 6 - .../Variables/SnippetExpansionPipeline.cs | 13 +- .../Variables/TokenSnippetTemplateCompiler.cs | 75 ++++++++ .../Variables/TokenSnippetTextExpander.cs | 47 ----- .../Variables/TokenSnippetVariableScanner.cs | 35 ---- .../ApplySnippetCommandTests.cs | 10 +- .../CopySnippetCommandTests.cs | 5 +- .../SnippetVariablesTests.cs | 168 +++++++++--------- 15 files changed, 193 insertions(+), 205 deletions(-) create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplate.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplateCompiler.cs delete mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs delete mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs create mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTemplateCompiler.cs delete mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs delete mode 100644 src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs diff --git a/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs b/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs index 04fac7e..cfc25e0 100644 --- a/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs +++ b/src/Neptuo.Productivity.SnippetManager.Avalonia/Navigator.cs @@ -39,9 +39,8 @@ public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository confi this.snippetProviderContext = new(); this.snippetProviderContext.Changed += OnModelsChanged; this.expansionPipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(variables), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(variables) ); snippetProviderInitializeTask = snippetProvider.InitializeAsync(snippetProviderContext); diff --git a/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs b/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs index b26fdba..f0ac435 100644 --- a/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs +++ b/src/Neptuo.Productivity.SnippetManager.Avalonia/Views/DesignData/ViewModelLocator.cs @@ -8,9 +8,8 @@ namespace Neptuo.Productivity.SnippetManager.Views.DesignData; internal class ViewModelLocator { private static readonly SnippetExpansionPipeline emptyPipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(null), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(null) ); private static MainViewModel? mainViewModel; diff --git a/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs b/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs index b5b7607..c9cc960 100644 --- a/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs +++ b/src/Neptuo.Productivity.SnippetManager.UI/Navigator.cs @@ -37,9 +37,8 @@ public Navigator(ISnippetProvider snippetProvider, ConfigurationRepository confi this.snippetProviderContext = new(); this.snippetProviderContext.Changed += OnModelsChanged; this.expansionPipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(variables), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(variables) ); snippetProviderInitializeTask = snippetProvider.InitializeAsync(snippetProviderContext); diff --git a/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs b/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs index cf5ab74..d586d73 100644 --- a/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs +++ b/src/Neptuo.Productivity.SnippetManager.UI/Views/DesignData/ViewModelLocator.cs @@ -8,9 +8,8 @@ namespace Neptuo.Productivity.SnippetManager.Views.DesignData; internal class ViewModelLocator { private static readonly SnippetExpansionPipeline emptyPipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(null), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(null) ); private static MainViewModel? mainViewModel; diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplate.cs b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplate.cs new file mode 100644 index 0000000..87727b5 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplate.cs @@ -0,0 +1,7 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public interface ISnippetTemplate +{ + IReadOnlyList Variables { get; } + string Render(IReadOnlyDictionary values); +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplateCompiler.cs b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplateCompiler.cs new file mode 100644 index 0000000..ea45304 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTemplateCompiler.cs @@ -0,0 +1,6 @@ +namespace Neptuo.Productivity.SnippetManager.Variables; + +public interface ISnippetTemplateCompiler +{ + ISnippetTemplate Compile(string text); +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs deleted file mode 100644 index b5822df..0000000 --- a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetTextExpander.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Neptuo.Productivity.SnippetManager.Variables; - -public interface ISnippetTextExpander -{ - string Expand(string text, IReadOnlyDictionary values); -} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs b/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs deleted file mode 100644 index 342532a..0000000 --- a/src/Neptuo.Productivity.SnippetManager/Variables/ISnippetVariableScanner.cs +++ /dev/null @@ -1,6 +0,0 @@ -namespace Neptuo.Productivity.SnippetManager.Variables; - -public interface ISnippetVariableScanner -{ - IReadOnlyList Scan(string text); -} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs b/src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs index b31a4dc..b489529 100644 --- a/src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs +++ b/src/Neptuo.Productivity.SnippetManager/Variables/SnippetExpansionPipeline.cs @@ -1,23 +1,22 @@ namespace Neptuo.Productivity.SnippetManager.Variables; public class SnippetExpansionPipeline( - ISnippetVariableScanner scanner, - IVariableValueResolver resolver, - ISnippetTextExpander expander) + ISnippetTemplateCompiler compiler, + IVariableValueResolver resolver) { public string Apply(string text) { - var references = scanner.Scan(text); - if (references.Count == 0) + var template = compiler.Compile(text); + if (template.Variables.Count == 0) return text; var values = new Dictionary(); - foreach (var reference in references) + foreach (var reference in template.Variables) { resolver.TryGetValue(reference.Name, out var value); values[reference.Name] = value; } - return expander.Expand(text, values); + return template.Render(values); } } diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTemplateCompiler.cs b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTemplateCompiler.cs new file mode 100644 index 0000000..5b778a4 --- /dev/null +++ b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTemplateCompiler.cs @@ -0,0 +1,75 @@ +using System.Text; +using Neptuo.Text.Tokens; + +namespace Neptuo.Productivity.SnippetManager.Variables; + +public class TokenSnippetTemplateCompiler : ISnippetTemplateCompiler +{ + public ISnippetTemplate Compile(string text) + { + var parser = CreateParser(); + var parsedTokens = new List(); + parser.OnParsedToken += (sender, e) => parsedTokens.Add(e); + + if (!parser.Parse(text) || parsedTokens.Count == 0) + return new PassthroughTemplate(text); + + var names = new HashSet(); + var references = new List(); + foreach (var e in parsedTokens) + { + if (names.Add(e.Token.Fullname)) + references.Add(new VariableReference(e.Token.Fullname)); + } + + return new TokenSnippetTemplate(text, parsedTokens, references); + } + + private static TokenParser CreateParser() + { + var parser = new TokenParser(); + parser.Configuration.AllowTextContent = true; + parser.Configuration.AllowMultipleTokens = true; + parser.Configuration.AllowEscapeSequence = true; + parser.Configuration.AllowAttributes = false; + parser.Configuration.AllowDefaultAttributes = false; + return parser; + } + + private sealed class PassthroughTemplate(string text) : ISnippetTemplate + { + public IReadOnlyList Variables => Array.Empty(); + + public string Render(IReadOnlyDictionary values) => text; + } + + private sealed class TokenSnippetTemplate( + string text, + IReadOnlyList tokens, + IReadOnlyList variables) : ISnippetTemplate + { + public IReadOnlyList Variables => variables; + + public string Render(IReadOnlyDictionary values) + { + var sb = new StringBuilder(); + int lastEnd = 0; + + foreach (var tokenEvent in tokens) + { + sb.Append(text, lastEnd, tokenEvent.StartPosition - lastEnd); + + if (values.TryGetValue(tokenEvent.Token.Fullname, out var value) && value != null) + sb.Append(value); + else + sb.Append(text, tokenEvent.StartPosition, tokenEvent.EndPosition - tokenEvent.StartPosition); + + lastEnd = tokenEvent.EndPosition; + } + + sb.Append(text, lastEnd, text.Length - lastEnd); + + return sb.ToString(); + } + } +} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs deleted file mode 100644 index bc98f1d..0000000 --- a/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetTextExpander.cs +++ /dev/null @@ -1,47 +0,0 @@ -using System.Text; -using Neptuo.Text.Tokens; - -namespace Neptuo.Productivity.SnippetManager.Variables; - -public class TokenSnippetTextExpander : ISnippetTextExpander -{ - public string Expand(string text, IReadOnlyDictionary values) - { - var parser = CreateParser(); - var parsedTokens = new List(); - parser.OnParsedToken += (sender, e) => parsedTokens.Add(e); - - if (!parser.Parse(text) || parsedTokens.Count == 0) - return text; - - var sb = new StringBuilder(); - int lastEnd = 0; - - foreach (var tokenEvent in parsedTokens) - { - sb.Append(text, lastEnd, tokenEvent.StartPosition - lastEnd); - - if (values.TryGetValue(tokenEvent.Token.Fullname, out var value) && value != null) - sb.Append(value); - else - sb.Append(text, tokenEvent.StartPosition, tokenEvent.EndPosition - tokenEvent.StartPosition); - - lastEnd = tokenEvent.EndPosition; - } - - sb.Append(text, lastEnd, text.Length - lastEnd); - - return sb.ToString(); - } - - private static TokenParser CreateParser() - { - var parser = new TokenParser(); - parser.Configuration.AllowTextContent = true; - parser.Configuration.AllowMultipleTokens = true; - parser.Configuration.AllowEscapeSequence = true; - parser.Configuration.AllowAttributes = false; - parser.Configuration.AllowDefaultAttributes = false; - return parser; - } -} diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs b/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs deleted file mode 100644 index 714a400..0000000 --- a/src/Neptuo.Productivity.SnippetManager/Variables/TokenSnippetVariableScanner.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Neptuo.Text.Tokens; - -namespace Neptuo.Productivity.SnippetManager.Variables; - -public class TokenSnippetVariableScanner : ISnippetVariableScanner -{ - public IReadOnlyList Scan(string text) - { - var parser = CreateParser(); - var names = new HashSet(); - var references = new List(); - - parser.OnParsedToken += (sender, e) => - { - if (names.Add(e.Token.Fullname)) - references.Add(new VariableReference(e.Token.Fullname)); - }; - - if (!parser.Parse(text)) - return Array.Empty(); - - return references; - } - - private static TokenParser CreateParser() - { - var parser = new TokenParser(); - parser.Configuration.AllowTextContent = true; - parser.Configuration.AllowMultipleTokens = true; - parser.Configuration.AllowEscapeSequence = true; - parser.Configuration.AllowAttributes = false; - parser.Configuration.AllowDefaultAttributes = false; - return parser; - } -} diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs index 2fb4e5d..6e760be 100644 --- a/test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs +++ b/test/Neptuo.Productivity.SnippetManager.Tests/ApplySnippetCommandTests.cs @@ -9,9 +9,8 @@ namespace Neptuo.Productivity.SnippetManager.Tests; public class ApplySnippetCommandTests { private static SnippetExpansionPipeline EmptyPipeline => new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(null), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(null) ); [Fact] @@ -50,9 +49,8 @@ public void ApplyCommand_Execute_ExpandsVariablesBeforeSend() var service = new TestSendTextService(); var config = new VariablesConfiguration { ["ShellExt"] = "ps1" }; var pipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(config), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(config) ); var command = new ApplySnippetCommand(service, pipeline); diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs index eb9f19a..96e8b74 100644 --- a/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs +++ b/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs @@ -9,9 +9,8 @@ namespace Neptuo.Productivity.SnippetManager.Tests; public class CopySnippetCommandTests { private static SnippetExpansionPipeline EmptyPipeline => new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(null), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(null) ); [Fact] diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs index 996948c..017f548 100644 --- a/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs +++ b/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs @@ -4,147 +4,154 @@ namespace Neptuo.Productivity.SnippetManager.Tests; public class SnippetVariablesTests { - #region Scanner tests + #region Compiler tests [Fact] - public void Scanner_ReturnsEmptyForTextWithNoTokens() + public void Compiler_ReturnsEmptyVariablesForTextWithNoTokens() { - var scanner = new TokenSnippetVariableScanner(); - var result = scanner.Scan("Hello, World!"); - Assert.Empty(result); + var compiler = new TokenSnippetTemplateCompiler(); + var template = compiler.Compile("Hello, World!"); + Assert.Empty(template.Variables); } [Fact] - public void Scanner_ReturnsEmptyForEmptyText() + public void Compiler_ReturnsEmptyVariablesForEmptyText() { - var scanner = new TokenSnippetVariableScanner(); - var result = scanner.Scan(string.Empty); - Assert.Empty(result); + var compiler = new TokenSnippetTemplateCompiler(); + var template = compiler.Compile(string.Empty); + Assert.Empty(template.Variables); } [Fact] - public void Scanner_ReturnsDistinctNames() + public void Compiler_ReturnsDistinctVariableNames() { - var scanner = new TokenSnippetVariableScanner(); - var result = scanner.Scan("{A} and {A} and {B}"); - Assert.Equal(2, result.Count); - Assert.Contains(result, r => r.Name == "A"); - Assert.Contains(result, r => r.Name == "B"); + var compiler = new TokenSnippetTemplateCompiler(); + var template = compiler.Compile("{A} and {A} and {B}"); + Assert.Equal(2, template.Variables.Count); + Assert.Contains(template.Variables, r => r.Name == "A"); + Assert.Contains(template.Variables, r => r.Name == "B"); } [Fact] - public void Scanner_ReturnsSingleToken() + public void Compiler_ReturnsSingleVariable() { - var scanner = new TokenSnippetVariableScanner(); - var result = scanner.Scan("Hello, {Name}!"); - Assert.Single(result); - Assert.Equal("Name", result[0].Name); + var compiler = new TokenSnippetTemplateCompiler(); + var template = compiler.Compile("Hello, {Name}!"); + Assert.Single(template.Variables); + Assert.Equal("Name", template.Variables[0].Name); } [Fact] - public void Scanner_ReturnsEmptyForMalformedInput() + public void Compiler_ReturnsEmptyVariablesForMalformedInput() { - var scanner = new TokenSnippetVariableScanner(); + var compiler = new TokenSnippetTemplateCompiler(); // JSON-like input causes parse failure - var result = scanner.Scan("{\"key\": \"value\"}"); - Assert.Empty(result); + var template = compiler.Compile("{\"key\": \"value\"}"); + Assert.Empty(template.Variables); } [Fact] - public void Scanner_ReturnsEmptyForAttributeToken() + public void Compiler_ReturnsEmptyVariablesForAttributeToken() { - var scanner = new TokenSnippetVariableScanner(); + var compiler = new TokenSnippetTemplateCompiler(); // Attributes disabled → parse fails - var result = scanner.Scan("{Name key=value}"); - Assert.Empty(result); + var template = compiler.Compile("{Name key=value}"); + Assert.Empty(template.Variables); } [Fact] - public void Scanner_DoesNotReturnEscapedTokens() + public void Compiler_DoesNotIncludeEscapedTokensInVariables() { - var scanner = new TokenSnippetVariableScanner(); - var result = scanner.Scan("{{escaped}} and {Real}"); - Assert.Single(result); - Assert.Equal("Real", result[0].Name); + var compiler = new TokenSnippetTemplateCompiler(); + var template = compiler.Compile("{{escaped}} and {Real}"); + Assert.Single(template.Variables); + Assert.Equal("Real", template.Variables[0].Name); + } + + [Fact] + public void Compiler_ParsesTextOnlyOnce() + { + // Compile once; Render many times on the same template reuses the parsed token positions. + var compiler = new TokenSnippetTemplateCompiler(); + var template = compiler.Compile("install.{ShellExt}"); + + var first = template.Render(new Dictionary { ["ShellExt"] = "ps1" }); + var second = template.Render(new Dictionary { ["ShellExt"] = "sh" }); + + Assert.Equal("install.ps1", first); + Assert.Equal("install.sh", second); } #endregion - #region Expander tests + #region Template.Render tests [Fact] - public void Expander_ReplacesKnownToken() + public void Template_ReplacesKnownToken() { - var expander = new TokenSnippetTextExpander(); + var template = Compile("install.{ShellExt}"); var values = new Dictionary { ["ShellExt"] = "ps1" }; - var result = expander.Expand("install.{ShellExt}", values); - Assert.Equal("install.ps1", result); + Assert.Equal("install.ps1", template.Render(values)); } [Fact] - public void Expander_PassesThroughUnknownToken() + public void Template_PassesThroughUnknownToken() { - var expander = new TokenSnippetTextExpander(); + var template = Compile("install.{OtherExt}"); var values = new Dictionary(); - var result = expander.Expand("install.{OtherExt}", values); - Assert.Equal("install.{OtherExt}", result); + Assert.Equal("install.{OtherExt}", template.Render(values)); } [Fact] - public void Expander_PassesThroughNullValue() + public void Template_PassesThroughNullValue() { - var expander = new TokenSnippetTextExpander(); + var template = Compile("install.{ShellExt}"); var values = new Dictionary { ["ShellExt"] = null }; - var result = expander.Expand("install.{ShellExt}", values); - Assert.Equal("install.{ShellExt}", result); + Assert.Equal("install.{ShellExt}", template.Render(values)); } [Fact] - public void Expander_PreservesEscapeSequence() + public void Template_PreservesEscapeSequence() { - var expander = new TokenSnippetTextExpander(); + var template = Compile("{{literal}} and {ShellExt}"); var values = new Dictionary { ["ShellExt"] = "ps1" }; - var result = expander.Expand("{{literal}} and {ShellExt}", values); - Assert.Equal("{{literal}} and ps1", result); + Assert.Equal("{{literal}} and ps1", template.Render(values)); } [Fact] - public void Expander_PreservesTextWithNoTokens() + public void Template_PreservesTextWithNoTokens() { - var expander = new TokenSnippetTextExpander(); - var values = new Dictionary(); - var result = expander.Expand("Hello, World!", values); - Assert.Equal("Hello, World!", result); + var template = Compile("Hello, World!"); + Assert.Equal("Hello, World!", template.Render(new Dictionary())); } [Fact] - public void Expander_ReturnsMalformedInputUnchanged() + public void Template_ReturnsMalformedInputUnchanged() { - var expander = new TokenSnippetTextExpander(); - var values = new Dictionary(); - var result = expander.Expand("{\"key\": \"value\"}", values); - Assert.Equal("{\"key\": \"value\"}", result); + var template = Compile("{\"key\": \"value\"}"); + Assert.Equal("{\"key\": \"value\"}", template.Render(new Dictionary())); } [Fact] - public void Expander_IsCaseSensitive() + public void Template_IsCaseSensitive() { - var expander = new TokenSnippetTextExpander(); + var template = Compile("{shellext}"); var values = new Dictionary { ["ShellExt"] = "ps1" }; - var result = expander.Expand("{shellext}", values); // "shellext" is not the same as "ShellExt" → pass through - Assert.Equal("{shellext}", result); + Assert.Equal("{shellext}", template.Render(values)); } [Fact] - public void Expander_ReplacesMultipleTokens() + public void Template_ReplacesMultipleTokens() { - var expander = new TokenSnippetTextExpander(); + var template = Compile("{A} and {B}"); var values = new Dictionary { ["A"] = "hello", ["B"] = "world" }; - var result = expander.Expand("{A} and {B}", values); - Assert.Equal("hello and world", result); + Assert.Equal("hello and world", template.Render(values)); } + private static ISnippetTemplate Compile(string text) + => new TokenSnippetTemplateCompiler().Compile(text); + #endregion #region Resolver tests @@ -205,9 +212,8 @@ public void Pipeline_ExpandsVariables() { var config = new VariablesConfiguration { ["ShellExt"] = "ps1" }; var pipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(config), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(config) ); var result = pipeline.Apply("Run install.{ShellExt}"); @@ -218,9 +224,8 @@ public void Pipeline_ExpandsVariables() public void Pipeline_PassesThroughTextWithNoTokens() { var pipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(null), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(null) ); var result = pipeline.Apply("Hello, World!"); @@ -232,9 +237,8 @@ public void Pipeline_PassesThroughUnknownTokens() { var config = new VariablesConfiguration { ["ShellExt"] = "ps1" }; var pipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(config), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(config) ); var result = pipeline.Apply("install.{OtherExt}"); @@ -249,9 +253,8 @@ public void Pipeline_DollarPrefixExpandsWhenVariableDefined() // yielding "export $" + resolved-value = "export $/usr/local/bin". var config = new VariablesConfiguration { ["PATH"] = "/usr/local/bin" }; var pipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(config), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(config) ); var result = pipeline.Apply("export ${PATH}"); @@ -263,9 +266,8 @@ public void Pipeline_DollarPrefixPassesThroughWhenVariableNotDefined() { // ${PATH} — PATH not defined → pass through verbatim var pipeline = new SnippetExpansionPipeline( - new TokenSnippetVariableScanner(), - new ConfigurationVariableValueResolver(null), - new TokenSnippetTextExpander() + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(null) ); var result = pipeline.Apply("export ${PATH}"); From 5c1acc5f84c749c983860c99f40662aab4577699 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Fri, 17 Apr 2026 20:41:44 +0200 Subject: [PATCH 5/9] Match GeneralConfiguration formatting for VariablesConfiguration.Example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Variables/VariablesConfiguration.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs b/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs index 31b4cb1..8287990 100644 --- a/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs +++ b/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs @@ -2,8 +2,9 @@ namespace Neptuo.Productivity.SnippetManager.Variables; public class VariablesConfiguration : Dictionary { - public static VariablesConfiguration Example => new() - { - ["ShellExt"] = "sh" - }; + public static VariablesConfiguration Example + => new() + { + ["ShellExt"] = "sh" + }; } From a6707b2efa1b7a62c0922f8e7a8cb4eb2d26d487 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Fri, 17 Apr 2026 20:42:39 +0200 Subject: [PATCH 6/9] Make Example ShellExt platform-specific (cmd on Windows, sh elsewhere) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Variables/VariablesConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs b/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs index 8287990..8d3d0e8 100644 --- a/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs +++ b/src/Neptuo.Productivity.SnippetManager/Variables/VariablesConfiguration.cs @@ -5,6 +5,6 @@ public class VariablesConfiguration : Dictionary public static VariablesConfiguration Example => new() { - ["ShellExt"] = "sh" + ["ShellExt"] = OperatingSystem.IsWindows() ? "cmd" : "sh" }; } From 568f09c727885b13e8ccec8c23e98af4d21ab1f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Fri, 17 Apr 2026 20:45:22 +0200 Subject: [PATCH 7/9] Fix WPF reload wiring so Variables changes take effect without restart The tray icon and hotkey handlers capture a specific Navigator instance by reference. Previously the WPF reload path only rebound the hotkey when GeneralConfiguration.HotKey changed, and never recreated the tray icon, so any edit limited to Variables (or Providers) left the old Navigator wired up. Mirror the Avalonia host: unconditionally dispose and recreate the tray icon, and unconditionally unbind and rebind the hotkey to the new Navigator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../App.xaml.cs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs b/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs index 71461e9..0f10e81 100644 --- a/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs +++ b/src/Neptuo.Productivity.SnippetManager.UI/App.xaml.cs @@ -117,17 +117,15 @@ private void AskToReloadConfiguration() { navigator.CloseMain(); - string? oldHotKey = configuration.General?.HotKey ?? GeneralConfiguration.Example.HotKey; - configuration = CreateConfiguration(); provider = snippetProviders.Create(configuration.Providers); navigator = CreateNavigator(); - if (hotkey != null && configuration.General?.HotKey != oldHotKey) - { - hotkey.UnBind(); - hotkey.Bind(navigator, Dispatcher, configuration.General?.HotKey); - } + trayIcon.Dispose(); + trayIcon = new TrayIcon(navigator, hotkey, GetXmlSnippetFilePaths); + + hotkey.UnBind(); + hotkey.Bind(navigator, Dispatcher, configuration.General?.HotKey); }); } } From daffabac8244192c8c9c7d193fa4de2c2fce25be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Fri, 17 Apr 2026 21:32:30 +0200 Subject: [PATCH 8/9] Add expansion correctness tests for corner cases - CopySnippetCommand.Execute: expansion parity with ApplySnippetCommand - Template: repeated same token, adjacent tokens, token-only string, empty-string value, brace-like value never re-expanded, multiline text with tokens Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../CopySnippetCommandTests.cs | 20 ++++++- .../SnippetVariablesTests.cs | 55 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs index 96e8b74..3ef82ba 100644 --- a/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs +++ b/test/Neptuo.Productivity.SnippetManager.Tests/CopySnippetCommandTests.cs @@ -43,9 +43,27 @@ public void CopyCommand_CanExecute_ReturnsTrueForFilledSnippet() Assert.True(canExecute); } + [Fact] + public void CopyCommand_Execute_ExpandsVariablesBeforeSettingClipboard() + { + var service = new TestClipboardService(); + var config = new VariablesConfiguration { ["ShellExt"] = "ps1" }; + var pipeline = new SnippetExpansionPipeline( + new TokenSnippetTemplateCompiler(), + new ConfigurationVariableValueResolver(config) + ); + var command = new CopySnippetCommand(service, pipeline); + + command.Execute(new SnippetModel("Install", "install.{ShellExt}")); + + Assert.Equal("install.ps1", service.LastText); + } + private sealed class TestClipboardService : IClipboardService { + public string? LastText { get; private set; } + public void SetText(string text) - { } + => LastText = text; } } diff --git a/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs b/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs index 017f548..f18ba09 100644 --- a/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs +++ b/test/Neptuo.Productivity.SnippetManager.Tests/SnippetVariablesTests.cs @@ -149,6 +149,61 @@ public void Template_ReplacesMultipleTokens() Assert.Equal("hello and world", template.Render(values)); } + [Fact] + public void Template_ReplacesRepeatedOccurrencesOfSameToken() + { + var template = Compile("{Name}-{Name}-{Name}"); + var values = new Dictionary { ["Name"] = "John" }; + Assert.Equal("John-John-John", template.Render(values)); + } + + [Fact] + public void Template_ReplacesAdjacentTokens() + { + var template = Compile("{A}{B}"); + var values = new Dictionary { ["A"] = "hello", ["B"] = "world" }; + Assert.Equal("helloworld", template.Render(values)); + } + + [Fact] + public void Template_ReplacesTokenAtStartAndEnd() + { + var template = Compile("{Only}"); + var values = new Dictionary { ["Only"] = "value" }; + Assert.Equal("value", template.Render(values)); + } + + [Fact] + public void Template_UsesEmptyStringValue() + { + // An empty string is a legitimate value distinct from null; it should replace the token. + var template = Compile("[{Name}]"); + var values = new Dictionary { ["Name"] = string.Empty }; + Assert.Equal("[]", template.Render(values)); + } + + [Fact] + public void Template_DoesNotRecursivelyExpandBracesInResolvedValue() + { + // Values are pasted verbatim: a value that looks like a token must not be re-expanded. + var template = Compile("Say {Greeting}"); + var values = new Dictionary + { + ["Greeting"] = "Hello {World}", + ["World"] = "NEVER" + }; + Assert.Equal("Say Hello {World}", template.Render(values)); + } + + [Fact] + public void Template_ExpandsTokensInsideMultilineText() + { + var source = "first {A}\nsecond {B}\nthird {A}"; + var template = Compile(source); + var values = new Dictionary { ["A"] = "ALPHA", ["B"] = "BETA" }; + Assert.Equal("first ALPHA\nsecond BETA\nthird ALPHA", template.Render(values)); + } + private static ISnippetTemplate Compile(string text) => new TokenSnippetTemplateCompiler().Compile(text); From 963e046b7137727cd62075fbe03ac82e7cc97dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marek=20Fi=C5=A1era?= Date: Fri, 17 Apr 2026 22:06:08 +0200 Subject: [PATCH 9/9] Align README Variables example with the generated default (cmd/sh) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f86f049..b08dc8a 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ The `Variables` section lets you declare named values that are substituted into ```json { "Variables": { - "ShellExt": "ps1" + "ShellExt": "sh" }, "Snippets": { "Install": "Run install.{ShellExt} to get started." @@ -105,7 +105,7 @@ The `Variables` section lets you declare named values that are substituted into } ``` -On macOS/Linux you would set `"ShellExt": "sh"`, on Windows `"ShellExt": "ps1"`. The snippet text `"Run install.{ShellExt} to get started."` expands to the correct value at apply/copy time, while the snippet source stays unchanged. +On macOS/Linux you would set `"ShellExt": "sh"`, on Windows `"ShellExt": "cmd"` — those are the defaults the generated example config uses. The snippet text `"Run install.{ShellExt} to get started."` expands to the correct value at apply/copy time, while the snippet source stays unchanged. **Notes:** - Variable names are matched exactly as declared (case-sensitive).