From ca6e9530f2b73139be64ac754c8ef37a860e4c20 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:23:54 +0530 Subject: [PATCH 1/8] Fix [Bug] Predictor Path De-Synchronization between C# Module and PowerShell Module Fixes #14 --- Module/PSFavorite.psm1 | 5 ++ Module/Private/Initialize-Configuration.ps1 | 7 +- Predictor/PSFavoritePredictor.cs | 89 +++++++++++++++++++-- 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/Module/PSFavorite.psm1 b/Module/PSFavorite.psm1 index 7d554ba..2c20281 100644 --- a/Module/PSFavorite.psm1 +++ b/Module/PSFavorite.psm1 @@ -18,3 +18,8 @@ Get-ChildItem -Path "$PSScriptRoot\Public" -Filter "*.ps1" | ForEach-Object { # Initialize the PSFavorite module with the default configuration Initialize-PSFavorite + +# Load the favorites from the configuration file if it exists +if ($Script:FavoritesPath) { + [PSFavorite.PSFavoritePredictor]::Initialize($Script:FavoritesPath) +} diff --git a/Module/Private/Initialize-Configuration.ps1 b/Module/Private/Initialize-Configuration.ps1 index 803a212..e68ea8b 100644 --- a/Module/Private/Initialize-Configuration.ps1 +++ b/Module/Private/Initialize-Configuration.ps1 @@ -19,6 +19,9 @@ function Initialize-Configuration( # Name of the parent folder [string] $ModuleName = "PSFavorite", + # Name of the configuration file + [string] $FavoritesFile = "Favorites.txt", + # Path to the configuration file [string] $FavoritesPath ) { @@ -31,9 +34,7 @@ function Initialize-Configuration( else { # Create the PSFavorite directory if it doesn't already exist $local = if ($IsWindows) { $Env:LOCALAPPDATA } else { "$HOME/.local/share" } - $folder = Join-Path $local $ModuleName - # Path to the Favorites file - $Script:FavoritesPath = Join-Path $folder "Favorites.txt" + $Script:FavoritesPath = Join-Path $local $ModuleName $FavoritesFile } # Create the directory if it doesn't exist diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs index d4b5d58..dba098f 100644 --- a/Predictor/PSFavoritePredictor.cs +++ b/Predictor/PSFavoritePredictor.cs @@ -1,4 +1,9 @@ -using System.Management.Automation; +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Collections.Generic; +using System.Management.Automation; using System.Management.Automation.Subsystem; using System.Management.Automation.Subsystem.Prediction; @@ -28,15 +33,82 @@ internal PSFavoritePredictor(string guid) /// public string Description => "A predictor that uses a list of favorite commands to provide suggestions."; + #region "Favorites" + /// /// The file path of the favorite commands file. + /// For Windows, the default path is "%LocalAppData%\PSFavorite\Favorites.txt" and for Linux/macOS, the default path is "$HOME/.local/share/PSFavorite/Favorites.txt". + /// The file is expected to contain one favorite command per line, and an optional description after a '#' character. + /// + private static string _FavoritesFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PSFavorite", "Favorites.txt"); + + /// + /// A cached list of favorite commands. + /// Can be updated by calling LoadFavoritesIfExists, which is triggered during initialization. /// - private static readonly string _FavoritesFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PSFavorite", "Favorites.txt"); + private static string[] _favorites = Array.Empty(); + + /// + /// An object used for locking access to the favorites array to ensure thread safety. + /// This is necessary because the predictor may be called from multiple threads concurrently, and we want to avoid race conditions + /// when loading or accessing the favorites. + /// + private static readonly object _favoritesLock = new object(); + + /// + /// Initialize the predictor with an explicit favorites path. + /// Safe to call from PowerShell after the module's configuration is resolved. + /// + /// Full path to the favorites file. + public static void Initialize(string favoritesPath) + { + if (!string.IsNullOrWhiteSpace(favoritesPath)) + { + _FavoritesFilePath = favoritesPath; + } + + LoadFavoritesIfExists(); + } /// - /// A list of favorite commands. + /// Load the favorites from the file if it exists. If any error occurs, set favorites to an empty array. /// - private readonly string[] favorites = File.ReadAllLines(_FavoritesFilePath); + private static void LoadFavoritesIfExists() + { + try + { + // Check if the favorites file exists. If it does, read all lines and update the _favorites array. + if (File.Exists(_FavoritesFilePath)) + { + var lines = File.ReadAllLines(_FavoritesFilePath); + lock (_favoritesLock) + { + _favorites = lines; + } + } + // ...otherwise, if the file does not exist, set _favorites to an empty array. + else + { + lock (_favoritesLock) + { + _favorites = Array.Empty(); + } + } + } + // If any exception occurs during file access (e.g., file is locked, permission issues, etc.), + // catch the exception and set _favorites to an empty array to avoid crashing the predictor. + catch + { + lock (_favoritesLock) + { + _favorites = Array.Empty(); + } + } + } + + #endregion + + #region "Suggestions" /// /// Get the predictive suggestions. It indicates the start of a suggestion rendering session. @@ -54,9 +126,8 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex return default; } - // Generate the list of predictive suggestions. - List suggestions = favorites - .Select(line => (Line: line, Score: DetermineScore(input, line))) // Determine the score for each line. + List suggestions = _favorites + .Select(line => (Line: line!, Score: DetermineScore(input, line!))) // Determine the score for each line. .Where(tuple => tuple.Score >= ScoreThreshold) // Filter out the lines below the score threshold. .OrderByDescending(tuple => tuple.Score) // Order the list by the score in descending order. .Select(tuple => new PredictiveSuggestion(tuple.Line, GetTooltip(tuple.Line))) // Create a PredictiveSuggestion object for selected line. @@ -130,6 +201,8 @@ private static string GetTooltip(string line) } } + #endregion + #region "interface methods for processing feedback" /// @@ -177,7 +250,7 @@ public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList /// Shows whether the execution was successful. public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } - #endregion; + #endregion } /// From 9f0f5516ac6af40926e7cf262da11b54ca813b69 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:41:32 +0530 Subject: [PATCH 2/8] Move the file-reading logic inside the lock --- Predictor/PSFavoritePredictor.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs index dba098f..1e57219 100644 --- a/Predictor/PSFavoritePredictor.cs +++ b/Predictor/PSFavoritePredictor.cs @@ -80,10 +80,9 @@ private static void LoadFavoritesIfExists() // Check if the favorites file exists. If it does, read all lines and update the _favorites array. if (File.Exists(_FavoritesFilePath)) { - var lines = File.ReadAllLines(_FavoritesFilePath); lock (_favoritesLock) { - _favorites = lines; + _favorites = File.ReadAllLines(_FavoritesFilePath); } } // ...otherwise, if the file does not exist, set _favorites to an empty array. From 6e6df673420564ed35d04dd04ad7cac1a5b49681 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 14:42:03 +0530 Subject: [PATCH 3/8] Refactor favorites access to use a snapshot within a lock for thread safety --- Predictor/PSFavoritePredictor.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs index 1e57219..3a91097 100644 --- a/Predictor/PSFavoritePredictor.cs +++ b/Predictor/PSFavoritePredictor.cs @@ -124,9 +124,20 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex { return default; } + + string[] favoritesSnapshot; + lock (_favoritesLock) + { + favoritesSnapshot = _favorites; + } + + if (favoritesSnapshot is null || favoritesSnapshot.Length == 0) + { + return default; + } - List suggestions = _favorites - .Select(line => (Line: line!, Score: DetermineScore(input, line!))) // Determine the score for each line. + List suggestions = favoritesSnapshot + .Select(line => (Line: line, Score: DetermineScore(input, line))) // Determine the score for each line. .Where(tuple => tuple.Score >= ScoreThreshold) // Filter out the lines below the score threshold. .OrderByDescending(tuple => tuple.Score) // Order the list by the score in descending order. .Select(tuple => new PredictiveSuggestion(tuple.Line, GetTooltip(tuple.Line))) // Create a PredictiveSuggestion object for selected line. From 8ccee73f0151b0e4380a4130bde076c501b222d4 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:06:04 +0530 Subject: [PATCH 4/8] Add tests for predictor initialization with custom favorites path --- .../Public/PredictorInitialization.Tests.ps1 | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 Module/Tests/Public/PredictorInitialization.Tests.ps1 diff --git a/Module/Tests/Public/PredictorInitialization.Tests.ps1 b/Module/Tests/Public/PredictorInitialization.Tests.ps1 new file mode 100644 index 0000000..019b2a4 --- /dev/null +++ b/Module/Tests/Public/PredictorInitialization.Tests.ps1 @@ -0,0 +1,27 @@ +Describe "Predictor initialization on first import" { + Context "When initializing module with a custom favorites path" { + It "Creates the favorites file when given a temp path" { + $testRoot = Join-Path $PSScriptRoot '..\temp-' + ([guid]::NewGuid().ToString()) + New-Item -Path $testRoot -ItemType Directory -Force | Out-Null + + $favoritesFile = Join-Path $testRoot 'PSFavorite\Favorites.txt' + + try { + # Import the module (should not throw) + $manifest = Join-Path (Join-Path $PSScriptRoot '..\..') 'PSFavorite.psd1' + Import-Module $manifest -Force -ErrorAction Stop + + # Initialize using a custom favorites path under our temp folder + Initialize-PSFavorite -FavoritesPath $favoritesFile -ErrorAction Stop + + # Assert the favorites file exists + Test-Path $favoritesFile | Should -BeTrue + } + finally { + # Cleanup: remove module and temp folder + Remove-Module PSFavorite -ErrorAction SilentlyContinue + Remove-Item -Path $testRoot -Recurse -Force -ErrorAction SilentlyContinue + } + } + } +} From bb2c211a82ee3b9f6354952aca7d2b405b30b1a2 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 15:19:02 +0530 Subject: [PATCH 5/8] Make predictor registration idempotent by handling duplicate registration exceptions --- Predictor/PSFavoritePredictor.cs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs index 3a91097..5e6ef52 100644 --- a/Predictor/PSFavoritePredictor.cs +++ b/Predictor/PSFavoritePredictor.cs @@ -276,7 +276,19 @@ public class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup public void OnImport() { var predictor = new PSFavoritePredictor(Identifier); - SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor); + try + { + SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor); + } + catch (InvalidOperationException ex) + { + // The predictor may already be registered (e.g., repeated module import in the same process). + // Treat duplicate registration as a no-op to make initialization idempotent. + if (!ex.Message.Contains("already registered")) + { + throw; + } + } } /// From 96be90315e3e021a580a3aef9aa21b1412b629d7 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:14:27 +0530 Subject: [PATCH 6/8] Add `Unregister` function and cmdlet --- Module/PSFavorite.psd1 | 3 +- .../Public/Unregister-PSFavoritePredictor.ps1 | 14 +++++++++ .../Public/PredictorInitialization.Tests.ps1 | 3 +- Predictor/PSFavoritePredictor.cs | 31 +++++++++++++------ 4 files changed, 40 insertions(+), 11 deletions(-) create mode 100644 Module/Public/Unregister-PSFavoritePredictor.ps1 diff --git a/Module/PSFavorite.psd1 b/Module/PSFavorite.psd1 index 49b3b89..8ee18d6 100644 --- a/Module/PSFavorite.psd1 +++ b/Module/PSFavorite.psd1 @@ -76,7 +76,8 @@ "Get-PSFavorites", "Initialize-PSFavorite", "Optimize-PSFavorites", - "Remove-PSFavorite" + "Remove-PSFavorite", + "Unregister-PSFavoritePredictor" ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. diff --git a/Module/Public/Unregister-PSFavoritePredictor.ps1 b/Module/Public/Unregister-PSFavoritePredictor.ps1 new file mode 100644 index 0000000..0bb89b7 --- /dev/null +++ b/Module/Public/Unregister-PSFavoritePredictor.ps1 @@ -0,0 +1,14 @@ +<# +.SYNOPSIS + Unregister the PSFavorite predictor from PSReadLine. +.DESCRIPTION + Explicitly removes the PSFavorite predictor from the PSReadLine subsystem. + This is safe to call multiple times and is useful for testing or when you + want to cleanly unload the predictor before re-importing the module. +.EXAMPLE + Unregister-PSFavoritePredictor + Unregisters the predictor from PSReadLine. +#> +function Unregister-PSFavoritePredictor { + [PSFavorite.PSFavoritePredictor]::Unregister() +} diff --git a/Module/Tests/Public/PredictorInitialization.Tests.ps1 b/Module/Tests/Public/PredictorInitialization.Tests.ps1 index 019b2a4..1533278 100644 --- a/Module/Tests/Public/PredictorInitialization.Tests.ps1 +++ b/Module/Tests/Public/PredictorInitialization.Tests.ps1 @@ -18,7 +18,8 @@ Describe "Predictor initialization on first import" { Test-Path $favoritesFile | Should -BeTrue } finally { - # Cleanup: remove module and temp folder + # Cleanup: unregister predictor, remove module and temp folder + Unregister-PSFavoritePredictor -ErrorAction SilentlyContinue Remove-Module PSFavorite -ErrorAction SilentlyContinue Remove-Item -Path $testRoot -Recurse -Force -ErrorAction SilentlyContinue } diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs index 5e6ef52..378a8f5 100644 --- a/Predictor/PSFavoritePredictor.cs +++ b/Predictor/PSFavoritePredictor.cs @@ -33,6 +33,9 @@ internal PSFavoritePredictor(string guid) /// public string Description => "A predictor that uses a list of favorite commands to provide suggestions."; + /// A fixed GUID to identify the predictor. This should be unique to avoid conflicts with other predictors. + internal const string Identifier = "843b51d0-55c8-4c1a-8116-f0728d419306"; + #region "Favorites" /// @@ -261,6 +264,22 @@ public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { } #endregion + + /// + /// Explicitly unregister the predictor from the PSReadLine subsystem. + /// Safe to call multiple times; silently ignores if not currently registered. + /// + public static void Unregister() + { + try + { + SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, new Guid(Identifier)); + } + catch (InvalidOperationException ex) when (ex.Message.Contains("not registered")) + { + // Predictor was already unregistered or never registered; no-op. + } + } } /// @@ -268,26 +287,20 @@ public void OnCommandLineExecuted(PredictionClient client, string commandLine, b /// public class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup { - private const string Identifier = "843b51d0-55c8-4c1a-8116-f0728d419306"; - /// /// Gets called when assembly is loaded. /// public void OnImport() { - var predictor = new PSFavoritePredictor(Identifier); + var predictor = new PSFavoritePredictor(PSFavoritePredictor.Identifier); try { SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor); } - catch (InvalidOperationException ex) + catch (InvalidOperationException ex) when (ex.Message.Contains("already registered")) { // The predictor may already be registered (e.g., repeated module import in the same process). // Treat duplicate registration as a no-op to make initialization idempotent. - if (!ex.Message.Contains("already registered")) - { - throw; - } } } @@ -296,7 +309,7 @@ public void OnImport() /// public void OnRemove(PSModuleInfo psModuleInfo) { - SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, new Guid(Identifier)); + PSFavoritePredictor.Unregister(); } } } From 663d166918a409ffb13ab2e2ab4d5329105fb532 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:22:20 +0530 Subject: [PATCH 7/8] Simplify exception handling in Unregister method to ignore already unregistered state --- Predictor/PSFavoritePredictor.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs index 378a8f5..d292b19 100644 --- a/Predictor/PSFavoritePredictor.cs +++ b/Predictor/PSFavoritePredictor.cs @@ -275,7 +275,7 @@ public static void Unregister() { SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, new Guid(Identifier)); } - catch (InvalidOperationException ex) when (ex.Message.Contains("not registered")) + catch (InvalidOperationException) { // Predictor was already unregistered or never registered; no-op. } From 5aa6739cb0d9a9a6276e08d9d9c5ca6f21bd39a7 Mon Sep 17 00:00:00 2001 From: Shresht Srivastav <59516096+Shresht7@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:26:04 +0530 Subject: [PATCH 8/8] Update docstring comments --- Predictor/PSFavoritePredictor.cs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Predictor/PSFavoritePredictor.cs b/Predictor/PSFavoritePredictor.cs index d292b19..d3c87be 100644 --- a/Predictor/PSFavoritePredictor.cs +++ b/Predictor/PSFavoritePredictor.cs @@ -11,8 +11,16 @@ namespace PSFavorite { public class PSFavoritePredictor : ICommandPredictor { + /// + /// The unique identifier for this predictor instance. + /// This is set through the constructor and used for registration with the subsystem manager. + /// private readonly Guid _guid; + /// + /// Initializes a new instance of the class with a specified GUID. + /// + /// The GUID to associate with this predictor instance. internal PSFavoritePredictor(string guid) { _guid = new Guid(guid); @@ -33,7 +41,10 @@ internal PSFavoritePredictor(string guid) /// public string Description => "A predictor that uses a list of favorite commands to provide suggestions."; + /// /// A fixed GUID to identify the predictor. This should be unique to avoid conflicts with other predictors. + /// This is used for registration and unregistration of the predictor with the subsystem manager. + /// internal const string Identifier = "843b51d0-55c8-4c1a-8116-f0728d419306"; #region "Favorites"