Skip to content
Merged
3 changes: 2 additions & 1 deletion Module/PSFavorite.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions Module/PSFavorite.psm1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
7 changes: 4 additions & 3 deletions Module/Private/Initialize-Configuration.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand All @@ -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
Expand Down
14 changes: 14 additions & 0 deletions Module/Public/Unregister-PSFavoritePredictor.ps1
Original file line number Diff line number Diff line change
@@ -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()
}
28 changes: 28 additions & 0 deletions Module/Tests/Public/PredictorInitialization.Tests.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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: unregister predictor, remove module and temp folder
Unregister-PSFavoritePredictor -ErrorAction SilentlyContinue
Remove-Module PSFavorite -ErrorAction SilentlyContinue
Remove-Item -Path $testRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
}
143 changes: 131 additions & 12 deletions Predictor/PSFavoritePredictor.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
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;

namespace PSFavorite
{
public class PSFavoritePredictor : ICommandPredictor
{
/// <summary>
/// The unique identifier for this predictor instance.
/// This is set through the constructor and used for registration with the subsystem manager.
/// </summary>
private readonly Guid _guid;

/// <summary>
/// Initializes a new instance of the <see cref="PSFavoritePredictor"/> class with a specified GUID.
/// </summary>
/// <param name="guid">The GUID to associate with this predictor instance.</param>
internal PSFavoritePredictor(string guid)
{
_guid = new Guid(guid);
Expand All @@ -28,15 +41,87 @@ internal PSFavoritePredictor(string guid)
/// </summary>
public string Description => "A predictor that uses a list of favorite commands to provide suggestions.";

/// <summary>
/// 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.
/// </summary>
internal const string Identifier = "843b51d0-55c8-4c1a-8116-f0728d419306";

#region "Favorites"

/// <summary>
/// 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.
/// </summary>
private static readonly string _FavoritesFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PSFavorite", "Favorites.txt");
private static string _FavoritesFilePath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "PSFavorite", "Favorites.txt");

/// <summary>
/// A list of favorite commands.
/// A cached list of favorite commands.
/// Can be updated by calling LoadFavoritesIfExists, which is triggered during initialization.
/// </summary>
private readonly string[] favorites = File.ReadAllLines(_FavoritesFilePath);
private static string[] _favorites = Array.Empty<string>();

/// <summary>
/// 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.
/// </summary>
private static readonly object _favoritesLock = new object();

/// <summary>
/// Initialize the predictor with an explicit favorites path.
/// Safe to call from PowerShell after the module's configuration is resolved.
/// </summary>
/// <param name="favoritesPath">Full path to the favorites file.</param>
public static void Initialize(string favoritesPath)
{
if (!string.IsNullOrWhiteSpace(favoritesPath))
{
_FavoritesFilePath = favoritesPath;
}

LoadFavoritesIfExists();
}

/// <summary>
/// Load the favorites from the file if it exists. If any error occurs, set favorites to an empty array.
/// </summary>
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))
{
lock (_favoritesLock)
{
_favorites = File.ReadAllLines(_FavoritesFilePath);
}
}
// ...otherwise, if the file does not exist, set _favorites to an empty array.
else
{
lock (_favoritesLock)
{
_favorites = Array.Empty<string>();
}
}
}
// 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<string>();
}
}
}

#endregion

#region "Suggestions"

/// <summary>
/// Get the predictive suggestions. It indicates the start of a suggestion rendering session.
Expand All @@ -53,9 +138,19 @@ public SuggestionPackage GetSuggestion(PredictionClient client, PredictionContex
{
return default;
}

string[] favoritesSnapshot;
lock (_favoritesLock)
{
favoritesSnapshot = _favorites;
}

// Generate the list of predictive suggestions.
List<PredictiveSuggestion> suggestions = favorites
if (favoritesSnapshot is null || favoritesSnapshot.Length == 0)
{
return default;
}

List<PredictiveSuggestion> 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.
Expand Down Expand Up @@ -130,6 +225,8 @@ private static string GetTooltip(string line)
}
}

#endregion

#region "interface methods for processing feedback"

/// <summary>
Expand Down Expand Up @@ -177,31 +274,53 @@ public void OnCommandLineAccepted(PredictionClient client, IReadOnlyList<string>
/// <param name="success">Shows whether the execution was successful.</param>
public void OnCommandLineExecuted(PredictionClient client, string commandLine, bool success) { }

#endregion;
#endregion

/// <summary>
/// Explicitly unregister the predictor from the PSReadLine subsystem.
/// Safe to call multiple times; silently ignores if not currently registered.
/// </summary>
public static void Unregister()
{
try
{
SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, new Guid(Identifier));
}
catch (InvalidOperationException)
{
// Predictor was already unregistered or never registered; no-op.
}
}
}

/// <summary>
/// Register the predictor on module loading and unregister it on module un-loading.
/// </summary>
public class Init : IModuleAssemblyInitializer, IModuleAssemblyCleanup
{
private const string Identifier = "843b51d0-55c8-4c1a-8116-f0728d419306";

/// <summary>
/// Gets called when assembly is loaded.
/// </summary>
public void OnImport()
{
var predictor = new PSFavoritePredictor(Identifier);
SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor);
var predictor = new PSFavoritePredictor(PSFavoritePredictor.Identifier);
try
{
SubsystemManager.RegisterSubsystem(SubsystemKind.CommandPredictor, predictor);
}
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.
}
}

/// <summary>
/// Gets called when the binary module is unloaded.
/// </summary>
public void OnRemove(PSModuleInfo psModuleInfo)
{
SubsystemManager.UnregisterSubsystem(SubsystemKind.CommandPredictor, new Guid(Identifier));
PSFavoritePredictor.Unregister();
}
}
}