├── .gitattributes ├── img ├── AzViz.png └── Mockup.png ├── explainpowershell.frontend ├── wwwroot │ ├── appsettings.json │ ├── favicon.ico │ └── index.html ├── InternalsVisibleTo.cs ├── Clients │ ├── AiExplanationResponse.cs │ ├── ISyntaxAnalyzerClient.cs │ ├── ApiCallResult.cs │ └── SyntaxAnalyzerClient.cs ├── App.razor ├── _Imports.razor ├── explainpowershell.frontend.csproj ├── Program.cs ├── Shared │ ├── MetaDataInfo.razor.css │ ├── MainLayout.razor.css │ ├── ExplainCard.razor │ ├── MainLayout.razor │ └── MetaDataInfo.razor ├── Properties │ └── launchSettings.json └── Tree.cs ├── explainpowershell.analysisservice.tests ├── GlobalUsings.cs ├── Test-IsAzuriteUp.ps1 ├── testfiles │ ├── myTestModule.psm1 │ ├── myConflictingTestModule.psm1 │ └── oneliners.ps1 ├── Test-IsPrerequisitesRunning.ps1 ├── tests │ ├── AstVisitorExplainer_helpers.tests.cs │ ├── GetParameterSetData.tests.cs │ ├── MatchParam.tests.cs │ ├── AstVisitorExplainer_command.tests.cs │ ├── HelpEntitySynopsisBinding.tests.cs │ ├── InMemoryHelpRepository.tests.cs │ └── SyntaxAnalyzerFunction.tests.cs ├── explainpowershell.analysisservice.tests.csproj ├── Get-MetaData.ps1 ├── Start-FunctionApp.ps1 ├── Get-MetaData.Tests.ps1 ├── Get-HelpDatabaseData.Tests.ps1 ├── HelpCollectorHtmlScraper.Tests.ps1 ├── helpers │ ├── Helpers.Logger.cs │ ├── InMemoryHelpRepository.cs │ └── TestHelpData.cs ├── Invoke-SyntaxAnalyzer.ps1 ├── Get-DotnetEnvironment.Tests.ps1 ├── Start-AllTests.ps1 ├── Invoke-AiExplanation.ps1 └── Get-HelpDatabaseData.ps1 ├── explainpowershell.models ├── Code.cs ├── Module.cs ├── explainpowershell.models.csproj ├── Explanation.cs ├── AnalysisResult.cs ├── ParameterSetData.cs ├── HelpMetaData.cs ├── ParameterData.cs └── HelpEntity.cs ├── explainpowershell.helpcollector ├── tools │ ├── explainpowershell.helpcollector.tools.csproj │ └── DeCompress.cs ├── New-SasToken.ps1 ├── BulkHelpCollector.ps1 ├── BulkHelpCacheUploader.ps1 ├── aboutcollector.ps1 ├── Get-ParameterSetNames.ps1 ├── helpcollector.ps1 └── HelpCollector.Functions.ps1 ├── explainpowershell.analysisservice ├── host.json ├── local.settings.json ├── Services │ ├── IAiExplanationService.cs │ └── AiExplanationOptions.cs ├── Helpers │ ├── TableClientFactory.cs │ ├── GetBuildDate.cs │ ├── GetParameterSetData.cs │ └── MatchParam.cs ├── codegen │ ├── Accelerators.cs_code_generator.ps1 │ ├── TokenKind.cs_code_generator.ps1 │ └── Alias.cs_code_generator.ps1 ├── Repositories │ ├── IHelpRepository.cs │ └── TableStorageHelpRepository.cs ├── explainpowershell.csproj ├── Constants.cs ├── Program.cs ├── ExtensionMethods │ └── SyntaxAnalyzerExtensions.cs ├── SyntaxAnalyzer.cs ├── AiExplanationFunction.cs └── MetaData.cs ├── nuget.config ├── .vscode ├── settings.json ├── extensions.json ├── launch.json └── tasks.json ├── .github ├── workflows │ ├── greetings.yml │ ├── deploy_azure_infra.yml │ ├── add_module_help.yml │ └── deploy_app.yml └── copilot-instructions.md ├── explainpowershell.azureinfra ├── parameters.json └── template.bicep ├── research └── GetRequests.ps1 ├── explainpowershell.frontend.tests ├── explainpowershell.frontend.tests.csproj └── TreeTests.cs ├── LICENSE ├── explainpowershell.metadata └── defaultModules.json ├── .tours ├── tour-of-the-azure-bootstrapper.tour ├── high-level-tour-of-the-application.tour ├── tour-of-the-help-collector.tour └── tour-of-the-ai-explanation-feature.tour ├── tools └── bump_versions.ps1 └── .gitignore /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /img/AzViz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jawz84/explainpowershell/HEAD/img/AzViz.png -------------------------------------------------------------------------------- /img/Mockup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jawz84/explainpowershell/HEAD/img/Mockup.png -------------------------------------------------------------------------------- /explainpowershell.frontend/wwwroot/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "BaseAddress": "http://localhost:7071/api/" 3 | } 4 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/GlobalUsings.cs: -------------------------------------------------------------------------------- 1 | global using NUnit.Framework; 2 | global using Assert = NUnit.Framework.Legacy.ClassicAssert; 3 | -------------------------------------------------------------------------------- /explainpowershell.frontend/wwwroot/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Jawz84/explainpowershell/HEAD/explainpowershell.frontend/wwwroot/favicon.ico -------------------------------------------------------------------------------- /explainpowershell.frontend/InternalsVisibleTo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("explainpowershell.frontend.tests")] 4 | -------------------------------------------------------------------------------- /explainpowershell.models/Code.cs: -------------------------------------------------------------------------------- 1 | namespace explainpowershell.models 2 | { 3 | public class Code 4 | { 5 | public string? PowershellCode { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /explainpowershell.models/Module.cs: -------------------------------------------------------------------------------- 1 | namespace explainpowershell.models 2 | { 3 | public class Module 4 | { 5 | public string? ModuleName { get; set; } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /explainpowershell.frontend/Clients/AiExplanationResponse.cs: -------------------------------------------------------------------------------- 1 | namespace explainpowershell.frontend.Clients; 2 | 3 | public sealed class AiExplanationResponse 4 | { 5 | public string AiExplanation { get; set; } = string.Empty; 6 | 7 | public string ModelName { get; set; } = string.Empty; 8 | } 9 | -------------------------------------------------------------------------------- /explainpowershell.helpcollector/tools/explainpowershell.helpcollector.tools.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | enable 5 | enable 6 | 7 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingExcludedTypes": "Request", 6 | "samplingSettings": { 7 | "isEnabled": true 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "UseDevelopmentStorage=true", 5 | "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated" 6 | }, 7 | "Host": { 8 | "CORS": "*", 9 | "CORSCredentials": false 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "azureFunctions.deploySubpath": "bin/Release/net10.0/publish", 3 | "azureFunctions.projectLanguage": "C#", 4 | "azureFunctions.projectRuntime": "~4", 5 | "debug.internalConsoleOptions": "neverOpen", 6 | "azureFunctions.preDeployTask": "publish", 7 | "azurite.location": ".azurite" 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "azurite.azurite", 4 | "ms-azuretools.vscode-azurefunctions", 5 | "ms-dotnettools.blazorwasm-companion", 6 | "ms-dotnettools.csharp", 7 | "ms-dotnettools.vscode-dotnet-runtime", 8 | "ms-vscode.powershell", 9 | "vsls-contrib.codetour" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.github/workflows/greetings.yml: -------------------------------------------------------------------------------- 1 | name: Greetings 2 | 3 | on: [pull_request, issues] 4 | 5 | jobs: 6 | greeting: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/first-interaction@v1 10 | with: 11 | repo-token: ${{ secrets.GITHUB_TOKEN }} 12 | issue-message: 'Hi! Thanks for your issue!' 13 | pr-message: 'Hi! Thanks for your PR!' 14 | -------------------------------------------------------------------------------- /explainpowershell.models/explainpowershell.models.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | enable 5 | enable 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /explainpowershell.frontend/App.razor: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |

Sorry, there's nothing at this address.

8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Test-IsAzuriteUp.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot/Test-IsPrerequisitesRunning.ps1 2 | 3 | $IsAzuriteUp = Test-IsPrerequisitesRunning -ports 10002 4 | 5 | if ( $IsAzuriteUp ) { 6 | Write-Host "OK - Azurite table service available on localhost:10002" -ForegroundColor Green 7 | } 8 | else { 9 | Write-Warning "Azurite Table service not found on localhost:10002. Make sure Azurite table service is running and accessible." 10 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Services/IAiExplanationService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using explainpowershell.models; 4 | 5 | namespace explainpowershell.analysisservice.Services 6 | { 7 | public interface IAiExplanationService 8 | { 9 | Task<(string? explanation, string? modelName)> GenerateAsync(string powershellCode, AnalysisResult analysisResult, CancellationToken cancellationToken = default); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /explainpowershell.models/Explanation.cs: -------------------------------------------------------------------------------- 1 | namespace explainpowershell.models 2 | { 3 | public class Explanation 4 | { 5 | public string? OriginalExtent { get; set; } 6 | public string? CommandName { get; set; } 7 | public string? Description { get; set; } 8 | public HelpEntity? HelpResult { get; set; } 9 | public string? Id { get; set; } 10 | public string? ParentId { get; set; } 11 | public string? TextToHighlight { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /explainpowershell.azureinfra/parameters.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#", 3 | "contentVersion": "1.0.0.0", 4 | "parameters": { 5 | "functionAppName": { 6 | "value": "" 7 | }, 8 | "appServicePlanName": { 9 | "value": "" 10 | }, 11 | "storageAccountName": { 12 | "value": "" 13 | }, 14 | "location": { 15 | "value": "" 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /explainpowershell.frontend/_Imports.razor: -------------------------------------------------------------------------------- 1 | @using System.Net.Http 2 | @using System.Net.Http.Json 3 | @using Microsoft.AspNetCore.Components.Forms 4 | @using Microsoft.AspNetCore.Components.Routing 5 | @using Microsoft.AspNetCore.Components.Web 6 | @using Microsoft.AspNetCore.Components.Web.Virtualization 7 | @using Microsoft.AspNetCore.Components.WebAssembly.Http 8 | @using Microsoft.JSInterop 9 | @using explainpowershell.frontend 10 | @using explainpowershell.frontend.Shared 11 | @using explainpowershell.models 12 | @using MudBlazor -------------------------------------------------------------------------------- /explainpowershell.models/AnalysisResult.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace explainpowershell.models 4 | { 5 | public class AnalysisResult 6 | { 7 | public string? ExpandedCode { get; set; } 8 | public List Explanations { get; set; } = new List(); 9 | public List DetectedModules { get; set; } = new List(); 10 | public string? ParseErrorMessage { get; set; } 11 | public string? AiExplanation { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /research/GetRequests.ps1: -------------------------------------------------------------------------------- 1 | $a = (az monitor app-insights query --apps 8ae245a8-42fa-48bc-9ac0-d1d55326f12b --analytics-query 'traces | where message startswith ''PowerShell''|project message' --offset 30d |convertfrom-json).tables.rows 2 | $commands = $a.foreach{$_.split(':',1, $null)[1].trim()}| Sort-Object -Unique 3 | $requestedCommands = $commands.foreach{if($_ -match "\w+-\w+") {$matches[0]}}| Sort-Object -Unique 4 | #$requestedCommands 5 | $unknownCommdands = $requestedCommands.where{-not (Get-Command $_ -ea silentlycontinue)} 6 | $unknownCommdands -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/testfiles/myTestModule.psm1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | A dummy function to test stuff with. 4 | .DESCRIPTION 5 | A longer description of the dummy test function, its purpose, common use cases, etc. 6 | .LINK 7 | https://www.explainpowershell.com 8 | .EXAMPLE 9 | Get-TestInfo -IsDummy 10 | Get-TestInfo will return if the switch parameter 'IsDummy' is present or not. This will return $true 11 | #> 12 | function Get-TestInfo { 13 | [CmdletBinding()] 14 | param ( 15 | [Switch]$IsDummy 16 | ) 17 | return $IsDummy 18 | } -------------------------------------------------------------------------------- /explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs: -------------------------------------------------------------------------------- 1 | using System.Threading; 2 | using System.Threading.Tasks; 3 | using explainpowershell.models; 4 | 5 | namespace explainpowershell.frontend.Clients; 6 | 7 | public interface ISyntaxAnalyzerClient 8 | { 9 | Task> AnalyzeAsync(Code code, CancellationToken cancellationToken = default); 10 | 11 | Task> GetAiExplanationAsync( 12 | Code code, 13 | AnalysisResult analysisResult, 14 | CancellationToken cancellationToken = default); 15 | } 16 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/testfiles/myConflictingTestModule.psm1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | A function that is ALSO a testfunction, just like myTestModule\Get-TestInfo. 4 | .DESCRIPTION 5 | A funnction to test our clobber support with 6 | .LINK 7 | https://www.explainpowershell.com 8 | .EXAMPLE 9 | Get-TestInfo -IsAlsoADummy 10 | Get-TestInfo will return if the switch parameter 'IsAlsoADummy' is present or not. This will return $true 11 | #> 12 | function Get-TestInfo { 13 | [CmdletBinding()] 14 | param ( 15 | [Switch]$IsAlsoADummy 16 | ) 17 | return $IsAlsoADummy 18 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Test-IsPrerequisitesRunning.ps1: -------------------------------------------------------------------------------- 1 | function Test-IsPrerequisitesRunning { 2 | param( 3 | [parameter(mandatory)] 4 | [int[]]$ports 5 | ) 6 | 7 | $result = $true 8 | 9 | try { 10 | foreach ($port in $ports) { 11 | $tcpClient = New-Object System.Net.Sockets.TcpClient 12 | $result = $result -and $tcpClient.ConnectAsync('127.0.0.1', $port).Wait(100) 13 | } 14 | } 15 | catch { 16 | return $false 17 | } 18 | finally { 19 | $tcpClient.Dispose() 20 | } 21 | 22 | return $result 23 | } -------------------------------------------------------------------------------- /explainpowershell.models/ParameterSetData.cs: -------------------------------------------------------------------------------- 1 | namespace explainpowershell.models 2 | { 3 | public class ParameterSetData 4 | { 5 | public string? ParameterSetName { get; set; } 6 | public bool IsMandatory { get; set; } 7 | public int Position { get; set; } 8 | public bool ValueFromPipeline { get; set; } 9 | public bool ValueFromPipelineByPropertyName { get; set; } 10 | public bool ValueFromRemainingArguments { get; set; } 11 | public string? HelpMessage { get; set; } 12 | public string? HelpMessageBaseName { get; set; } 13 | public string? HelpMessageResourceId { get; set; } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_helpers.tests.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | 3 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 4 | { 5 | public class GetAstVisitorExplainer_helpersTests 6 | { 7 | [Test] 8 | public void ShouldReturnListOfVerbs() 9 | { 10 | var verbList = AstVisitorExplainer.GetApprovedVerbs(); 11 | 12 | Assert.That(verbList.Contains("Get")); 13 | Assert.That(verbList.Contains("Convert")); 14 | Assert.That(verbList.Contains("Enable")); 15 | Assert.That(verbList.Count > 90); // there are ~100 approved verbs 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /explainpowershell.frontend/explainpowershell.frontend.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /explainpowershell.helpcollector/New-SasToken.ps1: -------------------------------------------------------------------------------- 1 | function New-SasToken { 2 | param( 3 | $ResourceGroupName, 4 | $StorageAccountName 5 | ) 6 | 7 | $context = (Get-AzStorageAccount -ResourceGroupName $ResourceGroupName -AccountName $StorageAccountName).context 8 | 9 | $sasSplat = @{ 10 | Service = 'Table' 11 | ResourceType = 'Service', 'Container', 'Object' 12 | Permission = 'racwdlup' # https://learn.microsoft.com/en-us/powershell/module/az.storage/new-azstorageaccountsastoken#-permission 13 | StartTime = (Get-Date) 14 | ExpiryTime = (Get-Date).AddMinutes(30) 15 | Context = $context 16 | } 17 | 18 | return New-AzStorageAccountSASToken @sasSplat 19 | } -------------------------------------------------------------------------------- /explainpowershell.models/HelpMetaData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Azure; 3 | using Azure.Data.Tables; 4 | 5 | namespace explainpowershell.models 6 | { 7 | public class HelpMetaData : ITableEntity 8 | { 9 | public int NumberOfCommands { get; set; } 10 | public int NumberOfAboutArticles { get; set; } 11 | public int NumberOfModules { get; set; } 12 | public string? ModuleNames { get; set; } 13 | public string? LastPublished {get; set;} 14 | 15 | // ITableEntity 16 | public required string PartitionKey { get; set; } 17 | public required string RowKey { get; set; } 18 | public DateTimeOffset? Timestamp { get; set; } 19 | public ETag ETag { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /explainpowershell.models/ParameterData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json; 3 | 4 | namespace explainpowershell.models 5 | { 6 | public class ParameterData 7 | { 8 | public string? Aliases { get; set; } 9 | public string? DefaultValue { get; set; } 10 | public string? Description { get; set; } 11 | public string? Globbing { get; set; } 12 | public bool? IsDynamic { get; set; } 13 | public string? Name { get; set; } 14 | public string? PipelineInput { get; set; } 15 | public string? Position { get; set; } 16 | public string? Required { get; set; } 17 | public bool? SwitchParameter { get; set; } 18 | public string? TypeName { get; set; } 19 | public JsonElement ParameterSets { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | false 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Helpers/TableClientFactory.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Azure.Data.Tables; 3 | 4 | namespace explainpowershell.analysisservice 5 | { 6 | internal static class TableClientFactory 7 | { 8 | private const string StorageConnectionSetting = "AzureWebJobsStorage"; 9 | 10 | public static TableClient Create(string tableName) 11 | { 12 | var connectionString = Environment.GetEnvironmentVariable(StorageConnectionSetting); 13 | if (string.IsNullOrWhiteSpace(connectionString)) 14 | { 15 | throw new InvalidOperationException($"Configuration value '{StorageConnectionSetting}' is not set."); 16 | } 17 | 18 | var serviceClient = new TableServiceClient(connectionString); 19 | return serviceClient.GetTableClient(tableName); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Get-MetaData.ps1: -------------------------------------------------------------------------------- 1 | function Get-MetaData { 2 | <# 3 | .SYNOPSIS 4 | Gets metadata from the local Analysis Service. 5 | 6 | .DESCRIPTION 7 | Calls the Analysis Service HTTP endpoint to retrieve metadata used by tests. 8 | Use -Refresh to request regenerated metadata. 9 | 10 | .PARAMETER Refresh 11 | When specified, adds '?refresh=true' to the request URI. 12 | 13 | .EXAMPLE 14 | Get-MetaData 15 | Retrieves metadata from the default endpoint. 16 | 17 | .EXAMPLE 18 | Get-MetaData -Refresh 19 | Retrieves metadata and forces a refresh on the service. 20 | #> 21 | param( 22 | [switch] $Refresh 23 | ) 24 | 25 | $uri = 'http://127.0.0.1:7071/api/MetaData' 26 | 27 | if ( $Refresh ) { 28 | $uri += '?refresh=true' 29 | } 30 | 31 | Invoke-RestMethod -Uri $uri 32 | } 33 | -------------------------------------------------------------------------------- /explainpowershell.helpcollector/BulkHelpCollector.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [switch] $Force, 4 | $modulesToProcess = ( Get-Module -ListAvailable ) #| Where-Object Name -notmatch "az.*|micrsoft.*|pester|editorservices|plaser|threadjob|posh-git" 5 | ) 6 | 7 | foreach ($module in $modulesToProcess) { 8 | $fileName = "$PSScriptRoot/help.$($module.Name).cache.user" 9 | 10 | if ($Force -or !(Test-Path $fileName) -or ((Get-Item $fileName).Length -eq 0)) { 11 | Write-Host -ForegroundColor Green "Collecting help data for module '$($module.Name)'.." 12 | ./helpcollector.ps1 -ModulesToProcess $module 13 | | ConvertTo-Json 14 | | Out-File -path $fileName -Force:$Force 15 | } 16 | else { 17 | Write-Host "Detected cache file '$fileName', skipping collecting help data. Use '-Force' or remove cache file to refresh help data." 18 | } 19 | } -------------------------------------------------------------------------------- /explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | latest 5 | enable 6 | enable 7 | false 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /explainpowershell.frontend/Clients/ApiCallResult.cs: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | 3 | using System.Net; 4 | 5 | namespace explainpowershell.frontend.Clients; 6 | 7 | public sealed class ApiCallResult 8 | { 9 | private ApiCallResult() { } 10 | 11 | public bool IsSuccess { get; private init; } 12 | 13 | public HttpStatusCode StatusCode { get; private init; } 14 | 15 | public T? Value { get; private init; } 16 | 17 | public string? ErrorMessage { get; private init; } 18 | 19 | public static ApiCallResult Success(T value, HttpStatusCode statusCode = HttpStatusCode.OK) 20 | => new() 21 | { 22 | IsSuccess = true, 23 | StatusCode = statusCode, 24 | Value = value, 25 | ErrorMessage = null 26 | }; 27 | 28 | public static ApiCallResult Failure(string? errorMessage, HttpStatusCode statusCode) 29 | => new() 30 | { 31 | IsSuccess = false, 32 | StatusCode = statusCode, 33 | Value = default, 34 | ErrorMessage = errorMessage 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Jos Koelewijn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Start-FunctionApp.ps1: -------------------------------------------------------------------------------- 1 | . $PSScriptRoot/Test-IsPrerequisitesRunning.ps1 2 | 3 | function IsTimedOut { 4 | param( 5 | [datetime]$Start, 6 | [int]$TimeOut 7 | ) 8 | 9 | $IsTimedOut = (Get-Date) -gt $Start.AddSeconds($TimeOut) 10 | 11 | if ($IsTimedOut) { 12 | Write-Warning "Timed out after '$TimeOut' seconds." 13 | } 14 | 15 | return $IsTimedOut 16 | } 17 | 18 | $timeOut = 120 19 | 20 | Write-Host "Checking if function app is running.." 21 | if (-not (Test-IsPrerequisitesRunning -ports 7071)) { 22 | $start = Get-Date 23 | try { 24 | Write-Host "Starting Function App.." 25 | Start-ThreadJob -ArgumentList $PSScriptRoot { 26 | Push-Location "$($args[0])/../explainpowershell.analysisservice/" 27 | func host start 28 | } 29 | 30 | do { 31 | Start-Sleep -Seconds 2 32 | } until ((IsTimedOut -Start $start -TimeOut $timeOut) -or (Test-IsPrerequisitesRunning -ports 7071)) 33 | } 34 | catch { 35 | throw 36 | } 37 | } 38 | 39 | Write-Host "OK - Function App running" -ForegroundColor Green -------------------------------------------------------------------------------- /explainpowershell.frontend/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Components.WebAssembly.Hosting; 2 | using Microsoft.Extensions.Configuration; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using Microsoft.Extensions.Logging; 5 | using explainpowershell.frontend.Clients; 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Net.Http; 9 | using System.Text; 10 | using System.Threading.Tasks; 11 | using MudBlazor.Services; 12 | 13 | namespace explainpowershell.frontend 14 | { 15 | public class Program 16 | { 17 | public static async Task Main(string[] args) 18 | { 19 | var builder = WebAssemblyHostBuilder.CreateDefault(args); 20 | builder.RootComponents.Add("#app"); 21 | 22 | builder.Services.AddScoped(sp => new HttpClient { 23 | BaseAddress = new Uri( 24 | builder.Configuration.GetValue("BaseAddress"))}); 25 | 26 | builder.Services.AddScoped(); 27 | 28 | builder.Services.AddMudServices(); 29 | 30 | await builder.Build().RunAsync(); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/codegen/Accelerators.cs_code_generator.ps1: -------------------------------------------------------------------------------- 1 | $pre = @' 2 | // NB: This file is auto-generated by 'Accelerators.cs_code_generator.ps1'. Your changes here will be overwritten. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | 8 | namespace ExplainPowershell.SyntaxAnalyzer 9 | { 10 | public static partial class Helpers { 11 | public static (string, string) ResolveAccelerator(string typeName) 12 | { 13 | var dict = new Dictionary(StringComparer.OrdinalIgnoreCase) 14 | { 15 | '@ 16 | 17 | $post = @' 18 | }; 19 | 20 | return dict.ContainsKey(typeName) ? 21 | (dict.Keys.First(k => string.Equals(typeName, k, StringComparison.OrdinalIgnoreCase)), dict[typeName]) : 22 | (null, null); 23 | } 24 | } 25 | } 26 | '@ 27 | 28 | $body = [psobject].Assembly.GetType("System.Management.Automation.TypeAccelerators")::Get.GetEnumerator() | ForEach-Object { 29 | @" 30 | { `"$($_.Key)`", `"$($_.Value.FullName)`" }, 31 | "@ 32 | } 33 | 34 | 35 | $pre, $body, $post | Out-File -FilePath "$PSScriptRoot\..\Helpers\Accelerators.generated.cs" -Force -------------------------------------------------------------------------------- /explainpowershell.analysisservice/codegen/TokenKind.cs_code_generator.ps1: -------------------------------------------------------------------------------- 1 | # TokenKind.cs code generator 2 | 3 | $pre = @' 4 | // NB: This file is auto-generated by 'TokenKind.cs_code_generator.ps1'. Your changes here will be overwritten. 5 | 6 | using System.Management.Automation.Language; 7 | 8 | namespace ExplainPowershell.SyntaxAnalyzer 9 | { 10 | public static partial class Helpers { 11 | public static (string, string) TokenExplainer(TokenKind tokenKind) 12 | { 13 | var description = string.Empty; 14 | var helpQuery = string.Empty; 15 | 16 | switch (tokenKind) 17 | { 18 | '@ 19 | 20 | $post = @' 21 | } 22 | return (description, helpQuery); 23 | } 24 | } 25 | } 26 | '@ 27 | 28 | $tokenKind = Import-Csv -Delimiter ';' -Path "$PSScriptRoot\TokenKind.csv" 29 | 30 | $generatedCode = $tokenKind | ForEach-Object { 31 | @" 32 | case TokenKind.$($_.Name): 33 | description = "$($_.Explanation)"; 34 | helpQuery = "$($_.HelpQuery)"; 35 | break; 36 | "@ 37 | } 38 | 39 | $pre, $generatedCode, $post | Out-File -FilePath "$PSScriptRoot\..\Helpers\TokenKind.generated.cs" -Force -------------------------------------------------------------------------------- /explainpowershell.analysisservice/codegen/Alias.cs_code_generator.ps1: -------------------------------------------------------------------------------- 1 | $modules = Get-Content "$PSScriptRoot/../../explainpowershell.metadata/defaultModules.json" | ConvertFrom-Json 2 | 3 | $pre = @' 4 | // NB: This file is auto-generated by 'Alias.cs_code_generator.ps1'. Your changes here will be overwritten. 5 | 6 | using System; 7 | using System.Collections.Generic; 8 | 9 | namespace ExplainPowershell.SyntaxAnalyzer 10 | { 11 | public static partial class Helpers { 12 | public static string ResolveAlias(string cmdName) 13 | { 14 | var dict = new Dictionary(StringComparer.OrdinalIgnoreCase) 15 | { 16 | '@ 17 | 18 | $post = @' 19 | }; 20 | 21 | return dict.ContainsKey(cmdName) ? dict[cmdName] : null; 22 | } 23 | } 24 | } 25 | '@ 26 | 27 | $body = Get-Alias 28 | # We only want to 'recognize' aliases from the core PowerShell modules, so we exclude other modules' aliases here 29 | | Where-Object { -not $_.Source -or $_.Source -in $modules.Name } 30 | | ForEach-Object { 31 | @" 32 | { `"$($_.Name)`", `"$($_.Definition)`" }, 33 | "@ 34 | } 35 | 36 | 37 | $pre, $body, $post | Out-File -FilePath "$PSScriptRoot\..\Helpers\Alias.generated.cs" -Force -------------------------------------------------------------------------------- /explainpowershell.models/HelpEntity.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Azure; 3 | using Azure.Data.Tables; 4 | 5 | namespace explainpowershell.models 6 | { 7 | public class HelpEntity : ITableEntity 8 | { 9 | public string? Aliases { get; set; } 10 | public string? CommandName { get; set; } 11 | public string? DefaultParameterSet { get; set; } 12 | public string? Description { get; set; } 13 | public string? DocumentationLink { get; set; } 14 | public string? InputTypes { get; set; } 15 | public string? ModuleName { get; set; } 16 | public string? ModuleProjectUri { get; set; } 17 | public string? ModuleVersion { get; set; } 18 | public string? Parameters { get; set; } 19 | public string? ParameterSetNames { get; set; } 20 | public string? RelatedLinks { get; set; } 21 | public string? ReturnValues { get; set; } 22 | public string? Synopsis { get; set; } 23 | public string? Syntax { get; set; } 24 | // ITableEntity 25 | public string? PartitionKey { get; set; } 26 | public string? RowKey { get; set; } 27 | public DateTimeOffset? Timestamp { get; set; } 28 | public ETag ETag { get; set; } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /explainpowershell.frontend/wwwroot/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Explain PowerShell 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 |

Explain PowerShell

19 |

Blazor WASM loading..

20 |
21 |
22 |
23 | 24 |
25 | An unhandled error has occurred. 26 | Reload 27 | 🗙 28 |
29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Helpers/GetBuildDate.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Reflection; 4 | 5 | namespace explainpowershell.analysisservice 6 | { 7 | public static partial class Helpers 8 | { 9 | public static DateTime GetBuildDate(Assembly assembly) 10 | { 11 | // Credit to Gérald Barré: https://www.meziantou.net/getting-the-date-of-build-of-a-dotnet-assembly-at-runtime.htm 12 | const string BuildVersionMetadataPrefix = "+build"; 13 | 14 | var attribute = assembly.GetCustomAttribute(); 15 | if (attribute?.InformationalVersion != null) 16 | { 17 | var value = attribute.InformationalVersion; 18 | var index = value.IndexOf(BuildVersionMetadataPrefix); 19 | if (index > 0) 20 | { 21 | value = value[(index + BuildVersionMetadataPrefix.Length)..]; 22 | if (DateTime.TryParseExact(value, "yyyyMMddHHmmss", CultureInfo.InvariantCulture, DateTimeStyles.None, out var result)) 23 | { 24 | return result; 25 | } 26 | } 27 | } 28 | 29 | return default; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/tests/GetParameterSetData.tests.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using System.IO; 3 | using System; 4 | using System.Collections.Generic; 5 | using System.Text.Json; 6 | using System.Linq; 7 | using ExplainPowershell.SyntaxAnalyzer; 8 | using explainpowershell.models; 9 | 10 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 11 | { 12 | public class GetParameterSetDataTests 13 | { 14 | private HelpEntity helpItem; 15 | private List doc; 16 | 17 | [SetUp] 18 | public void Setup() 19 | { 20 | var filename = "../../../testfiles/test_get_help.json"; 21 | var json = File.ReadAllText(filename); 22 | helpItem = JsonSerializer.Deserialize(json); 23 | doc = JsonSerializer.Deserialize>(helpItem.Parameters); 24 | } 25 | 26 | [Test] 27 | public void ShouldReadParameterSetDetails() 28 | { 29 | var parameterData = doc[4]; // The -Full parameter 30 | 31 | Assert.AreEqual( 32 | Helpers.GetParameterSetData( 33 | parameterData, 34 | helpItem.ParameterSetNames.Split(", ")).FirstOrDefault().ParameterSetName, 35 | "AllUsersView" 36 | ); 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /explainpowershell.frontend/Shared/MetaDataInfo.razor.css: -------------------------------------------------------------------------------- 1 | .navbar-toggler { 2 | background-color: rgba(255, 255, 255, 0.1); 3 | } 4 | 5 | .top-row { 6 | height: 3.5rem; 7 | background-color: rgba(0,0,0,0.4); 8 | } 9 | 10 | .navbar-brand { 11 | font-size: 1.1rem; 12 | } 13 | 14 | .oi { 15 | width: 2rem; 16 | font-size: 1.1rem; 17 | vertical-align: text-top; 18 | top: -2px; 19 | } 20 | 21 | .nav-item { 22 | font-size: 0.9rem; 23 | padding-bottom: 0.5rem; 24 | } 25 | 26 | .nav-item:first-of-type { 27 | padding-top: 1rem; 28 | } 29 | 30 | .nav-item:last-of-type { 31 | padding-bottom: 1rem; 32 | } 33 | 34 | .nav-item ::deep a { 35 | color: #d7d7d7; 36 | border-radius: 4px; 37 | height: 3rem; 38 | display: flex; 39 | align-items: center; 40 | line-height: 3rem; 41 | } 42 | 43 | .nav-item ::deep a.active { 44 | background-color: rgba(255,255,255,0.25); 45 | color: white; 46 | } 47 | 48 | .nav-item ::deep a:hover { 49 | background-color: rgba(255,255,255,0.1); 50 | color: white; 51 | } 52 | 53 | @media (min-width: 641px) { 54 | .navbar-toggler { 55 | display: none; 56 | } 57 | 58 | .collapse { 59 | /* Never collapse the sidebar for wide screens */ 60 | display: block; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Get-MetaData.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | . $PSCommandPath.Replace('.Tests.ps1', '.ps1') 3 | . $PSScriptRoot/Start-FunctionApp.ps1 4 | . $PSScriptRoot/Test-IsAzuriteUp.ps1 5 | } 6 | 7 | Describe "Get-MetaData" { 8 | It "Calculates Data about the database" { 9 | $metaData = Get-MetaData -Refresh 10 | 11 | $metaData.psobject.Properties.Name | Should -Be @( 12 | 'NumberOfCommands' 13 | 'NumberOfAboutArticles' 14 | 'NumberOfModules' 15 | 'ModuleNames' 16 | 'LastPublished' 17 | 'PartitionKey' 18 | 'RowKey' 19 | 'Timestamp' 20 | 'ETag') 21 | $metaData.NumberOfCommands | Should -Not -Be 0 22 | $metaData.LastPublished | Should -Not -BeNullOrEmpty 23 | } 24 | 25 | It "Returns cached Data about the database" { 26 | $metaData = Get-MetaData 27 | 28 | $metaData.psobject.Properties.Name | Should -Be @( 29 | 'NumberOfCommands' 30 | 'NumberOfAboutArticles' 31 | 'NumberOfModules' 32 | 'ModuleNames' 33 | 'LastPublished' 34 | 'PartitionKey' 35 | 'RowKey' 36 | 'Timestamp' 37 | 'ETag') 38 | $metaData.NumberOfCommands | Should -Not -Be 0 39 | $metaData.LastPublished | Should -Not -BeNullOrEmpty 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/deploy_azure_infra.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | name: Deploy Azure infra 4 | jobs: 5 | deploy: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: azure/login@v2 10 | with: 11 | creds: ${{ secrets.AZURE_SERVICE_PRINCIPAL }} 12 | - run: | 13 | $params = Get-Content ./explainpowershell.azureinfra/parameters.json | ConvertFrom-Json 14 | $params.parameters.functionAppName.value = "${{ secrets.FUNCTION_APP_NAME }}".trim() 15 | $params.parameters.appServicePlanName.value = "asp-" + "${{ secrets.FUNCTION_APP_NAME }}".trim() 16 | $params.parameters.storageAccountName.value = "${{ secrets.STORAGE_ACCOUNT_NAME }}".trim() 17 | $params.parameters.location.value = az group show --name "${{ secrets.RESOURCE_GROUP_NAME }}".trim() --query location -o tsv 18 | $params | ConvertTo-Json | Out-File ./explainpowershell.azureinfra/parameters.json -force 19 | name: Overwrite ARM parameters 20 | shell: pwsh 21 | - uses: Azure/arm-deploy@v1 22 | name: Deploy Bicep template for Storage and FunctionApp 23 | with: 24 | parameters: ./explainpowershell.azureinfra/parameters.json 25 | scope: resourcegroup 26 | resourceGroupName: ${{ secrets.RESOURCE_GROUP_NAME }} 27 | template: ./explainpowershell.azureinfra/template.bicep 28 | deploymentMode: incremental 29 | 30 | 31 | -------------------------------------------------------------------------------- /explainpowershell.frontend.tests/TreeTests.cs: -------------------------------------------------------------------------------- 1 | using explainpowershell.models; 2 | 3 | namespace explainpowershell.frontend.tests; 4 | 5 | public class TreeTests 6 | { 7 | [Test] 8 | public void GenerateTree_TreatsNullAndEmptyParentIdAsRoot() 9 | { 10 | var explanations = new List 11 | { 12 | new() { Id = "1", ParentId = "", CommandName = "root-empty" }, 13 | new() { Id = "2", ParentId = null, CommandName = "root-null" }, 14 | new() { Id = "1.1", ParentId = "1", CommandName = "child" }, 15 | }; 16 | 17 | var tree = explanations.GenerateTree(e => e.Id, e => e.ParentId); 18 | 19 | Assert.That(tree, Has.Count.EqualTo(2)); 20 | 21 | var rootEmpty = tree.Single(t => t.Value is not null && t.Value.Id == "1"); 22 | Assert.That(rootEmpty.Value, Is.Not.Null); 23 | Assert.That(rootEmpty.Children, Is.Not.Null); 24 | var rootEmptyChildren = rootEmpty.Children!; 25 | Assert.That(rootEmptyChildren, Has.Count.EqualTo(1)); 26 | var onlyChild = rootEmptyChildren.Single(); 27 | Assert.That(onlyChild.Value, Is.Not.Null); 28 | Assert.That(onlyChild.Value!.Id, Is.EqualTo("1.1")); 29 | 30 | var rootNull = tree.Single(t => t.Value is not null && t.Value.Id == "2"); 31 | Assert.That(rootNull.Value, Is.Not.Null); 32 | Assert.That(rootNull.Children, Is.Null); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /explainpowershell.frontend/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "iisSettings": { 3 | "windowsAuthentication": false, 4 | "anonymousAuthentication": true, 5 | "iisExpress": { 6 | "applicationUrl": "http://localhost:63535", 7 | "sslPort": 44333 8 | } 9 | }, 10 | "profiles": { 11 | "IIS Express": { 12 | "commandName": "IISExpress", 13 | "launchBrowser": true, 14 | "environmentVariables": { 15 | "ASPNETCORE_ENVIRONMENT": "Development" 16 | }, 17 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}" 18 | }, 19 | "DefaultBlazor.Wasm": { 20 | "commandName": "Project", 21 | "launchBrowser": false, 22 | "environmentVariables": { 23 | "ASPNETCORE_ENVIRONMENT": "Development" 24 | }, 25 | "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", 26 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 27 | "dotnetRunMessages": "true" 28 | }, 29 | "WSL": { 30 | "commandName": "WSL2", 31 | "launchBrowser": true, 32 | "launchUrl": "https://localhost:5001", 33 | "environmentVariables": { 34 | "ASPNETCORE_ENVIRONMENT": "Development", 35 | "ASPNETCORE_URLS": "https://localhost:5001;http://localhost:5000" 36 | }, 37 | "distributionName": "" 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Repositories/IHelpRepository.cs: -------------------------------------------------------------------------------- 1 | using explainpowershell.models; 2 | 3 | namespace ExplainPowershell.SyntaxAnalyzer.Repositories 4 | { 5 | /// 6 | /// Repository interface for accessing PowerShell help data 7 | /// 8 | public interface IHelpRepository 9 | { 10 | /// 11 | /// Query help data for a specific command 12 | /// 13 | /// The command name to query 14 | /// The help entity if found, null otherwise 15 | HelpEntity? GetHelpForCommand(string commandName); 16 | 17 | /// 18 | /// Query help data for a specific command in a specific module 19 | /// 20 | /// The command name to query 21 | /// The module name containing the command 22 | /// The help entity if found, null otherwise 23 | HelpEntity? GetHelpForCommand(string commandName, string moduleName); 24 | 25 | /// 26 | /// Query help data for commands matching a prefix 27 | /// 28 | /// The command name prefix to query 29 | /// List of matching help entities 30 | List GetHelpForCommandRange(string commandName); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/tests/MatchParam.tests.cs: -------------------------------------------------------------------------------- 1 | using NUnit.Framework; 2 | using System.IO; 3 | using System; 4 | using ExplainPowershell.SyntaxAnalyzer; 5 | 6 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 7 | { 8 | public class MatchParamTests 9 | { 10 | private string filename; 11 | private string json; 12 | 13 | [SetUp] 14 | public void Setup() 15 | { 16 | filename = "../../../testfiles/parameterinfo.json"; 17 | json = File.ReadAllText(filename); 18 | } 19 | 20 | [Test] 21 | public void ShouldThrowIfAmbiguous() 22 | { 23 | var param = "a"; 24 | Assert.Throws(() => Helpers.MatchParam(param, json)); 25 | } 26 | 27 | [Test] 28 | public void ShouldResolveParameterIfAlias() 29 | { 30 | var param = "s"; 31 | Assert.AreEqual(Helpers.MatchParam(param, json).Name, "Recurse"); 32 | } 33 | 34 | [Test] 35 | public void ShouldNotMatchNoneAlias() 36 | { 37 | var param = "none"; 38 | Assert.IsNull(Helpers.MatchParam(param, json)); 39 | } 40 | 41 | [Test] 42 | public void ShouldResolveParamForUnambiguousPartialName() 43 | { 44 | var param = "sy"; 45 | Assert.AreEqual(Helpers.MatchParam(param, json).Name, "System"); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /explainpowershell.metadata/defaultModules.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "Name": "Microsoft.PowerShell.Archive", 4 | "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.archive" 5 | }, 6 | { 7 | "Name": "Microsoft.PowerShell.Host", 8 | "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.host" 9 | }, 10 | { 11 | "Name": "Microsoft.PowerShell.Management", 12 | "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management" 13 | }, 14 | { 15 | "Name": "Microsoft.PowerShell.Security", 16 | "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security" 17 | }, 18 | { 19 | "Name": "Microsoft.PowerShell.Utility", 20 | "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility" 21 | }, 22 | { 23 | "Name": "Microsoft.PowerShell.Core", 24 | "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core" 25 | }, 26 | { 27 | "Name": "PSReadLine", 28 | "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/psreadline" 29 | }, 30 | { 31 | "Name": "myTestModule", 32 | "ProjectUri": "https://www.explainpowershell.com" 33 | }, 34 | { 35 | "Name": "myConflictingTestModule", 36 | "ProjectUri": "https://www.explainpowershell.com" 37 | } 38 | ] -------------------------------------------------------------------------------- /explainpowershell.frontend/Shared/MainLayout.razor.css: -------------------------------------------------------------------------------- 1 | .page { 2 | position: relative; 3 | display: flex; 4 | flex-direction: column; 5 | } 6 | 7 | .main { 8 | flex: 1; 9 | } 10 | 11 | .sidebar { 12 | background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); 13 | } 14 | 15 | .top-row { 16 | background-color: #f7f7f7; 17 | border-bottom: 1px solid #d6d5d5; 18 | justify-content: flex-end; 19 | height: 3.5rem; 20 | display: flex; 21 | align-items: center; 22 | } 23 | 24 | .top-row ::deep a, .top-row .btn-link { 25 | white-space: nowrap; 26 | margin-left: 1.5rem; 27 | } 28 | 29 | .top-row a:first-child { 30 | overflow: hidden; 31 | text-overflow: ellipsis; 32 | } 33 | 34 | @media (max-width: 640.98px) { 35 | .top-row:not(.auth) { 36 | display: none; 37 | } 38 | 39 | .top-row.auth { 40 | justify-content: space-between; 41 | } 42 | 43 | .top-row a, .top-row .btn-link { 44 | margin-left: 0; 45 | } 46 | } 47 | 48 | @media (min-width: 641px) { 49 | .page { 50 | flex-direction: row; 51 | } 52 | 53 | .sidebar { 54 | width: 250px; 55 | height: 100vh; 56 | position: sticky; 57 | top: 0; 58 | } 59 | 60 | .top-row { 61 | position: sticky; 62 | top: 0; 63 | z-index: 1; 64 | } 65 | 66 | .main > div { 67 | padding-left: 2rem !important; 68 | padding-right: 1.5rem !important; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1: -------------------------------------------------------------------------------- 1 | BeforeAll { 2 | . $PSCommandPath.Replace('.Tests.ps1', '.ps1') 3 | . $PSScriptRoot/Test-IsAzuriteUp.ps1 4 | } 5 | 6 | Describe 'Get-HelpDatabaseData' { 7 | It 'Has help about_.. article data in database' { 8 | $data = Get-HelpDatabaseData -RowKey 'about_pwsh' 9 | 10 | $data.Properties.CommandName | Should -BeExactly 'about_Pwsh' 11 | $data.Properties.DocumentationLink | Should -Match 'https://(learn|docs).microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Pwsh' 12 | $data.Properties.ModuleName | Should -BeNullOrEmpty 13 | $data.Properties.Synopsis | Should -BeExactly 'Explains how to use the pwsh command-line interface. Displays the command-line parameters and describes the syntax.' 14 | } 15 | 16 | $commandsToCheck = Get-Content "$PSScriptRoot/../explainpowershell.metadata/defaultModules.json" 17 | | ConvertFrom-Json 18 | | Where-Object {$_.name -notmatch '^myTestModule$|^myConflictingTestModule$'} 19 | | ForEach-Object { 20 | $cmd = Get-Command -Module $_.name | Select-Object -First 1 21 | [psobject]@{ 22 | CommandName = $cmd.Name 23 | ModuleName = $cmd.ModuleName 24 | } 25 | } 26 | 27 | It "For default module '', there should be help information available for at least one command: ''" -ForEach $commandsToCheck { 28 | $data = Get-HelpDatabaseData -RowKey $CommandName.ToLower() 29 | $data | Should -Not -BeNullOrEmpty 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /explainpowershell.helpcollector/BulkHelpCacheUploader.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [string] $Path = $PSScriptRoot, 4 | [switch] $Force, 5 | [switch] $IsDev 6 | ) 7 | 8 | foreach ($file in (Get-ChildItem -Path $Path -Filter '*.cache.user' )) { 9 | $fileName = $file.FullName 10 | 11 | if (-not $Force) { 12 | $item = Get-Content $fileName | ConvertFrom-Json | Select-Object -First 1 -ExpandProperty CommandName | ForEach-Object ToLower 13 | } 14 | 15 | if ($force -or -not (Get-HelpDatabaseData -RowKey $item -IsProduction).Properties.ModuleVersion) { 16 | Write-Host "Writing help for module '$($File.Name)' to Azure table.." 17 | try { 18 | ./helpwriter.ps1 -HelpDataCacheFilename $fileName -IsProduction:$(-not $IsDev) 19 | } 20 | catch { 21 | Write-Warning "Error in processing module '$($file.Name)': $($_.Exception.Message)" 22 | } 23 | } 24 | else { 25 | Write-Host "Skipping '$($File.Name)', because that data is already present in Azure. (use -Force switch to overwrite)" 26 | } 27 | } 28 | 29 | if (-not $IsDev) { 30 | # Trigger refresh of database metadata, so ExplainPowerShell can show the newly added modules and updated cmdlet count. 31 | if (-not (Get-Module -ListAvailable Az.Functions)) { 32 | Install-Module Az.Functions -Force 33 | } 34 | 35 | Get-AzFunctionApp 36 | | Where-Object { $_.Name -match 'powershellexplainer' -and $_.Status -eq 'running' } 37 | | ForEach-Object { Invoke-RestMethod -Uri "https://$($_.DefaultHostName)/api/MetaData?refresh=true" } 38 | } -------------------------------------------------------------------------------- /explainpowershell.frontend/Shared/ExplainCard.razor: -------------------------------------------------------------------------------- 1 | 2 | 7 | 12 | 13 | @if(@Context.HelpResult != null) 14 | { 15 | @Context.CommandName 16 | } 17 | else { 18 | @Context.CommandName 19 | } 20 | 21 | @Context.Description 22 | @if(! string.IsNullOrEmpty(@Context.HelpResult?.ModuleName)) { 23 | @if(! string.IsNullOrEmpty(@Context.HelpResult?.ModuleProjectUri)) { 24 | Module: @moduleInfo 25 | } 26 | else { 27 | Module: @moduleInfo 28 | } 29 | 30 | } 31 | 32 | 33 | @code { 34 | [Parameter] 35 | public Explanation Context { get; set; } 36 | 37 | string moduleInfo => string.Join(" ", Context.HelpResult?.ModuleName, Context.HelpResult?.ModuleVersion); 38 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Services/AiExplanationOptions.cs: -------------------------------------------------------------------------------- 1 | namespace explainpowershell.analysisservice.Services 2 | { 3 | public sealed class AiExplanationOptions 4 | { 5 | public const string SectionName = "AiExplanation"; 6 | public const string DefaultSystemPrompt = "You are a PowerShell expert that explains PowerShell oneliners to end users in the shortest form possible. You will be given a oneliner, and metadata that contains command documentation. Keep the answer short and stick to what is present in the oneliner. Answer in one sentence only."; 7 | public const string DefaultExamplePrompt = """ 8 | Explain this in just one sentence: 9 | ```json 10 | {"powershellCode":"gps","explanationInfo":{"expandedCode":"Get-Process","explanations":[{"originalExtent":"gps","commandName":"Get-Process","description":"Gets the processes that are running on the local computer."}]}} 11 | ``` 12 | """; 13 | public const string DefaultExampleResponse = "Returns information about all running processes on the local computer, like name, ID, and resource usage."; 14 | 15 | public bool Enabled { get; set; } = true; 16 | public string? Endpoint { get; set; } 17 | public string? DeploymentName { get; set; } 18 | public string? ApiKey { get; set; } 19 | public string? SystemPrompt { get; set; } 20 | public string? ExamplePrompt { get; set; } 21 | public string? ExampleResponse { get; set; } 22 | public int MaxPayloadCharacters { get; set; } = 50000; 23 | public int RequestTimeoutSeconds { get; set; } = 30; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "compounds": [ 4 | { 5 | "name": "Debug solution", 6 | "configurations": ["Debug Function App", "Debug Blazor wasm"], 7 | "outFiles": [ 8 | "./explainpowershell.analysisservice/SyntaxAnalyzer.cs", 9 | "./explainpowershell.frontend/Pages/Index.razor" 10 | ] 11 | } 12 | ], 13 | "configurations": [ 14 | { 15 | "name": "Debug Function App", 16 | "type": "coreclr", 17 | "request": "attach", 18 | "processId": "${command:azureFunctions.pickProcess}", 19 | "internalConsoleOptions": "neverOpen", 20 | "preLaunchTask": "func: host start" 21 | }, 22 | { 23 | "name": "Debug Blazor wasm", 24 | "type": "blazorwasm", 25 | "request": "launch", 26 | "cwd": "${workspaceFolder}/explainpowershell.frontend/", 27 | "browser": "edge" 28 | }, 29 | { 30 | "name": "PowerShell Launch Current File", 31 | "type": "PowerShell", 32 | "request": "launch", 33 | "script": "${file}", 34 | "cwd": "${file}" 35 | }, 36 | { 37 | "name": "Run test suite", 38 | "type": "PowerShell", 39 | "request": "launch", 40 | "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllTests.ps1", 41 | "args": ["-Output Detailed"], 42 | "cwd": "${workspaceFolder}", 43 | "internalConsoleOptions": "openOnSessionStart", 44 | "preLaunchTask": "startstorageemulator" 45 | } 46 | ] 47 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/explainpowershell.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | net10.0 4 | v4 5 | Exe 6 | enable 7 | enable 8 | build$([System.DateTime]::UtcNow.ToString("yyyyMMddHHmmss")) 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | PreserveNewest 21 | 22 | 23 | PreserveNewest 24 | Never 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe "HelpCollector HTML synopsis scraper" { 2 | BeforeAll { 3 | . "$PSScriptRoot/../explainpowershell.helpcollector/HelpCollector.Functions.ps1" 4 | } 5 | 6 | It "Normalizes docs.microsoft.com to learn.microsoft.com and forces https" { 7 | ConvertTo-LearnDocumentationUri -Uri 'http://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_return' | 8 | Should -Be 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_return' 9 | } 10 | 11 | It "Extracts synopsis from summary paragraph" { 12 | $html = @' 13 | 14 | 15 | t 16 | 17 |

about_Return

18 |

Returns from the current scope.

19 | 20 | 21 | '@ 22 | 23 | Get-SynopsisFromHtml -Html $html -Cmd 'return' | Should -Be 'Returns from the current scope.' 24 | } 25 | 26 | It "Extracts synopsis from meta description" { 27 | $html = @' 28 | 29 | 30 | 31 | 32 | 33 |

about_Throw

34 | 35 | 36 | '@ 37 | 38 | Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws a terminating error.' 39 | } 40 | 41 | It "Extracts keyword synopsis from about_language_keywords section" { 42 | $html = @' 43 | 44 | 45 |

about_Language_Keywords

46 |

throw

47 |

Throws an exception.

48 | 49 | 50 | '@ 51 | 52 | Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws an exception.' 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/helpers/Helpers.Logger.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 7 | { 8 | public class LoggerDouble : ILogger, ILogger 9 | { 10 | public List LogEntries { get; } = new List(); 11 | 12 | // Add more of these if they make life easier. 13 | public IEnumerable InformationEntries => 14 | LogEntries.Where(e => e.LogLevel == LogLevel.Information); 15 | 16 | public void Log(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func formatter) 17 | { 18 | LogEntries.Add(new LogEntry(logLevel, eventId, state, exception)); 19 | } 20 | 21 | public bool IsEnabled(LogLevel logLevel) 22 | { 23 | return true; 24 | } 25 | 26 | public IDisposable BeginScope(TState state) 27 | { 28 | return new LoggingScope(); 29 | } 30 | 31 | public class LoggingScope : IDisposable 32 | { 33 | public void Dispose() => GC.SuppressFinalize(this); 34 | } 35 | } 36 | 37 | public class LogEntry 38 | { 39 | public LogEntry(LogLevel logLevel, EventId eventId, object state, Exception exception) 40 | { 41 | LogLevel = logLevel; 42 | EventId = eventId; 43 | State = state; 44 | Exception = exception; 45 | } 46 | 47 | public LogLevel LogLevel { get; } 48 | public EventId EventId { get; } 49 | public object State { get; } 50 | public Exception Exception { get; } 51 | } 52 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Helpers/GetParameterSetData.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json; 3 | using explainpowershell.models; 4 | 5 | namespace ExplainPowershell.SyntaxAnalyzer 6 | { 7 | public static partial class Helpers 8 | { 9 | public static List GetParameterSetData(ParameterData paramData, string[] paramSetNames) 10 | { 11 | List paramSetData = new(); 12 | 13 | foreach (var paramSet in paramSetNames) 14 | { 15 | paramData.ParameterSets.TryGetProperty(paramSet, out JsonElement foundParamSet); 16 | 17 | if (foundParamSet.ValueKind == JsonValueKind.Undefined) 18 | continue; 19 | 20 | paramSetData.Add( 21 | new ParameterSetData() 22 | { 23 | ParameterSetName = paramSet, 24 | HelpMessage = foundParamSet.GetProperty("HelpMessage").GetString() ?? string.Empty, 25 | HelpMessageBaseName = foundParamSet.GetProperty("HelpMessageBaseName").GetString() ?? string.Empty, 26 | HelpMessageResourceId = foundParamSet.GetProperty("HelpMessageResourceId").GetString() ?? string.Empty, 27 | IsMandatory = foundParamSet.GetProperty("IsMandatory").GetBoolean(), 28 | Position = foundParamSet.GetProperty("Position").GetInt32(), 29 | ValueFromPipeline = foundParamSet.GetProperty("ValueFromPipeline").GetBoolean(), 30 | ValueFromPipelineByPropertyName = foundParamSet.GetProperty("ValueFromPipelineByPropertyName").GetBoolean(), 31 | ValueFromRemainingArguments = foundParamSet.GetProperty("ValueFromRemainingArguments").GetBoolean() 32 | }); 33 | } 34 | 35 | return paramSetData; 36 | } 37 | } 38 | } -------------------------------------------------------------------------------- /explainpowershell.helpcollector/tools/DeCompress.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.IO.Compression; 4 | using System.Text; 5 | 6 | namespace explainpowershell.helpcollector.tools 7 | { 8 | public static class DeCompress 9 | { 10 | // Thanks fubo, adapted from their answer: https://stackoverflow.com/questions/7343465/compression-decompression-string-with-c-sharp 11 | public static string Compress(string text) 12 | { 13 | byte[] buffer = Encoding.UTF8.GetBytes(text); 14 | var memoryStream = new MemoryStream(); 15 | using (var gZipStream = new GZipStream(memoryStream, CompressionMode.Compress, true)) 16 | { 17 | gZipStream.Write(buffer, 0, buffer.Length); 18 | } 19 | 20 | memoryStream.Position = 0; 21 | 22 | var compressedData = new byte[memoryStream.Length]; 23 | memoryStream.Read(compressedData, 0, compressedData.Length); 24 | 25 | var gZipBuffer = new byte[compressedData.Length + 4]; 26 | Buffer.BlockCopy(compressedData, 0, gZipBuffer, 4, compressedData.Length); 27 | Buffer.BlockCopy(BitConverter.GetBytes(buffer.Length), 0, gZipBuffer, 0, 4); 28 | return Convert.ToBase64String(gZipBuffer); 29 | } 30 | 31 | public static string Decompress(string compressedText) 32 | { 33 | byte[] gZipBuffer = Convert.FromBase64String(compressedText); 34 | using var memoryStream = new MemoryStream(); 35 | int dataLength = BitConverter.ToInt32(gZipBuffer, 0); 36 | memoryStream.Write(gZipBuffer, 4, gZipBuffer.Length - 4); 37 | 38 | var buffer = new byte[dataLength]; 39 | 40 | memoryStream.Position = 0; 41 | using (var gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) 42 | { 43 | gZipStream.ReadExactly(buffer); 44 | } 45 | 46 | return Encoding.UTF8.GetString(buffer); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /.tours/tour-of-the-azure-bootstrapper.tour: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://aka.ms/codetour-schema", 3 | "title": "Tour of the Azure bootstrapper", 4 | "steps": [ 5 | { 6 | "file": "azuredeploymentbootstrapper.ps1", 7 | "description": "Welcome to the Azure bootstrapper tour.\n\nHere we will have a look at how you can get your own copy of explain powershell running in Azure, and this is basically how the actual www.explainpowershell.com site is set up in Azure too, excluding DNS, CDN and application insights.\n\nTo be able to use this, you need a GitHub account and an Azure subscription. A 30-day free subscription will do just fine.\nThe script assumes you have forked the explain powershell repo to your own github.\n\nYou will be asked to authenticate, so the script can set up everything.\n\nA few things are stored as GitHub Secrets, so they can be used from the GitHub Actions.\n\nAfter the resource group in Azure is created and the secrets are in place, you can run the `Deploy Azure Infra` GitHub Action. This action will deploy you copy of explain powershell to Azure.", 8 | "line": 1 9 | }, 10 | { 11 | "file": ".github/workflows/deploy_azure_infra.yml", 12 | "description": "This is the github action that you can run after you have run the `azuredeploymentbootstrapper.ps1` script.\n\nIt logs to Azure, and performs an Azure ARM deployment to deploy your copy of explain powershell.\n\nOnce this Action is done, you can go into your Azure subscription and find the functionapp and storage ready for deployment. After this step, you also need to deploy the app to Azure. Use the `Deploy app to Azure` Action for this.", 13 | "line": 2 14 | }, 15 | { 16 | "file": ".github/workflows/deploy_app.yml", 17 | "description": "This GitHub Action is for deploying the function app and the Blazor frontend to your Azure environment. \n\nThere is a step in here to purge the Azure CDN, which you don't have if you have used the `Deploy Azure infra` Action, but it will be silently skipped in that case.", 18 | "line": 2 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "clean", 6 | "command": "dotnet", 7 | "args": [ 8 | "clean", 9 | "/property:GenerateFullPaths=true", 10 | "/consoleloggerparameters:NoSummary" 11 | ], 12 | "type": "process", 13 | "problemMatcher": "$msCompile" 14 | }, 15 | { 16 | "label": "build", 17 | "command": "dotnet", 18 | "args": [ 19 | "build", 20 | "/property:GenerateFullPaths=true", 21 | "/consoleloggerparameters:NoSummary" 22 | ], 23 | "type": "process", 24 | "group": { 25 | "kind": "build", 26 | "isDefault": true 27 | }, 28 | "problemMatcher": "$msCompile" 29 | }, 30 | { 31 | "label": "func: host start", 32 | "type": "func", 33 | "options": { 34 | "cwd": "${workspaceFolder}/explainpowershell.analysisservice/" 35 | }, 36 | "command": "host start", 37 | "isBackground": true, 38 | "problemMatcher": "$func-dotnet-watch", 39 | "dependsOn": "startstorageemulator" 40 | }, 41 | { 42 | "label": "watch run frontend", 43 | "command": "dotnet", 44 | "args": [ 45 | "watch", 46 | "run" 47 | ], 48 | "type": "process", 49 | "problemMatcher": "$msCompile", 50 | "options": { 51 | "cwd": "${workspaceFolder}/explainpowershell.frontend/" 52 | }, 53 | "dependsOn": "Open browser frontend" 54 | }, 55 | { 56 | "label": "watch run backend", 57 | "command": "dotnet", 58 | "args": [ 59 | "watch", 60 | "run", 61 | "--no-hot-reload" 62 | ], 63 | "type": "process", 64 | "problemMatcher": "$msCompile", 65 | "options": { 66 | "cwd": "${workspaceFolder}/explainpowershell.analysisservice/", 67 | "env": { 68 | "DOTNET_USE_POLLING_FILE_WATCHER": "false" 69 | } 70 | }, 71 | "dependsOn": "startstorageemulator" 72 | }, 73 | { 74 | "label": "startstorageemulator", 75 | "command": "${command:azurite.start}", 76 | "isBackground": true 77 | }, 78 | { 79 | "label": "Open browser frontend", 80 | "type": "shell", 81 | "command": "Start-Process https://localhost:5001", 82 | "problemMatcher": [] 83 | } 84 | ] 85 | } 86 | -------------------------------------------------------------------------------- /explainpowershell.helpcollector/aboutcollector.ps1: -------------------------------------------------------------------------------- 1 | using namespace Microsoft.PowerShell.Commands 2 | 3 | Write-Host -Foregroundcolor green "Get all built-in 'about_..' articles, and get missing short descriptions from text.." 4 | $aboutArticles = Get-Help About_* 5 | 6 | # filter only Microsoft built-in ones 7 | $abouts = $aboutArticles | Where-Object {-not $_.synopsis} 8 | 9 | foreach ($about in $abouts) { 10 | $baseUrl = 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/' 11 | [BasicHtmlWebResponseObject]$result = $null 12 | try { 13 | $result = Invoke-WebRequest -Uri ($baseUrl + $about.name) -ErrorAction SilentlyContinue 14 | } 15 | catch { 16 | try { 17 | $baseUrl = $baseUrl.replace("microsoft.powershell.core/about","Microsoft.PowerShell.Security/About") 18 | $result = Invoke-WebRequest -ErrorAction SilentlyContinue -Uri ($baseUrl + $about.name) 19 | } 20 | catch { 21 | try { 22 | $baseUrl = $baseUrl.replace("Microsoft.PowerShell.Security/About","microsoft.wsman.management/about") 23 | $result = Invoke-WebRequest -ErrorAction SilentlyContinue -Uri ($baseUrl + $about.name) 24 | } 25 | catch { 26 | Write-Warning "Cannot find online help for about_.. article '$($about.name)'" 27 | } 28 | } 29 | } 30 | 31 | $selectedContext = $about.ToString().Split("`n") 32 | | Select-String "short description" -Context 5 33 | 34 | if ($selectedContext) { 35 | $synopsis = ( 36 | ( 37 | $selectedContext.Context.PostContext.Split("`n") 38 | | Where-Object {-not [string]::IsNullOrWhiteSpace($_) } 39 | ) -join ' ' 40 | ).Replace("`r", '').Replace("`n", '') 41 | 42 | $hasLongSubscription = $synopsis.IndexOf("long description", [stringcomparison]::OrdinalIgnoreCase) 43 | if ($hasLongSubscription -gt 0) { 44 | $synopsis = $synopsis.Substring(0, $hasLongSubscription) 45 | } 46 | } 47 | 48 | [PSCustomObject]@{ 49 | CommandName = $about.name 50 | DocumentationLink = if ($null -ne $result) {$baseUrl + $about.name} 51 | Synopsis = $synopsis 52 | } 53 | } -------------------------------------------------------------------------------- /explainpowershell.helpcollector/Get-ParameterSetNames.ps1: -------------------------------------------------------------------------------- 1 | function Get-ParameterSetNames { 2 | param( 3 | $CommandName 4 | ) 5 | 6 | $cmd = Get-Command -Name $commandName 7 | $parameterData = $cmd.Parameters.Keys | ForEach-Object { 8 | [pscustomobject]@{ 9 | ParameterSets = $cmd.Parameters[$_].ParameterSets 10 | } 11 | } 12 | 13 | return $parameterData.ParameterSets.Keys | Where-Object { $_ -ne '__AllParameterSets' } | Sort-Object -Unique 14 | } 15 | 16 | 17 | function Get-ParameterSets { 18 | param( 19 | $CommandName = 'Add-AzAdGroupMember', 20 | [switch]$Full 21 | ) 22 | 23 | $cmd = Get-Command -Name $commandName 24 | $parameterData = $cmd.Parameters.Keys | ForEach-Object { 25 | [pscustomobject]@{ 26 | Name = $_ 27 | ParameterSets = $cmd.Parameters[$_].ParameterSets 28 | } 29 | } 30 | 31 | $parameterSetNames = $parameterData.ParameterSets.Keys | Where-Object { $_ -ne '__AllParameterSets' } | Sort-Object -Unique 32 | 33 | foreach ($parameterSetName in $parameterSetNames) { 34 | $parameterData 35 | | Where-Object { 36 | if ($Full) { 37 | $_.parameterSets.Keys -eq '__AllParameterSets' -or $_.parameterSets.Keys -contains $parameterSetName 38 | } 39 | else { 40 | $_.parameterSets.Keys -contains $parameterSetName 41 | } 42 | } 43 | | ForEach-Object { 44 | [pscustomobject]@{ 45 | ParameterSetName = $parameterSetName 46 | ParameterName = $_.Name 47 | IsMandatory = $_.parametersets[$parameterSetName].IsMandatory 48 | Position = $_.parametersets[$parameterSetName].Position 49 | HelpMessage = $_.parametersets[$parameterSetName].HelpMessage 50 | ValueFromPipeline = $_.parametersets[$parameterSetName].ValueFromPipeline 51 | ValueFromPipelineByPropertyName = $_.parametersets[$parameterSetName].ValueFromPipelineByPropertyName 52 | ValueFromRemainingArguments = $_.parametersets[$parameterSetName].ValueFromRemainingArguments 53 | } 54 | } 55 | } 56 | } 57 | 58 | Get-parameterSets -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .SYNOPSIS 3 | Invokes the SyntaxAnalyzer HTTP endpoint used by analysis service tests. 4 | 5 | .DESCRIPTION 6 | Posts PowerShell source text to the analysis service and returns the raw web response. 7 | When -Explanations is specified, returns only the Explanations property from the JSON 8 | response payload. 9 | 10 | .PARAMETER PowershellCode 11 | The PowerShell source text to analyze. 12 | 13 | .PARAMETER BaseUri 14 | Base URI for the analysis service API. Defaults to the local Azure Functions host. 15 | 16 | .PARAMETER TimeoutSec 17 | Timeout, in seconds, for the HTTP request. 18 | 19 | .PARAMETER Explanations 20 | If specified, returns only the parsed Explanations array/object from the JSON response. 21 | 22 | .OUTPUTS 23 | System.Net.HttpWebResponse / Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject 24 | By default, returns the response object from Invoke-WebRequest. 25 | 26 | .OUTPUTS 27 | System.Object 28 | When -Explanations is specified, returns the deserialized Explanations property. 29 | 30 | .EXAMPLE 31 | Invoke-SyntaxAnalyzer -PowershellCode 'Get-Date' 32 | 33 | .EXAMPLE 34 | Invoke-SyntaxAnalyzer -PowershellCode 'Get-ChildItem' -Explanations 35 | #> 36 | function Invoke-SyntaxAnalyzer { 37 | param( 38 | [Parameter(Mandatory)] 39 | [string]$PowershellCode, 40 | 41 | [Parameter()] 42 | [string]$BaseUri = 'http://127.0.0.1:7071/api', 43 | 44 | [Parameter()] 45 | [int]$TimeoutSec = 30, 46 | 47 | [switch]$Explanations 48 | ) 49 | 50 | $ErrorActionPreference = 'stop' 51 | 52 | $body = @{ PowershellCode = $PowershellCode } | ConvertTo-Json 53 | 54 | # Invoke-WebRequest can emit expensive per-request progress UI, which adds significant overhead 55 | # in tight test loops on Windows. Suppress progress only for this request to keep tests fast. 56 | $originalProgressPreference = $ProgressPreference 57 | $ProgressPreference = 'SilentlyContinue' 58 | try { 59 | $response = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec 60 | } 61 | finally { 62 | $ProgressPreference = $originalProgressPreference 63 | } 64 | 65 | if ($Explanations) { 66 | return $response.Content | ConvertFrom-Json | Select-Object -Expandproperty Explanations 67 | } 68 | 69 | return $response 70 | } 71 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Management.Automation; 6 | using System.Management.Automation.Language; 7 | using System.Text.Json; 8 | using System.Threading.Tasks; 9 | using explainpowershell.models; 10 | using NUnit.Framework; 11 | 12 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 13 | { 14 | public class GetAstVisitorExplainer_commandTests 15 | { 16 | AstVisitorExplainer explainer; 17 | 18 | [SetUp] 19 | public void Setup() 20 | { 21 | var mockILogger = new LoggerDouble(); 22 | var helpRepository = new InMemoryHelpRepository(); 23 | 24 | explainer = new( 25 | extentText: string.Empty, 26 | helpRepository: helpRepository, 27 | log: mockILogger, 28 | tokens: null); 29 | } 30 | 31 | [Test] 32 | public void ShoudGenerateHelpForUnknownCommand() 33 | { 34 | ScriptBlock.Create("myUnknownCommand") 35 | .Ast 36 | .Visit(explainer); 37 | 38 | AnalysisResult res = explainer.GetAnalysisResult(); 39 | 40 | Assert.AreEqual( 41 | "Unrecognized command.", 42 | res.Explanations[0].Description); 43 | } 44 | 45 | [Test] 46 | public void ShoudGenerateHelpForUnknownCmdLet() 47 | { 48 | ScriptBlock.Create("get-myunknownCmdlet") 49 | .Ast 50 | .Visit(explainer); 51 | 52 | AnalysisResult res = explainer.GetAnalysisResult(); 53 | 54 | Assert.That( 55 | res 56 | .Explanations[0] 57 | .Description 58 | .StartsWith( 59 | "Unrecognized cmdlet. Try finding the module that contains this cmdlet and add it to my database.")); 60 | } 61 | 62 | [Test] 63 | public void ShoudGenerateHelpForUnknownCommandWithDashInName() 64 | { 65 | ScriptBlock.Create("dotnet-watch") 66 | .Ast 67 | .Visit(explainer); 68 | 69 | AnalysisResult res = explainer.GetAnalysisResult(); 70 | 71 | Assert.AreEqual( 72 | "Unrecognized command.", 73 | res.Explanations[0].Description); 74 | } 75 | } 76 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Get-DotnetEnvironment.Tests.ps1: -------------------------------------------------------------------------------- 1 | Push-Location $PSScriptRoot/.. # this line does not work as expected when placed in the BeforeAll block. 2 | 3 | BeforeAll { 4 | $requiredDotnetVersions = Get-ChildItem $PSScriptRoot/.. *.csproj -Recurse -Depth 1 | ForEach-Object { 5 | [version]( 6 | [xml](Get-Content $_) 7 | ). 8 | Project. 9 | PropertyGroup. 10 | TargetFramework. 11 | Replace('net','') 12 | } | Sort-Object -Unique 13 | } 14 | 15 | Describe "prerequisites" { 16 | It "has the right dotnet sdk installed" { 17 | $latestAvailableDotnetVersion = dotnet --list-sdks 18 | | ForEach-Object { [version]$_.split(' ')[0] } 19 | | Sort-Object 20 | | Select-Object -Last 1 21 | 22 | foreach ($version in $script:requiredDotnetVersions) { 23 | $latestAvailableDotnetVersion | Should -BeGreaterOrEqual $version 24 | } 25 | } 26 | 27 | It "has the right Azure Function Core Tools version installed" { 28 | $requiredFuncVersion = [int](Get-Content $PSScriptRoot/../.vscode/settings.json | ConvertFrom-Json).'azurefunctions.projectruntime'.trim('~') 29 | $funcToolsVersion = [version](func --version) 30 | 31 | $funcToolsVersion.Major | Should -BeGreaterOrEqual $requiredFuncVersion 32 | } 33 | } 34 | 35 | Describe "dotnet sdk versions" { 36 | It "should be up-to-date" { 37 | # dotnet sdk check checks for dotnet sdk and runtimes. Should be "Up to date" for Major dotnet version (other versions don't matter) 38 | $dotnetMajorVersions = "$($requiredDotnetVersions.Major)" 39 | (dotnet sdk check) -match "$dotnetMajorVersions(\.\d+)+\s\s+" 40 | | Where-Object {$_ -notmatch "Up to date"} 41 | | Should -BeNullOrEmpty 42 | } 43 | } 44 | 45 | Describe "dotnet package versions"{ 46 | 47 | foreach ($package in @((dotnet list package --outdated) -match "^ >")) { 48 | It "in main project should be up to date" -TestCases @{'package' = $package} { 49 | $package | Should -BeNullOrEmpty 50 | } 51 | } 52 | 53 | Push-Location ./explainpowershell.analysisservice.tests 54 | foreach ($package in @((dotnet list package --outdated) -match "^ >")) { 55 | It "in testproject should be up to date" -TestCases @{'package' = $package} { 56 | $package | Should -BeNullOrEmpty 57 | } 58 | } 59 | Pop-Location 60 | } 61 | 62 | Pop-Location 63 | -------------------------------------------------------------------------------- /explainpowershell.frontend/Shared/MainLayout.razor: -------------------------------------------------------------------------------- 1 | @inherits LayoutComponentBase 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | @* Desktop - with text *@ 13 | 14 | Buy me a coffee 15 | 16 | 17 | @* Mobile - icon only *@ 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | Info 27 | 28 | 29 | 30 | 31 | @Body 32 | 33 | 34 | 35 | @code { 36 | bool _drawerOpen = false; 37 | 38 | // Bluesky butterfly logo (from Simple Icons, 24x24 viewBox) 39 | const string BlueskyIcon = ""; 40 | 41 | void DrawerToggle() 42 | { 43 | _drawerOpen = !_drawerOpen; 44 | } 45 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Constants.cs: -------------------------------------------------------------------------------- 1 | namespace ExplainPowershell.SyntaxAnalyzer 2 | { 3 | /// 4 | /// Application-wide constants 5 | /// 6 | public static class Constants 7 | { 8 | /// 9 | /// Azure Table Storage constants 10 | /// 11 | public static class TableStorage 12 | { 13 | /// 14 | /// The partition key used for command help entries in Azure Table Storage 15 | /// 16 | public const string CommandHelpPartitionKey = "CommandHelp"; 17 | 18 | /// 19 | /// The default table name for help data 20 | /// 21 | public const string HelpDataTableName = "HelpData"; 22 | 23 | /// 24 | /// Character used for filtering in range queries (ASCII 33 = '!') 25 | /// 26 | public const char RangeFilterChar = '!'; 27 | 28 | /// 29 | /// Separator character used between command name and module name (ASCII 32 = ' ') 30 | /// 31 | public const char CommandModuleSeparator = ' '; 32 | } 33 | 34 | /// 35 | /// PowerShell documentation link constants 36 | /// 37 | public static class Documentation 38 | { 39 | public const string MicrosoftDocsBase = "https://learn.microsoft.com/en-us/powershell/scripting/lang-spec"; 40 | public const string Chapter04TypeSystem = MicrosoftDocsBase + "/chapter-04"; 41 | public const string Chapter04GenericTypes = Chapter04TypeSystem + "#44-generic-types"; 42 | public const string Chapter08PipelineStatements = MicrosoftDocsBase + "/chapter-08#82-pipeline-statements"; 43 | } 44 | 45 | /// 46 | /// PowerShell about topics 47 | /// 48 | public static class AboutTopics 49 | { 50 | public const string AboutClasses = "about_classes"; 51 | public const string AboutEnum = "about_enum"; 52 | public const string AboutFunctions = "about_functions"; 53 | public const string AboutFunctionsCmdletBindingAttribute = "about_Functions_CmdletBindingAttribute"; 54 | public const string AboutHashTables = "about_hash_tables"; 55 | public const string AboutOperators = "about_operators"; 56 | public const string AboutRedirection = "about_redirection"; 57 | public const string AboutTypeAccelerators = "about_type_accelerators"; 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Helpers/MatchParam.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text.Json; 5 | using System.Text.Json.Serialization; 6 | using explainpowershell.models; 7 | using explainpowershell.helpcollector.tools; 8 | 9 | namespace ExplainPowershell.SyntaxAnalyzer 10 | { 11 | public static partial class Helpers 12 | { 13 | public static ParameterData? MatchParam(string foundParameter, string json) 14 | { 15 | List? doc; 16 | List matchedParam = new(); 17 | 18 | try { 19 | doc = JsonSerializer.Deserialize>(json, new JsonSerializerOptions() {DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull}); 20 | } 21 | catch { 22 | json = DeCompress.Decompress(json); 23 | doc = JsonSerializer.Deserialize>(json, new JsonSerializerOptions() {DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull}); 24 | } 25 | 26 | if (null == doc) { 27 | return matchedParam.FirstOrDefault(); 28 | } 29 | 30 | // First check for aliases, because they take precendence 31 | if (!string.Equals(foundParameter, "none", StringComparison.OrdinalIgnoreCase)) 32 | { 33 | matchedParam = doc.Where( 34 | p => (p.Aliases?.Split(", ") 35 | .All( 36 | q => q.StartsWith( 37 | foundParameter, 38 | StringComparison.InvariantCultureIgnoreCase))) ?? false).ToList(); 39 | } 40 | 41 | if (matchedParam.Count == 0) 42 | { 43 | // If no aliases match, then try partial parameter names for static params (aliases and static params take precedence) 44 | matchedParam = doc.Where( 45 | p => ! (p.IsDynamic ?? false) && 46 | (p.Name?.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase) ?? false)).ToList(); 47 | } 48 | 49 | if (matchedParam.Count == 0) 50 | { 51 | // If no aliases or static params match, then try partial parameter names for dynamic params too. 52 | matchedParam = doc.Where( 53 | p => p.Name?.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase) ?? false).ToList(); 54 | } 55 | 56 | if (matchedParam.Count == 0) 57 | { 58 | return null; 59 | } 60 | 61 | if (matchedParam.Count > 1) 62 | { 63 | throw new ArgumentException($"Abiguous parameter: {foundParameter}"); 64 | } 65 | 66 | return matchedParam.FirstOrDefault(); 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | using explainpowershell.models; 3 | 4 | namespace ExplainPowershell.SyntaxAnalyzer.Repositories 5 | { 6 | /// 7 | /// Implementation of IHelpRepository using Azure Table Storage 8 | /// 9 | public class TableStorageHelpRepository : IHelpRepository 10 | { 11 | private readonly TableClient tableClient; 12 | 13 | public TableStorageHelpRepository(TableClient tableClient) 14 | { 15 | this.tableClient = tableClient ?? throw new ArgumentNullException(nameof(tableClient)); 16 | } 17 | 18 | /// 19 | public HelpEntity? GetHelpForCommand(string commandName) 20 | { 21 | if (string.IsNullOrEmpty(commandName)) 22 | { 23 | return null; 24 | } 25 | 26 | string filter = TableServiceClient.CreateQueryFilter( 27 | $"PartitionKey eq {Constants.TableStorage.CommandHelpPartitionKey} and RowKey eq {commandName.ToLower()}"); 28 | var entities = tableClient.Query(filter: filter); 29 | return entities.FirstOrDefault(); 30 | } 31 | 32 | /// 33 | public HelpEntity? GetHelpForCommand(string commandName, string moduleName) 34 | { 35 | if (string.IsNullOrEmpty(commandName) || string.IsNullOrEmpty(moduleName)) 36 | { 37 | return null; 38 | } 39 | 40 | var rowKey = $"{commandName.ToLower()}{Constants.TableStorage.CommandModuleSeparator}{moduleName.ToLower()}"; 41 | return GetHelpForCommand(rowKey); 42 | } 43 | 44 | /// 45 | public List GetHelpForCommandRange(string commandName) 46 | { 47 | if (string.IsNullOrEmpty(commandName)) 48 | { 49 | return new List(); 50 | } 51 | 52 | // Getting a range from Azure Table storage works based on ascii char filtering. You can match prefixes. 53 | // We use a space ' ' (char)32 as a divider between the name of a command and the name of its module 54 | // for commands that appear in more than one module. Filtering this way makes sure we only match 55 | // entries with ' '. 56 | // filterChar = (char)33 = '!'. 57 | string rowKeyFilter = $"{commandName.ToLower()}{Constants.TableStorage.RangeFilterChar}"; 58 | string filter = TableServiceClient.CreateQueryFilter( 59 | $"PartitionKey eq {Constants.TableStorage.CommandHelpPartitionKey} and RowKey ge {commandName.ToLower()} and RowKey lt {rowKeyFilter}"); 60 | var entities = tableClient.Query(filter: filter); 61 | return entities.ToList(); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /explainpowershell.frontend/Shared/MetaDataInfo.razor: -------------------------------------------------------------------------------- 1 | @inject HttpClient Http 2 | 3 | 4 | @if (@hasMetaData) 5 | { 6 | 7 | 8 | Last published: 9 | @metaData?.LastPublished 10 | 11 | Number of commands: 12 | @metaData?.NumberOfCommands.ToString() 13 | 14 | Number of about-articles: 15 | @metaData?.NumberOfAboutArticles.ToString() 16 | 17 | Number of modules: 18 | @metaData?.NumberOfModules.ToString() 19 | 20 | 21 | 22 | 23 | @if(metaData != null) 24 | { 25 | @if(metaData.ModuleNames != null) 26 | { 27 | @foreach (var module in metaData.ModuleNames.Split(',').OrderBy(name => name)) 28 | { 29 | @module 30 | } 31 | } 32 | } 33 | 34 | 35 | 36 | Request a module 41 | 42 | 43 | } 44 | else 45 | { 46 | 47 | 48 | Warming up backend.. 49 | 50 | } 51 | 52 | 53 | @code { 54 | private bool hasMetaData { get; set; } 55 | private HelpMetaData metaData { get; set; } 56 | 57 | protected override async Task OnInitializedAsync() 58 | { 59 | try 60 | { 61 | metaData = await Http.GetFromJsonAsync("MetaData"); 62 | hasMetaData = true; 63 | StateHasChanged(); 64 | return; 65 | } 66 | catch 67 | { 68 | return; 69 | } 70 | } 71 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/Program.cs: -------------------------------------------------------------------------------- 1 | using Azure.Data.Tables; 2 | using explainpowershell.analysisservice; 3 | using explainpowershell.analysisservice.Services; 4 | using ExplainPowershell.SyntaxAnalyzer; 5 | using ExplainPowershell.SyntaxAnalyzer.Repositories; 6 | using Microsoft.Extensions.Configuration; 7 | using Microsoft.Extensions.DependencyInjection; 8 | using Microsoft.Extensions.Hosting; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Options; 11 | using OpenAI; 12 | using OpenAI.Chat; 13 | using System.ClientModel; 14 | using System.IO; 15 | 16 | var host = new HostBuilder() 17 | .ConfigureAppConfiguration((context, builder) => 18 | { 19 | builder.AddEnvironmentVariables(); 20 | 21 | if (File.Exists("local.settings.json")) 22 | { 23 | builder.AddJsonFile("local.settings.json", optional: true, reloadOnChange: true); 24 | } 25 | }) 26 | .ConfigureFunctionsWorkerDefaults() 27 | .ConfigureServices((context, services) => 28 | { 29 | services.AddLogging(); 30 | services.Configure(context.Configuration.GetSection(AiExplanationOptions.SectionName)); 31 | 32 | services.AddSingleton(sp => TableClientFactory.Create(Constants.TableStorage.HelpDataTableName)); 33 | services.AddSingleton(sp => new TableStorageHelpRepository(sp.GetRequiredService())); 34 | 35 | // Register ChatClient factory 36 | services.AddSingleton(sp => 37 | { 38 | var options = sp.GetRequiredService>().Value; 39 | var logger = sp.GetRequiredService>(); 40 | 41 | var isConfigured = options.Enabled 42 | && !string.IsNullOrWhiteSpace(options.ApiKey) 43 | && !string.IsNullOrWhiteSpace(options.DeploymentName) 44 | && !string.IsNullOrWhiteSpace(options.Endpoint); 45 | 46 | if (!isConfigured) 47 | { 48 | logger.LogWarning("AI explanation ChatClient not configured. AI features will be disabled."); 49 | return null!; 50 | } 51 | 52 | logger.LogInformation( 53 | "Registering ChatClient with model '{Model}' at endpoint '{Endpoint}'.", 54 | options.DeploymentName, 55 | options.Endpoint); 56 | 57 | var credential = new ApiKeyCredential(options.ApiKey!); 58 | return new ChatClient( 59 | credential: credential, 60 | model: options.DeploymentName!, 61 | options: new OpenAIClientOptions 62 | { 63 | Endpoint = new Uri(options.Endpoint!) 64 | }); 65 | }); 66 | 67 | services.AddSingleton(); 68 | }) 69 | .Build(); 70 | 71 | host.Run(); 72 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Management.Automation.Language; 4 | using explainpowershell.models; 5 | 6 | namespace explainpowershell.SyntaxAnalyzer.ExtensionMethods 7 | { 8 | public static class SyntaxAnalyzerExtensions 9 | { 10 | public static Explanation AddDefaults(this Explanation explanation, Ast ast, List explanations) 11 | { 12 | explanation.OriginalExtent = ast.Extent.Text; 13 | explanation.Id = ast.GenerateId(); 14 | explanation.ParentId = TryFindParentExplanation(ast, explanations); 15 | return explanation; 16 | } 17 | 18 | public static Explanation AddDefaults(this Explanation explanation, Token token, List explanations) 19 | { 20 | explanation.OriginalExtent = token.Extent.Text; 21 | explanation.Id = token.GenerateId(); 22 | explanation.ParentId = TryFindParentExplanation(token, explanations); 23 | return explanation; 24 | } 25 | private static string GenerateId(this Ast ast) 26 | { 27 | return $"{ast.Extent.StartLineNumber}.{ast.Extent.StartOffset}.{ast.Extent.EndOffset}.{ast.GetType().Name}"; 28 | } 29 | 30 | private static string GenerateId(this Token token) 31 | { 32 | return $"{token.Extent.StartLineNumber}.{token.Extent.StartOffset}.{token.Extent.EndOffset}.{token.Kind}"; 33 | } 34 | 35 | public static string? TryFindParentExplanation(Ast ast, List explanations, int level = 0) 36 | { 37 | if (explanations.Count == 0 || ast.Parent == null) 38 | return null; 39 | 40 | var parentId = ast.Parent.GenerateId(); 41 | 42 | if ((!explanations.Any(e => e.Id == parentId)) && level < 100) 43 | { 44 | return TryFindParentExplanation(ast.Parent, explanations, ++level); 45 | } 46 | 47 | if (level >= 99) 48 | return null; 49 | 50 | return parentId; 51 | } 52 | 53 | public static string? TryFindParentExplanation(Token token, List explanations) 54 | { 55 | var start = token.Extent.StartOffset; 56 | var explanationsBeforeToken = explanations.Where(e => GetEndOffSet(e) <= start); 57 | 58 | if (!explanationsBeforeToken.Any()) 59 | { 60 | return null; 61 | } 62 | 63 | var closestNeigbour = explanationsBeforeToken.Max(e => GetEndOffSet(e)); 64 | return explanationsBeforeToken.FirstOrDefault(t => GetEndOffSet(t) == closestNeigbour)?.Id; 65 | } 66 | 67 | private static int GetEndOffSet(Explanation e) 68 | { 69 | if (e.Id == null) 70 | return -1; 71 | return int.Parse(e.Id.Split('.')[2]); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using explainpowershell.models; 5 | using ExplainPowershell.SyntaxAnalyzer.Repositories; 6 | 7 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 8 | { 9 | /// 10 | /// In-memory implementation of IHelpRepository for testing 11 | /// 12 | public class InMemoryHelpRepository : IHelpRepository 13 | { 14 | private readonly Dictionary helpData = new(StringComparer.OrdinalIgnoreCase); 15 | 16 | /// 17 | /// Add help data for testing 18 | /// 19 | public void AddHelpEntity(HelpEntity entity) 20 | { 21 | if (entity == null) throw new ArgumentNullException(nameof(entity)); 22 | 23 | var key = entity.CommandName ?? string.Empty; 24 | if (!string.IsNullOrEmpty(entity.ModuleName)) 25 | { 26 | key = $"{key} {entity.ModuleName}"; 27 | } 28 | 29 | helpData[key] = entity; 30 | } 31 | 32 | /// 33 | /// Clear all help data 34 | /// 35 | public void Clear() 36 | { 37 | helpData.Clear(); 38 | } 39 | 40 | /// 41 | public HelpEntity GetHelpForCommand(string commandName) 42 | { 43 | if (string.IsNullOrEmpty(commandName)) 44 | { 45 | return null; 46 | } 47 | 48 | // First try exact match 49 | if (helpData.TryGetValue(commandName, out var entity)) 50 | { 51 | return entity; 52 | } 53 | 54 | // If not found, try to find a command with this name in any module 55 | var key = helpData.Keys.FirstOrDefault(k => 56 | k.Equals(commandName, StringComparison.OrdinalIgnoreCase) || 57 | k.StartsWith($"{commandName} ", StringComparison.OrdinalIgnoreCase)); 58 | 59 | return key != null ? helpData[key] : null; 60 | } 61 | 62 | /// 63 | public HelpEntity GetHelpForCommand(string commandName, string moduleName) 64 | { 65 | if (string.IsNullOrEmpty(commandName) || string.IsNullOrEmpty(moduleName)) 66 | { 67 | return null; 68 | } 69 | 70 | var key = $"{commandName} {moduleName}"; 71 | return helpData.TryGetValue(key, out var entity) ? entity : null; 72 | } 73 | 74 | /// 75 | public List GetHelpForCommandRange(string commandName) 76 | { 77 | if (string.IsNullOrEmpty(commandName)) 78 | { 79 | return new List(); 80 | } 81 | 82 | return helpData 83 | .Where(kvp => kvp.Key.StartsWith(commandName, StringComparison.OrdinalIgnoreCase)) 84 | .Select(kvp => kvp.Value) 85 | .ToList(); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Start-AllTests.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [Parameter()] 4 | [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] 5 | [string]$Output = 'Normal', 6 | [switch]$SkipIntegrationTests, 7 | [switch]$SkipUnitTests, 8 | 9 | # By default, keep test runs deterministic and fast by disabling outbound AI calls. 10 | # Opt-in for real AI calls when needed (e.g., manual/local integration). 11 | [switch]$EnableAiCalls 12 | ) 13 | 14 | $c = New-PesterConfiguration -Hashtable @{ 15 | Output = @{ 16 | Verbosity = $Output 17 | } 18 | } 19 | 20 | $PSScriptRoot 21 | 22 | # Save/restore environment so running tests doesn't permanently affect the user's session. 23 | $script:originalAiEnabled = $env:AiExplanation__Enabled 24 | $script:originalAiEndpoint = $env:AiExplanation__Endpoint 25 | $script:originalAiApiKey = $env:AiExplanation__ApiKey 26 | $script:originalAiDeploymentName = $env:AiExplanation__DeploymentName 27 | 28 | try { 29 | if (-not $EnableAiCalls) { 30 | $env:AiExplanation__Enabled = $false 31 | $env:AiExplanation__Endpoint = '' 32 | $env:AiExplanation__ApiKey = '' 33 | $env:AiExplanation__DeploymentName = '' 34 | } 35 | 36 | # Run all code generators 37 | Get-ChildItem -Path $PSScriptRoot/../explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } 38 | 39 | Push-Location -Path $PSScriptRoot/ 40 | if (-not $SkipIntegrationTests) { 41 | # Integration Tests 42 | Write-Host -ForegroundColor Cyan "`n####`n#### Starting Integration tests`n" 43 | . ./Test-IsPrerequisitesRunning.ps1 44 | $werePrerequisitesAlreadyRunning = Test-IsPrerequisitesRunning -ports 7071 45 | Invoke-Pester -Configuration $c 46 | if (-not $werePrerequisitesAlreadyRunning) { 47 | Get-Job | Stop-Job -PassThru | Remove-Job -Force 48 | } 49 | } 50 | if (-not $SkipUnitTests) { 51 | # Unit Tests 52 | Write-Host -ForegroundColor Cyan "`n####`n#### Starting Unit tests`n" 53 | Write-Host -ForegroundColor Green "Building tests.." 54 | Set-Location $PSScriptRoot/.. 55 | # we want the verbosity for the build step to be quiet 56 | dotnet build --verbosity quiet --nologo 57 | Write-Host -ForegroundColor Green "Running tests.." 58 | # for the test step we want to be able to adjust the verbosity 59 | dotnet test --no-build --nologo --verbosity $Output 60 | } 61 | Pop-Location 62 | 63 | } 64 | finally { 65 | if ($null -ne $script:originalAiEnabled) { $env:AiExplanation__Enabled = $script:originalAiEnabled } else { Remove-Item Env:AiExplanation__Enabled -ErrorAction SilentlyContinue } 66 | if ($null -ne $script:originalAiEndpoint) { $env:AiExplanation__Endpoint = $script:originalAiEndpoint } else { Remove-Item Env:AiExplanation__Endpoint -ErrorAction SilentlyContinue } 67 | if ($null -ne $script:originalAiApiKey) { $env:AiExplanation__ApiKey = $script:originalAiApiKey } else { Remove-Item Env:AiExplanation__ApiKey -ErrorAction SilentlyContinue } 68 | if ($null -ne $script:originalAiDeploymentName) { $env:AiExplanation__DeploymentName = $script:originalAiDeploymentName } else { Remove-Item Env:AiExplanation__DeploymentName -ErrorAction SilentlyContinue } 69 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/helpers/TestHelpData.cs: -------------------------------------------------------------------------------- 1 | using explainpowershell.models; 2 | 3 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 4 | { 5 | internal static class TestHelpData 6 | { 7 | public static void SeedAboutTopics(InMemoryHelpRepository repository) 8 | { 9 | repository.AddHelpEntity(new HelpEntity 10 | { 11 | CommandName = "about_Classes", 12 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Classes" 13 | }); 14 | 15 | repository.AddHelpEntity(new HelpEntity 16 | { 17 | CommandName = "about_Foreach", 18 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach" 19 | }); 20 | 21 | repository.AddHelpEntity(new HelpEntity 22 | { 23 | CommandName = "about_For", 24 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For" 25 | }); 26 | 27 | repository.AddHelpEntity(new HelpEntity 28 | { 29 | CommandName = "about_Remote_Variables", 30 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", 31 | RelatedLinks = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables" 32 | }); 33 | 34 | repository.AddHelpEntity(new HelpEntity 35 | { 36 | CommandName = "about_Scopes", 37 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Scopes" 38 | }); 39 | 40 | repository.AddHelpEntity(new HelpEntity 41 | { 42 | CommandName = "about_Return", 43 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" 44 | }); 45 | 46 | repository.AddHelpEntity(new HelpEntity 47 | { 48 | CommandName = "about_Throw", 49 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" 50 | }); 51 | 52 | repository.AddHelpEntity(new HelpEntity 53 | { 54 | CommandName = "about_language_keywords", 55 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords" 56 | }); 57 | 58 | repository.AddHelpEntity(new HelpEntity 59 | { 60 | CommandName = "about_trap", 61 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" 62 | }); 63 | 64 | repository.AddHelpEntity(new HelpEntity 65 | { 66 | CommandName = "about_Switch", 67 | DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Switch" 68 | }); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/tests/HelpEntitySynopsisBinding.tests.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Azure.Data.Tables; 3 | using explainpowershell.models; 4 | using NUnit.Framework; 5 | 6 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 7 | { 8 | [TestFixture] 9 | public class HelpEntitySynopsisBindingTests 10 | { 11 | [Test] 12 | public void HelpEntity_Synopsis_ShouldNotBeNullWhenPresent() 13 | { 14 | // Arrange - simulate what the helpcollector produces 15 | var jsonFromHelpcollector = @"{ 16 | ""CommandName"": ""Get-NetIPConfiguration"", 17 | ""ModuleName"": ""NetTCPIP"", 18 | ""ModuleVersion"": ""1.0.0.0"", 19 | ""Synopsis"": ""Gets the IP address configuration."", 20 | ""Syntax"": ""Get-NetIPConfiguration [[-InterfaceIndex] ]"" 21 | }"; 22 | 23 | // Act - deserialize like the helpwriter would read it 24 | var entity = JsonSerializer.Deserialize(jsonFromHelpcollector); 25 | 26 | // Assert 27 | Assert.IsNotNull(entity); 28 | Assert.IsNotNull(entity!.Synopsis, "Synopsis should not be null"); 29 | Assert.AreEqual("Get-NetIPConfiguration", entity.CommandName); 30 | Assert.AreEqual("NetTCPIP", entity.ModuleName); 31 | Assert.AreEqual("Gets the IP address configuration.", entity.Synopsis); 32 | } 33 | 34 | [Test] 35 | public void HelpEntity_Synopsis_ToString_ShouldReturnValue() 36 | { 37 | // Arrange 38 | var entity = new HelpEntity 39 | { 40 | CommandName = "Get-Test", 41 | Synopsis = "Test synopsis" 42 | }; 43 | 44 | // Act 45 | var description = entity.Synopsis?.ToString(); 46 | 47 | // Assert 48 | Assert.AreEqual("Test synopsis", description); 49 | } 50 | 51 | [Test] 52 | public void HelpEntity_Synopsis_NullCheck_ShouldWorkCorrectly() 53 | { 54 | // Arrange 55 | var entityWithSynopsis = new HelpEntity { Synopsis = "Has synopsis" }; 56 | var entityWithoutSynopsis = new HelpEntity { Synopsis = null }; 57 | var entityWithEmptySynopsis = new HelpEntity { Synopsis = "" }; 58 | 59 | // Act & Assert 60 | Assert.IsFalse(string.IsNullOrEmpty(entityWithSynopsis.Synopsis?.ToString())); 61 | Assert.IsTrue(string.IsNullOrEmpty(entityWithoutSynopsis.Synopsis?.ToString())); 62 | Assert.IsTrue(string.IsNullOrEmpty(entityWithEmptySynopsis.Synopsis?.ToString())); 63 | } 64 | 65 | [Test] 66 | public void TableEntity_CanStoreAndRetrieveSynopsis() 67 | { 68 | // This test simulates how Azure.Data.Tables handles the Synopsis property 69 | // Arrange 70 | var tableEntity = new TableEntity("CommandHelp", "get-test") 71 | { 72 | ["CommandName"] = "Get-Test", 73 | ["ModuleName"] = "TestModule", 74 | ["Synopsis"] = "This is a test synopsis" 75 | }; 76 | 77 | // Verify the property is stored correctly 78 | Assert.IsTrue(tableEntity.ContainsKey("Synopsis")); 79 | Assert.AreEqual("This is a test synopsis", tableEntity["Synopsis"]); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Net.Http.Json; 5 | using System.Text.Json; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using explainpowershell.models; 9 | 10 | namespace explainpowershell.frontend.Clients; 11 | 12 | public sealed class SyntaxAnalyzerClient : ISyntaxAnalyzerClient 13 | { 14 | private static readonly JsonSerializerOptions JsonOptions = new() 15 | { 16 | PropertyNameCaseInsensitive = true 17 | }; 18 | 19 | private readonly HttpClient http; 20 | 21 | public SyntaxAnalyzerClient(HttpClient http) 22 | { 23 | this.http = http; 24 | } 25 | 26 | public async Task> AnalyzeAsync(Code code, CancellationToken cancellationToken = default) 27 | { 28 | try 29 | { 30 | using var response = await http.PostAsJsonAsync("SyntaxAnalyzer", code, cancellationToken).ConfigureAwait(false); 31 | 32 | if (!response.IsSuccessStatusCode) 33 | { 34 | var reason = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); 35 | return ApiCallResult.Failure(reason, response.StatusCode); 36 | } 37 | 38 | var analysisResult = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); 39 | if (analysisResult is null) 40 | { 41 | return ApiCallResult.Failure("Empty response from SyntaxAnalyzer.", response.StatusCode); 42 | } 43 | 44 | return ApiCallResult.Success(analysisResult, response.StatusCode); 45 | } 46 | catch (OperationCanceledException) 47 | { 48 | throw; 49 | } 50 | catch (Exception ex) 51 | { 52 | return ApiCallResult.Failure($"Request failed: {ex.Message}", HttpStatusCode.ServiceUnavailable); 53 | } 54 | } 55 | 56 | public async Task> GetAiExplanationAsync( 57 | Code code, 58 | AnalysisResult analysisResult, 59 | CancellationToken cancellationToken = default) 60 | { 61 | try 62 | { 63 | var aiRequest = new 64 | { 65 | PowershellCode = code.PowershellCode, 66 | AnalysisResult = analysisResult 67 | }; 68 | 69 | using var response = await http.PostAsJsonAsync("AiExplanation", aiRequest, cancellationToken).ConfigureAwait(false); 70 | 71 | if (!response.IsSuccessStatusCode) 72 | { 73 | return ApiCallResult.Failure(null, response.StatusCode); 74 | } 75 | 76 | var aiResponse = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); 77 | if (aiResponse is null) 78 | { 79 | return ApiCallResult.Success(new AiExplanationResponse(), response.StatusCode); 80 | } 81 | 82 | return ApiCallResult.Success(aiResponse, response.StatusCode); 83 | } 84 | catch (OperationCanceledException) 85 | { 86 | throw; 87 | } 88 | catch 89 | { 90 | // AI is optional; treat failures as empty response. 91 | return ApiCallResult.Success(new AiExplanationResponse(), HttpStatusCode.OK); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | # Copilot instructions for Explain PowerShell 2 | 3 | These instructions are for GitHub Copilot Chat/Edits when working in this repository. 4 | 5 | ## Repo overview (what this project is) 6 | - A PowerShell oneliner explainer. 7 | - Backend: Azure Functions (.NET) in `explainpowershell.analysisservice/`. 8 | - Frontend: Blazor in `explainpowershell.frontend/`. 9 | - Shared models: `explainpowershell.models/`. 10 | - Tests: Pester tests in `explainpowershell.analysisservice.tests/`. 11 | - Infra: Bicep in `explainpowershell.azureinfra/`. 12 | - dotnet sdk 10.x 13 | 14 | ## Architecture & flow 15 | - The primary explanation is AST-based: 16 | - `SyntaxAnalyzer` parses PowerShell into an AST and produces a list of `Explanation` nodes. 17 | - The AI feature is additive and optional: 18 | - The AST analysis returns immediately; the AI call is a separate endpoint invoked after the tree is available. 19 | - Frontend triggers AI in the background so the UI remains responsive. 20 | 21 | ## Editing guidelines (preferred behavior) 22 | - Keep in mind this project is open source and intended to be cross platform. 23 | - Follow existing code style and patterns. 24 | - Favor readability and maintainability. 25 | - Prefer small, surgical changes; avoid unrelated refactors. 26 | - Preserve existing public APIs and JSON shapes unless explicitly requested. 27 | - Keep AI functionality optional and non-blocking. 28 | - If AI configuration is missing, the app should still work (AI can silently no-op). 29 | - Use existing patterns in the codebase (logging, DI, options, error handling). 30 | - Don’t add new external dependencies unless necessary and justified. 31 | 32 | ## C# conventions 33 | - Prefer async/await end-to-end. 34 | - Handle nullability deliberately; avoid introducing new nullable warnings. 35 | - Use `System.Text.Json` where the project already does; don’t mix serializers in the same flow unless required. 36 | 37 | ## Unit tests 38 | - Aim for high coverage on new features. 39 | - Focus on behavior verification over implementation details. 40 | - When adding tests, follow existing patterns in `explainpowershell.analysisservice.tests/`. 41 | 42 | ## Building 43 | - On fresh clones, run all code generators before building: `Get-ChildItem -Path $PSScriptRoot/explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName }` 44 | 45 | ## PowerShell / Pester conventions 46 | - Keep tests deterministic and fast; avoid relying on external services unless explicitly an integration test. 47 | - When adding tests, follow the existing Pester structure and naming. 48 | - Before adding Pester tests, consider if the behavior can be verified in C# unit tests first. 49 | 50 | ## Running locally 51 | - For running Pester integration tests locally successfully it is necessary to run `.\bootstrap.ps1` from the repo root, it sets up the required data in Azurite, and calls code generators. 52 | - For general debuging, running `.\bootstrap.ps1` once is also recommended. If Azurite is present and has helpldata, it is not necessary to run it again. 53 | - You can load helper methods to test the functionapp locally by importing the following scripts in your PowerShell session: 54 | ```powershell 55 | . $repoRoot\explainpowershell/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 56 | . $repoRoot\explainpowershell/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 57 | . $repoRoot\explainpowershell/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.ps1 58 | . $repoRoot\explainpowershell/explainpowershell.analysisservice.tests/Get-MetaData.ps1 59 | ``` 60 | 61 | ## How to validate changes 62 | - Prefer the repo task: run the VS Code task named `run tests` (Pester). 63 | - If you need a build check, use the VS Code `build` task. 64 | 65 | ## Documentation 66 | - When adding developer-facing features, also update or add a CodeTour in `.tours/` when it improves onboarding. 67 | -------------------------------------------------------------------------------- /.tours/high-level-tour-of-the-application.tour: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://aka.ms/codetour-schema", 3 | "title": "High level tour of the application", 4 | "steps": [ 5 | { 6 | "file": "explainpowershell.frontend/Pages/Index.razor", 7 | "description": "Welcome at the high-level tour of Explain PowerShell!\n\nWe will follow the journey of one user request trough the application and this is where that begins; the text input field where you can enter your PowerShell oneliner.\n\nIf the user presses Enter or clicks the `Explain` button, the oneliner is sent from this frontend to the backend api, the SyntaxAnalyzer endpoint.\n\nLet's see what happens there, and we will come back once we have an explanation to display here.", 8 | "line": 15 9 | }, 10 | { 11 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", 12 | "description": "This is where the PowerShell oneliner is sent to, the SyntaxAnalyzer endpoint, an Azure FunctionApp. \n\nWe use PowerShell's own parsing engine to parse the oneliner that was sent, the parser creates a so called Abstract Syntax Tree (AST), a logical representation of the oneliner in a convenient tree format that we can then 'walk' in an automated fashion. ", 13 | "line": 25 14 | }, 15 | { 16 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", 17 | "description": "The AST is analyzed here, via the AstVisitorExplainer. It basically looks at all the logical elements of the oneliner and generates an explanation for each of them.\n\nWe will have a brief look there as well, to get the basic idea.", 18 | "line": 68 19 | }, 20 | { 21 | "file": "explainpowershell.analysisservice/AstVisitorExplainer_statements.cs", 22 | "description": "This is an example of how an 'if' statement explanation is generated. When the AST contains an 'if' statement, this method is called, and an explanation for it is added to the list of explanations. ", 23 | "line": 178 24 | }, 25 | { 26 | "file": "explainpowershell.analysisservice/AstVisitorExplainer_statements.cs", 27 | "description": "The `Explanation` type you see here, is defined in the `Models` project, which is used both by the Backend api as well as the Blazor Frontend. \n\nLet's have a quick look there.", 28 | "line": 181 29 | }, 30 | { 31 | "file": "explainpowershell.models/Explanation.cs", 32 | "description": "This is how the `Explanation` type is defined. Even though we will send this type through the wire from the backend api to the frontend as json, because we use this same type on both ends, we can safely reserialize this data from json back to an `Explanation` object in the Frontend. \n\nThis is a great advantage of Blazor + C# api projects, you can have shared models. In JavaScript framework + c# backend api, you have to define the model twice. Which is errorprone. Ok back to our api.", 33 | "line": 3 34 | }, 35 | { 36 | "file": "explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs", 37 | "description": "Once we are done going through all the elements in the AST, this method gets called, and we return all explanations and a little metadata.", 38 | "line": 29 39 | }, 40 | { 41 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", 42 | "description": "if there were any parse errors, we get the message for that here.", 43 | "line": 79 44 | }, 45 | { 46 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", 47 | "description": "We send our list of explanations, and any parse error messages back to the frontend.", 48 | "line": 88 49 | }, 50 | { 51 | "file": "explainpowershell.frontend/Pages/Index.razor.cs", 52 | "description": "This is where we re-create the AST tree a little bit, and generate our own tree, to display everything in a nice tree view, ordered logically. ", 53 | "line": 152 54 | }, 55 | { 56 | "file": "explainpowershell.frontend/Pages/Index.razor", 57 | "description": "Here is where we display all the tree items. This is basically a foreach, with an ItemTemplate that is filled in for each item in the tree.\n\nThis is how the end user gets to see the explanation that was generated for them.\n\nThis is the end of the high-level tour", 58 | "line": 52 59 | } 60 | ] 61 | } -------------------------------------------------------------------------------- /explainpowershell.analysisservice/SyntaxAnalyzer.cs: -------------------------------------------------------------------------------- 1 | using System.Management.Automation.Language; 2 | using System.Net; 3 | using System.Text; 4 | using explainpowershell.analysisservice; 5 | using explainpowershell.models; 6 | using ExplainPowershell.SyntaxAnalyzer.Repositories; 7 | using Microsoft.Azure.Functions.Worker; 8 | using Microsoft.Azure.Functions.Worker.Http; 9 | using Microsoft.Extensions.Logging; 10 | using System.Text.Json; 11 | 12 | namespace ExplainPowershell.SyntaxAnalyzer 13 | { 14 | public sealed class SyntaxAnalyzerFunction 15 | { 16 | private readonly ILogger logger; 17 | private readonly IHelpRepository helpRepository; 18 | 19 | public SyntaxAnalyzerFunction(ILogger logger, IHelpRepository helpRepository) 20 | { 21 | this.logger = logger; 22 | this.helpRepository = helpRepository; 23 | } 24 | 25 | [Function("SyntaxAnalyzer")] 26 | public async Task Run( 27 | [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) 28 | { 29 | string requestBody; 30 | using (var reader = new StreamReader(req.Body)) 31 | { 32 | requestBody = await reader.ReadToEndAsync().ConfigureAwait(false); 33 | } 34 | 35 | if (string.IsNullOrEmpty(requestBody)) 36 | { 37 | return CreateResponse(req, HttpStatusCode.BadRequest, "Empty request. Pass powershell code in the request body for an AST analysis."); 38 | } 39 | 40 | Code? request; 41 | try 42 | { 43 | request = JsonSerializer.Deserialize(requestBody, new JsonSerializerOptions 44 | { 45 | PropertyNameCaseInsensitive = true 46 | }); 47 | } 48 | catch (Exception e) 49 | { 50 | logger.LogError(e, "Failed to deserialize SyntaxAnalyzer request"); 51 | return CreateResponse(req, HttpStatusCode.BadRequest, "Invalid request format. Pass powershell code in the request body for an AST analysis."); 52 | } 53 | 54 | var code = request?.PowershellCode ?? string.Empty; 55 | 56 | logger.LogInformation("PowerShell code sent: {Code}", code); 57 | 58 | ScriptBlockAst ast = Parser.ParseInput(code, out Token[] tokens, out ParseError[] parseErrors); 59 | 60 | if (string.IsNullOrEmpty(ast.Extent.Text)) 61 | { 62 | return CreateResponse(req, HttpStatusCode.BadRequest, "Empty request. Pass powershell code in the request body for an AST analysis."); 63 | } 64 | 65 | AnalysisResult analysisResult; 66 | try 67 | { 68 | var visitor = new AstVisitorExplainer(ast.Extent.Text, helpRepository: helpRepository, logger, tokens); 69 | ast.Visit(visitor); 70 | analysisResult = visitor.GetAnalysisResult(); 71 | } 72 | catch (Exception e) 73 | { 74 | logger.LogError(e, "An error occurred while analyzing the AST"); 75 | return CreateResponse(req, HttpStatusCode.InternalServerError, "Oops, someting went wrong internally. Please file an issue with the PowerShell code you submitted when this occurred."); 76 | } 77 | 78 | analysisResult.ParseErrorMessage = string.IsNullOrEmpty(analysisResult.ParseErrorMessage) 79 | ? parseErrors?.FirstOrDefault()?.Message ?? string.Empty 80 | : (analysisResult.ParseErrorMessage + "\n" + parseErrors?.FirstOrDefault()?.Message) ?? string.Empty; 81 | 82 | analysisResult.AiExplanation = string.Empty; 83 | 84 | var json = System.Text.Json.JsonSerializer.Serialize(analysisResult); 85 | return CreateResponse(req, HttpStatusCode.OK, json, "application/json"); 86 | } 87 | 88 | private static HttpResponseData CreateResponse(HttpRequestData req, HttpStatusCode status, string message, string mediaType = "text/plain") 89 | { 90 | var response = req.CreateResponse(status); 91 | response.Headers.Add("Content-Type", mediaType); 92 | response.WriteString(message, Encoding.UTF8); 93 | return response; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/AiExplanationFunction.cs: -------------------------------------------------------------------------------- 1 | using System.Net; 2 | using System.Text; 3 | using System.Text.Json; 4 | using explainpowershell.analysisservice.Services; 5 | using explainpowershell.models; 6 | using Microsoft.Azure.Functions.Worker; 7 | using Microsoft.Azure.Functions.Worker.Http; 8 | using Microsoft.Extensions.Logging; 9 | 10 | namespace ExplainPowershell.SyntaxAnalyzer 11 | { 12 | public sealed class AiExplanationFunction 13 | { 14 | private readonly ILogger logger; 15 | private readonly IAiExplanationService aiExplanationService; 16 | 17 | public AiExplanationFunction(ILogger logger, IAiExplanationService aiExplanationService) 18 | { 19 | this.logger = logger; 20 | this.aiExplanationService = aiExplanationService; 21 | } 22 | 23 | [Function("AiExplanation")] 24 | public async Task Run( 25 | [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) 26 | { 27 | string requestBody; 28 | using (var reader = new StreamReader(req.Body)) 29 | { 30 | requestBody = await reader.ReadToEndAsync().ConfigureAwait(false); 31 | } 32 | 33 | if (string.IsNullOrEmpty(requestBody)) 34 | { 35 | return CreateResponse(req, HttpStatusCode.BadRequest, "Empty request. Pass an AnalysisResult with PowerShell code in the request body."); 36 | } 37 | 38 | AiExplanationRequest? request; 39 | try 40 | { 41 | var options = new JsonSerializerOptions 42 | { 43 | PropertyNameCaseInsensitive = true 44 | }; 45 | request = JsonSerializer.Deserialize(requestBody, options); 46 | } 47 | catch (Exception e) 48 | { 49 | logger.LogError(e, "Failed to deserialize AI explanation request"); 50 | return CreateResponse(req, HttpStatusCode.BadRequest, "Invalid request format."); 51 | } 52 | 53 | if (request == null || string.IsNullOrEmpty(request.PowershellCode) || request.AnalysisResult == null) 54 | { 55 | return CreateResponse(req, HttpStatusCode.BadRequest, "Request must contain PowershellCode and AnalysisResult."); 56 | } 57 | 58 | logger.LogInformation("AI explanation requested for code: {Code}", request.PowershellCode); 59 | 60 | (string? aiExplanation, string? modelName) result; 61 | try 62 | { 63 | result = await aiExplanationService.GenerateAsync(request.PowershellCode, request.AnalysisResult).ConfigureAwait(false); 64 | } 65 | catch (Exception e) 66 | { 67 | logger.LogError(e, "An error occurred while generating AI explanation"); 68 | return CreateResponse(req, HttpStatusCode.InternalServerError, "Failed to generate AI explanation."); 69 | } 70 | 71 | var response = new AiExplanationResponse 72 | { 73 | AiExplanation = result.aiExplanation ?? string.Empty, 74 | ModelName = result.modelName 75 | }; 76 | 77 | var json = JsonSerializer.Serialize(response); 78 | return CreateResponse(req, HttpStatusCode.OK, json, "application/json"); 79 | } 80 | 81 | private static HttpResponseData CreateResponse(HttpRequestData req, HttpStatusCode status, string message, string mediaType = "text/plain") 82 | { 83 | var response = req.CreateResponse(status); 84 | response.Headers.Add("Content-Type", mediaType); 85 | response.WriteString(message, Encoding.UTF8); 86 | return response; 87 | } 88 | } 89 | 90 | public class AiExplanationRequest 91 | { 92 | public string PowershellCode { get; set; } = string.Empty; 93 | public AnalysisResult AnalysisResult { get; set; } = new(); 94 | } 95 | 96 | public class AiExplanationResponse 97 | { 98 | public string AiExplanation { get; set; } = string.Empty; 99 | public string? ModelName { get; set; } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1: -------------------------------------------------------------------------------- 1 | # Ensure we use the repo-local (optimized) helper implementation. 2 | # This avoids accidentally calling a stale `Invoke-SyntaxAnalyzer` already loaded in the caller's session. 3 | $invokeSyntaxAnalyzerPath = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-SyntaxAnalyzer.ps1' 4 | if (Test-Path -LiteralPath $invokeSyntaxAnalyzerPath) { 5 | . $invokeSyntaxAnalyzerPath 6 | } 7 | 8 | function Invoke-AiExplanation { 9 | <# 10 | .SYNOPSIS 11 | Invokes the Analysis Service "AiExplanation" endpoint for a given PowerShell snippet. 12 | 13 | .DESCRIPTION 14 | Sends PowerShell code (and optionally an existing analysis result) to the local Analysis Service. 15 | If -AnalysisResult is not provided, this function first obtains one by calling Invoke-SyntaxAnalyzer 16 | when available, otherwise it calls the SyntaxAnalyzer HTTP endpoint directly. 17 | 18 | By default, the function returns the raw web response. Use -AsObject to return the deserialized JSON 19 | payload, or -AiExplanation to return only the AiExplanation string. 20 | 21 | .PARAMETER PowershellCode 22 | The PowerShell code to analyze/explain. 23 | 24 | .PARAMETER AnalysisResult 25 | An existing analysis result object (typically the deserialized output of the SyntaxAnalyzer endpoint). 26 | When provided, the syntax analysis step is skipped. 27 | 28 | .PARAMETER BaseUri 29 | The base URI of the Analysis Service Functions host. 30 | 31 | .PARAMETER TimeoutSec 32 | The request timeout in seconds. 33 | 34 | .PARAMETER AsObject 35 | Return the response content as a PowerShell object (ConvertFrom-Json). 36 | 37 | .PARAMETER AiExplanation 38 | Return only the AiExplanation property from the response. 39 | 40 | .OUTPUTS 41 | When neither -AsObject nor -AiExplanation is specified, returns the Invoke-WebRequest response object. 42 | When -AsObject is specified, returns the deserialized JSON object. 43 | When -AiExplanation is specified, returns a string. 44 | 45 | .EXAMPLE 46 | Invoke-AiExplanation -PowershellCode 'Get-Process | Select-Object -First 1' 47 | 48 | .EXAMPLE 49 | Invoke-AiExplanation -PowershellCode 'Get-Date' -AiExplanation 50 | 51 | .EXAMPLE 52 | $analysis = (Invoke-SyntaxAnalyzer -PowershellCode 'Get-ChildItem').Content | ConvertFrom-Json 53 | Invoke-AiExplanation -PowershellCode 'Get-ChildItem' -AnalysisResult $analysis -AsObject 54 | #> 55 | param( 56 | [Parameter(Mandatory)] 57 | [string]$PowershellCode, 58 | 59 | [Parameter()] 60 | [object]$AnalysisResult, 61 | 62 | [Parameter()] 63 | [string]$BaseUri = 'http://127.0.0.1:7071/api', 64 | 65 | [Parameter()] 66 | [int]$TimeoutSec = 30, 67 | 68 | [Parameter()] 69 | [switch]$AsObject, 70 | 71 | [Parameter()] 72 | [switch]$AiExplanation 73 | ) 74 | 75 | $ErrorActionPreference = 'stop' 76 | 77 | if (-not $AnalysisResult) { 78 | if (Get-Command -Name Invoke-SyntaxAnalyzer -ErrorAction SilentlyContinue) { 79 | $analysisResponse = Invoke-SyntaxAnalyzer -PowershellCode $PowershellCode -BaseUri $BaseUri -TimeoutSec $TimeoutSec 80 | $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json 81 | } 82 | else { 83 | $analysisBody = @{ PowershellCode = $PowershellCode } | ConvertTo-Json 84 | 85 | $analysisResponse = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $analysisBody -ContentType 'application/json' -TimeoutSec $TimeoutSec 86 | $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json 87 | } 88 | } 89 | 90 | $body = @{ 91 | PowershellCode = $PowershellCode 92 | AnalysisResult = $AnalysisResult 93 | } | ConvertTo-Json -Depth 20 94 | 95 | # Note: the function route is `AiExplanation`, but the Functions host is case-insensitive. 96 | 97 | $response = Invoke-WebRequest -Uri "$BaseUri/aiexplanation" -Method Post -Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec 98 | 99 | if ($AsObject -or $AiExplanation) { 100 | $result = $response.Content | ConvertFrom-Json 101 | 102 | if ($AiExplanation) { 103 | return $result.AiExplanation 104 | } 105 | 106 | return $result 107 | } 108 | 109 | return $response 110 | } 111 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/Get-HelpDatabaseData.ps1: -------------------------------------------------------------------------------- 1 | using namespace Microsoft.Azure.Cosmos.Table 2 | 3 | <# 4 | .SYNOPSIS 5 | Gets help data from the HelpData Azure Table (local Azurite or production). 6 | 7 | .DESCRIPTION 8 | Retrieves entities from the HelpData table in the CommandHelp partition. 9 | 10 | By default, the function uses a local Azurite Table endpoint via a connection string. 11 | When -IsProduction is specified, it creates a Storage context using a SAS token and queries 12 | the production Storage Account. 13 | 14 | If -ReturnTable is specified, the underlying CloudTable object is returned instead of data. 15 | 16 | .PARAMETER RowKey 17 | Optional RowKey to filter for a single entity. When omitted, all entities in the CommandHelp 18 | partition are returned. 19 | 20 | .PARAMETER ReturnTable 21 | Returns the underlying CloudTable object for the HelpData table. 22 | 23 | .PARAMETER IsProduction 24 | Uses the production Storage Account instead of the local Azurite Table endpoint. 25 | 26 | .PARAMETER StorageAccountName 27 | Production-only. The Azure Storage Account name. 28 | 29 | .PARAMETER ResourceGroupName 30 | Production-only. The Azure Resource Group name containing the Storage Account. 31 | 32 | .EXAMPLE 33 | Get-HelpDatabaseData 34 | 35 | Returns all entities in the CommandHelp partition from the local Azurite table. 36 | 37 | .EXAMPLE 38 | Get-HelpDatabaseData -RowKey 'Get-Process' 39 | 40 | Returns the entity with RowKey 'Get-Process' from the local Azurite table. 41 | 42 | .EXAMPLE 43 | Get-HelpDatabaseData -IsProduction -RowKey 'Get-Process' -ResourceGroupName 'powershellexplainer' -StorageAccountName 'explainpowershell' 44 | 45 | Returns the entity with RowKey 'Get-Process' from the production table. 46 | 47 | .EXAMPLE 48 | $table = Get-HelpDatabaseData -ReturnTable 49 | 50 | Returns the CloudTable object. 51 | 52 | .OUTPUTS 53 | Microsoft.Azure.Cosmos.Table.DynamicTableEntity[] 54 | Microsoft.Azure.Cosmos.Table.CloudTable 55 | #> 56 | function Get-HelpDatabaseData { 57 | [CmdletBinding(DefaultParameterSetName = 'local')] 58 | param( 59 | [parameter(ParameterSetName = 'local', Position = '0')] 60 | [parameter(ParameterSetName = 'production', Position = '0')] 61 | [string]$RowKey, 62 | 63 | [parameter(ParameterSetName = 'local')] 64 | [parameter(ParameterSetName = 'production')] 65 | [switch]$ReturnTable, 66 | 67 | [parameter(ParameterSetName = 'production')] 68 | [switch]$IsProduction, 69 | 70 | [parameter(ParameterSetName = 'production')] 71 | [String]$StorageAccountName = 'explainpowershell', 72 | 73 | [parameter(ParameterSetName = 'production')] 74 | [String]$ResourceGroupName = 'powershellexplainer' 75 | ) 76 | 77 | $tableName = 'HelpData' 78 | $partitionKey = 'CommandHelp' 79 | 80 | if ($IsProduction) { 81 | . /workspace/explainpowershell.helpcollector/New-SasToken.ps1 82 | $sasToken = New-SasToken -ResourceGroupName $ResourceGroupName -StorageAccountName $storageAccountName 83 | $storageCtx = New-AzStorageContext -StorageAccountName $storageAccountName -SasToken $sasToken 84 | } 85 | else { 86 | $azuriteLocalConnectionString = 'AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;DefaultEndpointsProtocol=http;BlobEndpoint=http://127.0.0.1:10000/devstoreaccount1;QueueEndpoint=http://127.0.0.1:10001/devstoreaccount1;TableEndpoint=http://127.0.0.1:10002/devstoreaccount1;' 87 | $storageCtx = New-AzStorageContext -ConnectionString $azuriteLocalConnectionString 88 | } 89 | 90 | $table = (Get-AzStorageTable -Context $storageCtx -Name $tableName).CloudTable 91 | if ($ReturnTable) { 92 | return $table 93 | } 94 | elseif (-not $rowKey) { 95 | $query = [TableQuery]@{ 96 | FilterString = "PartitionKey eq '$partitionKey'" 97 | } 98 | 99 | return $table.ExecuteQuery($query) 100 | } 101 | else { 102 | $query = [TableQuery]@{ 103 | FilterString = [TableQuery]::CombineFilters( 104 | [TableQuery]::GenerateFilterCondition( 105 | 'PartitionKey', 106 | [QueryComparisons]::Equal, 107 | $partitionKey 108 | ), 109 | 'and', 110 | [TableQuery]::GenerateFilterCondition( 111 | 'RowKey', 112 | [QueryComparisons]::Equal, 113 | $rowKey 114 | ) 115 | ) 116 | } 117 | 118 | return $table.ExecuteQuery($query) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /explainpowershell.azureinfra/template.bicep: -------------------------------------------------------------------------------- 1 | param functionAppName string 2 | param appServicePlanName string 3 | param storageAccountName string 4 | param location string = resourceGroup().location 5 | 6 | resource storageAccount 'Microsoft.Storage/storageAccounts@2023-01-01' = { 7 | name: storageAccountName 8 | location: location 9 | sku: { 10 | name: 'Standard_LRS' 11 | } 12 | kind: 'StorageV2' 13 | properties: { 14 | minimumTlsVersion: 'TLS1_2' 15 | allowBlobPublicAccess: true 16 | allowSharedKeyAccess: true 17 | supportsHttpsTrafficOnly: true 18 | encryption: { 19 | services: { 20 | file: { 21 | keyType: 'Account' 22 | enabled: true 23 | } 24 | blob: { 25 | keyType: 'Account' 26 | enabled: true 27 | } 28 | } 29 | keySource: 'Microsoft.Storage' 30 | } 31 | accessTier: 'Hot' 32 | } 33 | } 34 | 35 | resource storageBlobService 'Microsoft.Storage/storageAccounts/blobServices@2023-01-01' = { 36 | name: 'default' 37 | parent: storageAccount 38 | properties: { 39 | cors: { 40 | corsRules: [] 41 | } 42 | staticWebsite: { 43 | enabled: true 44 | indexDocument: 'index.html' 45 | errorDocument404Path: 'index.html' 46 | } 47 | } 48 | } 49 | 50 | resource storageQueueService 'Microsoft.Storage/storageAccounts/queueServices@2023-01-01' = { 51 | name: 'default' 52 | parent: storageAccount 53 | properties: { 54 | cors: { 55 | corsRules: [] 56 | } 57 | } 58 | } 59 | 60 | resource storageTableService 'Microsoft.Storage/storageAccounts/tableServices@2023-01-01' = { 61 | name: 'default' 62 | parent: storageAccount 63 | properties: { 64 | cors: { 65 | corsRules: [] 66 | } 67 | } 68 | } 69 | 70 | resource webContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = { 71 | name: '$web' 72 | parent: storageBlobService 73 | properties: { 74 | publicAccess: 'Blob' 75 | } 76 | } 77 | 78 | resource webjobsHostsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = { 79 | name: 'azure-webjobs-hosts' 80 | parent: storageBlobService 81 | properties: { 82 | publicAccess: 'None' 83 | } 84 | } 85 | 86 | resource webjobsSecretsContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2023-01-01' = { 87 | name: 'azure-webjobs-secrets' 88 | parent: storageBlobService 89 | properties: { 90 | publicAccess: 'None' 91 | } 92 | } 93 | 94 | resource helpDataTable 'Microsoft.Storage/storageAccounts/tableServices/tables@2023-01-01' = { 95 | name: 'HelpData' 96 | parent: storageTableService 97 | } 98 | 99 | resource appServicePlan 'Microsoft.Web/serverfarms@2023-01-01' = { 100 | name: appServicePlanName 101 | location: location 102 | sku: { 103 | name: 'Y1' 104 | tier: 'Dynamic' 105 | } 106 | kind: 'functionapp' 107 | properties: { 108 | perSiteScaling: false 109 | maximumElasticWorkerCount: 1 110 | } 111 | } 112 | 113 | resource functionApp 'Microsoft.Web/sites@2023-01-01' = { 114 | name: functionAppName 115 | location: location 116 | kind: 'functionapp' 117 | properties: { 118 | httpsOnly: true 119 | serverFarmId: appServicePlan.id 120 | siteConfig: { 121 | appSettings: [ 122 | { 123 | name: 'AzureWebJobsStorage' 124 | value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};AccountKey=${listKeys(storageAccount.id, storageAccount.apiVersion).keys[0].value};EndpointSuffix=${environment().suffixes.storage}' 125 | } 126 | { 127 | name: 'FUNCTIONS_EXTENSION_VERSION' 128 | value: '~4' 129 | } 130 | { 131 | name: 'FUNCTIONS_WORKER_RUNTIME' 132 | value: 'dotnet-isolated' 133 | } 134 | ] 135 | cors: { 136 | allowedOrigins: [ 137 | 'https://functions.azure.com' 138 | 'https://functions-staging.azure.com' 139 | 'https://functions-next.azure.com' 140 | substring(storageAccount.properties.primaryEndpoints.web, 0, length(storageAccount.properties.primaryEndpoints.web) - 1) 141 | ] 142 | supportCredentials: false 143 | } 144 | http20Enabled: true 145 | minTlsVersion: '1.2' 146 | ftpsState: 'Disabled' 147 | } 148 | } 149 | dependsOn: [ 150 | storageAccount 151 | ] 152 | } 153 | 154 | resource functionHostname 'Microsoft.Web/sites/hostNameBindings@2023-01-01' = { 155 | name: '${functionApp.name}/${functionApp.name}.azurewebsites.net' 156 | location: location 157 | properties: { 158 | siteName: functionApp.name 159 | hostNameType: 'Verified' 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /.github/workflows/add_module_help.yml: -------------------------------------------------------------------------------- 1 | name: Check if we need to automatically add help for a module 2 | 3 | on: 4 | issue_comment: 5 | types: [created, edited] 6 | 7 | jobs: 8 | moduleHelpAdd: 9 | name: Add help for module '${{ github.event.comment.body }}' 10 | if: github.event.issue.number == 43 && ! startsWith(github.event.comment.body, '#') 11 | runs-on: ubuntu-latest 12 | env: 13 | MODULE_NAME: ${{ github.event.comment.body }} 14 | steps: 15 | 16 | - name: Comment back saying the process has started 17 | uses: jungwinter/comment@v1 18 | id: create 19 | with: 20 | type: create 21 | issue_number: ${{ github.event.issue.number }} 22 | body: "#. Hi @${{ github.event.sender.login }}, I've started working on your module '${{ github.event.comment.body }}'. You can follow the details [under Actions](https://github.com/Jawz84/explainpowershell/actions/runs/${{ github.run_id }}), but I will report back here when I'm done too." 23 | token: ${{ secrets.GITHUB_TOKEN }} 24 | 25 | - name: install module if necessary 26 | shell: pwsh 27 | run: | 28 | $env:MODULE_NAME = $env:MODULE_NAME.Trim() 29 | if (-not (Get-Module -ListAvailable -Name $env:MODULE_NAME)) { 30 | Install-Module -Force $env:MODULE_NAME.replace('*','') 31 | Write-Output "Module '$env:MODULE_NAME' installed" 32 | } 33 | else { 34 | Write-Output "Module '$env:MODULE_NAME' present" 35 | } 36 | 37 | - uses: actions/checkout@v1 38 | 39 | - name: get help info 40 | shell: pwsh 41 | run: | 42 | $helpCollectorSplat = @{ 43 | ModulesToProcess = @(Get-Module -ListAvailable $env:MODULE_NAME) 44 | verbose = $true 45 | } 46 | 47 | ./explainpowershell.helpcollector/helpcollector.ps1 @helpCollectorSplat 48 | | Tee-Object -variable out 49 | | ConvertTo-Json 50 | | Out-File -path "./explainpowershell.helpcollector/help.$env:MODULE_NAME.cache.json" 51 | 52 | $fileSize = (Get-Item "./explainpowershell.helpcollector/help.$env:MODULE_NAME.cache.json").Length / 1Kb 53 | Write-Host "Output file size: $fileSize Kb, containing $($out.count) help items" -ForegroundColor Cyan 54 | if ($fileSize -eq 0) { 55 | throw "The script was unable to acquire help automatically. Feel free to create an issue for this." 56 | } 57 | 58 | - name: Authenticate with Azure 59 | uses: azure/login@v2 60 | with: 61 | creds: ${{ secrets.AZURE_SERVICE_PRINCIPAL }} 62 | enable-AzPSSession: true 63 | 64 | - name: Verify Azure Authentication 65 | uses: azure/powershell@v2 66 | with: 67 | inlineScript: | 68 | Write-Host "Testing Azure authentication..." 69 | $context = Get-AzContext 70 | Write-Host "Authenticated as: $($context.Account.Id)" 71 | Write-Host "Subscription: $($context.Subscription.Name)" 72 | azPSVersion: 'latest' 73 | 74 | - name: Write help info 75 | uses: azure/powershell@v2 76 | with: 77 | inlineScript: | 78 | ./explainpowershell.helpcollector/helpwriter.ps1 -HelpDataCacheFilename "./explainpowershell.helpcollector/help.$env:MODULE_NAME.cache.json" -IsProduction 79 | azPSVersion: 'latest' 80 | 81 | - name: Comment back saying the module was added successfully 82 | if: success() 83 | uses: jungwinter/comment@v1 84 | with: 85 | type: edit 86 | issue_number: ${{ github.event.issue.number }} 87 | comment_id: ${{ steps.create.outputs.id }} 88 | body: "#. Great news @${{ github.event.sender.login }}, your module '${{ github.event.comment.body }}' was added successfully! :+1:" 89 | token: ${{ secrets.GITHUB_TOKEN }} 90 | 91 | - name: Comment back saying the module was not added successfully 92 | if: failure() 93 | uses: jungwinter/comment@v1 94 | with: 95 | type: edit 96 | issue_number: ${{ github.event.issue.number }} 97 | comment_id: ${{ steps.create.outputs.id }} 98 | body: "#. Sorry @${{ github.event.sender.login }} cc @jawz84, I was not able to process module '${{ github.event.comment.body }}' on my own.. :-( [See details here](https://github.com/Jawz84/explainpowershell/actions/runs/${{ github.run_id }})" 99 | token: ${{ secrets.GITHUB_TOKEN }} 100 | 101 | - name: Calculate new HelpMetaData 102 | uses: azure/powershell@v2 103 | with: 104 | inlineScript: | 105 | Get-AzFunctionApp 106 | | Where-Object { $_.Name -match 'powershellexplainer' -and $_.Status -eq 'running' } 107 | | ForEach-Object { Invoke-RestMethod -Uri "https://$($_.DefaultHostName)/api/MetaData?refresh=true" } 108 | azPSVersion: 'latest' -------------------------------------------------------------------------------- /tools/bump_versions.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | param( 3 | [string]$TargetDotnetVersion 4 | ) 5 | 6 | $ErrorActionPreference = 'stop' 7 | 8 | $currentGitBranch = git branch --show-current 9 | if ($currentGitBranch -eq 'main') { 10 | throw "You are on 'main' branch, create a new branch first" 11 | } 12 | 13 | function Ensure-Module { 14 | param([string]$Name) 15 | if (-not (Get-Module -ListAvailable $Name)) { 16 | Install-Module -Name $Name -Force 17 | } 18 | } 19 | 20 | Ensure-Module -Name 'powershell-yaml' 21 | 22 | function Get-TargetDotnetVersion { 23 | param([string]$Override) 24 | if ($Override) { return [version]$Override } 25 | $sdks = dotnet --list-sdks | ForEach-Object { [version]($_.Split(' ')[0]) } 26 | if (-not $sdks) { 27 | throw 'No .NET SDKs found. Install a recent SDK or provide -TargetDotnetVersion.' 28 | } 29 | return ($sdks | Sort-Object | Select-Object -Last 1) 30 | } 31 | 32 | $dotNetVersion = Get-TargetDotnetVersion -Override $TargetDotnetVersion 33 | $dotNetShortVersion = '{0}.{1}' -f $dotNetVersion.Major, $dotNetVersion.Minor 34 | $functionsToolsVersion = try { [version](func --version) } catch { [version]'4.0.0' } 35 | 36 | Write-Host "Updating project files to net$dotNetShortVersion" 37 | 38 | Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForEach-Object { 39 | $xml = [xml](Get-Content $_.FullName) 40 | foreach ($group in @($xml.Project.PropertyGroup)) { 41 | if ($group.TargetFramework) { 42 | $group.TargetFramework = "net$dotNetShortVersion" 43 | } 44 | } 45 | foreach ($itemGroup in @($xml.Project.ItemGroup)) { 46 | foreach ($reference in @($itemGroup.Reference)) { 47 | if ($reference.HintPath) { 48 | $reference.HintPath = $reference.HintPath -replace '\\net\d+\.\d+\\', "\net$dotNetShortVersion\" 49 | } 50 | } 51 | } 52 | $xml.Save($_.FullName) 53 | } 54 | 55 | Write-Host "Updating Azure Functions settings to runtime ~$($functionsToolsVersion.Major)" 56 | $vsCodeSettingsFile = "$PSScriptRoot/../.vscode/settings.json" 57 | if (Test-Path $vsCodeSettingsFile) { 58 | $settings = Get-Content -Path $vsCodeSettingsFile | ConvertFrom-Json 59 | $settings.'azureFunctions.deploySubpath' = "bin/Release/net$dotNetShortVersion/publish" 60 | $settings.'azureFunctions.projectRuntime' = "~$($functionsToolsVersion.Major)" 61 | $settings | ConvertTo-Json -Depth 5 | Out-File -Path $vsCodeSettingsFile -Force 62 | } 63 | 64 | Write-Host "Updating GitHub Actions workflows" 65 | $deployAppWorkflow = "$PSScriptRoot/../.github/workflows/deploy_app.yml" 66 | if (Test-Path $deployAppWorkflow) { 67 | $ghFlow = Get-Content -Path $deployAppWorkflow 68 | $ghFlow | ForEach-Object { $_ -replace 'dotnet-version: "\d+.0"', "dotnet-version: `"$($dotNetVersion.Major).0`"" } 69 | | Set-Content -Path $deployAppWorkflow -Force 70 | } 71 | else { 72 | Write-Host "No deploy_app.yml workflow found, skipping update." 73 | } 74 | 75 | Write-Host "Updating NuGet package versions to latest stable" 76 | ## Update packages (mirrors Pester checks: dotnet list package --outdated) 77 | $outdated = dotnet list "$PSScriptRoot/../explainpowershell.sln" package --outdated 78 | $outdated += dotnet list "$PSScriptRoot/../explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj" package --outdated 79 | 80 | $targetVersions = $outdated | Select-String '^ >' | ForEach-Object { 81 | $parts = ($_ -split '\s{2,}') | Where-Object { $_ } 82 | [pscustomobject]@{ 83 | PackageName = $parts[0].Trim('>',' ') 84 | LatestVersion = [version]$parts[-1] 85 | } 86 | } | Sort-Object PackageName -Unique 87 | 88 | Write-Host "Found $($targetVersions.Count) packages to update: $($targetVersions.PackageName -join ', ')" 89 | 90 | Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForEach-Object { 91 | $xml = [xml](Get-Content $_.FullName) 92 | foreach ($itemGroup in @($xml.Project.ItemGroup)) { 93 | foreach ($package in @($itemGroup.PackageReference)) { 94 | if ($package.Include -in $targetVersions.PackageName) { 95 | $latest = ($targetVersions | Where-Object PackageName -EQ $package.Include).LatestVersion 96 | Write-Debug "Updating '$($package.Include)' to version '$latest' in '$($_.FullName)'" 97 | if ($latest) { 98 | $package.Version = $latest.ToString() 99 | } 100 | } 101 | } 102 | } 103 | $xml.Save($_.FullName) 104 | } 105 | 106 | Write-Host "Restoring and cleaning solution" 107 | Push-Location $PSScriptRoot/.. 108 | dotnet restore 109 | dotnet clean --verbosity minimal 110 | Pop-Location 111 | 112 | Write-Host -ForegroundColor Magenta 'Version bump complete.' -------------------------------------------------------------------------------- /.tours/tour-of-the-help-collector.tour: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://aka.ms/codetour-schema", 3 | "title": "Tour of the help collector", 4 | "steps": [ 5 | { 6 | "file": ".github/workflows/add_module_help.yml", 7 | "description": "Welcome to the help collector tour.\n\nThe CmdLet explanations generated by Explain PowerShell come from cached help information from modules. To cache help information, the module must be installed and all help files for this module must be cached. We call that help scraping or collecting. \n\nWe are going to have a look at what the help collection process looks like by following the process from request to cached help.\n\nThis Yaml pipeline is a GitHub Action that is automatically run when someone comments on issue [#43](https://github.com/Jawz84/explainpowershell/issues/43).\n", 8 | "line": 2 9 | }, 10 | { 11 | "file": ".github/workflows/add_module_help.yml", 12 | "description": "If the module is not installed on the machine running this pipeline, try to install it.", 13 | "line": 25 14 | }, 15 | { 16 | "file": ".github/workflows/add_module_help.yml", 17 | "description": "After the module is installed, the `helpcollector.ps1` script is run. This script will get all the relevant help information from the module. \n\nThe output is saved to json, and a check is done to see if there actually is any data in the file. ", 18 | "line": 39 19 | }, 20 | { 21 | "file": ".github/workflows/add_module_help.yml", 22 | "description": "Here the `helpwriter.ps1` script is started, and is given the path to the cached json output from the previous step. This basically writes the information to an Azure Storage Table.\n\nThe steps below are just to notify the requester of the module if everything succeeded or not. We will have a look over at the scripts now, to see what they do.", 23 | "line": 74 24 | }, 25 | { 26 | "file": "explainpowershell.helpcollector/helpcollector.ps1", 27 | "description": "This is the `helpcollector.ps1` script that reads help information for each command in a module. \n\nIt: \n- Tries to update help for the module\n- Gets names of all the commands (and skips aliases)\n- Gathers help info for each command and outputs this as an object to the pipeline.", 28 | "line": 86 29 | }, 30 | { 31 | "file": "explainpowershell.helpcollector/helpwriter.ps1", 32 | "description": "We store the help data in an Azure Storage Table. These are tables with two very important columns: `PartitionKey` and `RowKey`. Data from a Table like this, is retrieved based on these two columns. If we want a certain row, we ask for `(PartitionKey='x' RowKey='y')`, if our data is at x, y so to speak. So if we store data, the string that is in the PartitionKey and the RowKey, needs to be unique and easily searchable. That's why we convert the name of the command to lower case. When we search for a command later, we can convert that commandname to lower too, and be sure we always find the right command, even when the user MIsTyped-ThecOmmand. ", 33 | "line": 117 34 | }, 35 | { 36 | "file": "explainpowershell.helpcollector/BulkHelpCollector.ps1", 37 | "description": "This script is used to scrape a lot of modules at once, after they are installed manually. Some modules require special prerequisites (like the Windows RSAT module, or the Exchange module). This script can be usefull in those cases. It produces a json file per scraped module.\n\nIt can be used in conjunction with the `BulkHelpCacheUploader.ps1` script, to later upload all cached json help.", 38 | "line": 5 39 | }, 40 | { 41 | "file": "explainpowershell.helpcollector/aboutcollector.ps1", 42 | "description": "All PowerShell built in about_-articles are also scraped and available. This is a treasure trove for additional details for many Explanations. \n\nAnother cool thing is that you can get a link to the about_-article by just asking for an Explanation with only the about_-article. For instance, requesting `about_History` will give you an explanation result with the link to that about_-article.\n\nAbout_-articles are only available after updating help from the internet. That's why there is a check if they are present at the top, and if not, an update help.\n\nWhen we want to be able to provide links to the about_-articles to the end user, we need to find the online url for them. I have not found a more elegant way than trial and error yet. Each possibility is tried in turn until a request succeeds.\n\nAt the last part, the synopsis of the about_article is extracted, so we have some info to show to the user.\n\nAll found info is piped to the pipeline as objects, so we can further process them elsewhere.\n\nThis code is used in the `bootstrap.ps1` script to ensure a working dev environment.\n\n\n> To view the help data in Azurite or in your own Azure Table in Azure, you can use the `Microsoft Azure Storage Explorer` application, found here: https://azure.microsoft.com/en-us/features/storage-explorer/", 43 | "line": 2 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /explainpowershell.helpcollector/helpcollector.ps1: -------------------------------------------------------------------------------- 1 | [cmdletbinding()] 2 | param( 3 | [pscustomobject[]] 4 | $ModulesToProcess 5 | ) 6 | 7 | . $PSScriptRoot/HelpCollector.Functions.ps1 8 | 9 | $ModulesToProcess = $ModulesToProcess | Sort-Object -Unique -Property Name 10 | 11 | $script:badUrls = @() 12 | 13 | foreach ($mod in $ModulesToProcess) { 14 | if (-not $PSCmdlet.MyInvocation.BoundParameters['Verbose'].IsPresent) { 15 | Write-Progress -Id 1 -Activity "Processing '$($ModulesToProcess.Count)' modules." -CurrentOperation "Processing module '$($mod.Name)'" -PercentComplete ((@($ModulesToProcess).IndexOf($mod) + 1) / $ModulesToProcess.Count * 100) 16 | } 17 | else { 18 | Write-Verbose "Processing module '$($mod.Name)'" 19 | } 20 | 21 | try { 22 | Update-Help -Module $mod.Name -Force -ErrorAction stop 23 | } 24 | catch { 25 | Write-Warning "$_" 26 | } 27 | 28 | [string]$moduleProjectUri = $mod.ProjectUri 29 | 30 | $commandsToProcess = (Get-Command -Module $mod.name).where{ $_.CommandType -ne 'Alias' } 31 | 32 | foreach ($cmd in $commandsToProcess) { 33 | Write-Debug $cmd.Name 34 | 35 | if (-not $PSCmdlet.MyInvocation.BoundParameters['Verbose'].IsPresent) { 36 | Write-Progress -ParentId 1 -Activity "Processing '$($commandsToProcess.Count)' commands" -CurrentOperation "Processing command '$($cmd.Name)'" -PercentComplete ((@($commandsToProcess).IndexOf($cmd) + 1) / $commandsToProcess.Count * 100) 37 | } 38 | 39 | try { 40 | $help = Get-Help $cmd.Name -ErrorAction stop 41 | } 42 | catch { 43 | Write-Warning "!!$($cmd.name) - Unexpected error in Get-Help: $_" 44 | $help = $null 45 | } 46 | $null = $help ?? $(Write-Warning "!!$($cmd.name) - No local help present, limited results") 47 | 48 | $relatedLinks = $help.relatedLinks.navigationLink.where{ $_.uri -match '^http' }.uri ?? $cmd.HelpUri ?? $moduleProjectUri 49 | $documentationLink = $relatedLinks | Select-Object -First 1 50 | 51 | $synopsis, $documentationLink = Get-Synopsis -Help $help -Cmd $cmd -DocumentationLink $DocumentationLink 52 | 53 | try { 54 | $syntax = ($help.syntax | Out-String).Trim() 55 | if ($syntax -like '*syntaxItem*') { 56 | $syntax = Get-Command $cmd.Name -Syntax 57 | } 58 | } 59 | catch { 60 | Write-Warning "!!$($cmd.name) - Something went wrong getting syntax info: $_" 61 | } 62 | 63 | $parameterData = $help.parameters.parameter.foreach{ 64 | try { 65 | $cmdParam = $cmd.Parameters[$_.name] 66 | } 67 | catch { 68 | Write-Verbose "$($cmd.name) - No command parameter data" 69 | } 70 | 71 | [pscustomobject]@{ 72 | Aliases = ($_.aliases -join ', ') ?? ($cmdParam.Aliases -join ', ') 73 | DefaultValue = $_.defaultValue 74 | Description = $_.Description.Text -join '' 75 | Globbing = $_.globbing 76 | IsDynamic = $cmdParam.IsDynamic 77 | Name = $_.name 78 | ParameterSets = $cmdParam.ParameterSets 79 | PipelineInput = $_.pipelineInput 80 | Position = $_.position 81 | Required = $_.required 82 | SwitchParameter = $cmdParam.SwitchParameter 83 | TypeName = $_.parameterValue ?? $cmdParam.ParameterType.FullName 84 | } 85 | } 86 | 87 | [pscustomobject]@{ 88 | Aliases = @($help.Aliases) -join ', ' 89 | CommandName = $cmd.Name 90 | DefaultParameterSet = $cmd.DefaultParameterSet 91 | Description = $help.Description.Text -join '' 92 | DocumentationLink = $documentationLink 93 | InputTypes = $help.InputTypes.inputType.type.name -join ', ' 94 | ModuleName = $cmd.ModuleName 95 | ModuleVersion = "$($cmd.Module.Version ?? $cmd.Version ?? '')" 96 | ModuleProjectUri = $moduleProjectUri 97 | Parameters = $parameterData | ConvertTo-Json -Compress -Depth 3 98 | ParameterSetNames = @($parameterData.ParameterSets.Keys | Where-Object { $_ -ne '__AllParameterSets' } | Sort-Object -Unique) -join ', ' 99 | RelatedLinks = $relatedLinks -join ', ' 100 | ReturnValues = $help.ReturnValues.returnValue.type.name -join ', ' 101 | Synopsis = $synopsis 102 | Syntax = $syntax 103 | } 104 | } 105 | 106 | if (-not $PSCmdlet.MyInvocation.BoundParameters['Verbose'].IsPresent) { 107 | Write-Progress -ParentId 1 -Activity "Processing '$($commandsToProcess.Count)' commands" -CurrentOperation 'Completed' -Completed 108 | Write-Progress -Id 1 -Activity "Processing '$($ModulesToProcess.Count)' modules." -CurrentOperation 'Completed' -Completed 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice/MetaData.cs: -------------------------------------------------------------------------------- 1 | using Azure; 2 | using Azure.Data.Tables; 3 | using explainpowershell.models; 4 | using Microsoft.Azure.Functions.Worker; 5 | using Microsoft.Azure.Functions.Worker.Http; 6 | using Microsoft.Extensions.Logging; 7 | using System; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using System.Net; 11 | using System.Reflection; 12 | using System.Text.Json; 13 | using System.Threading.Tasks; 14 | 15 | namespace explainpowershell.analysisservice 16 | { 17 | public sealed class MetaDataFunction 18 | { 19 | private const string HelpTableName = "HelpData"; 20 | private const string MetaDataPartitionKey = "HelpMetaData"; 21 | private const string MetaDataRowKey = "HelpMetaData"; 22 | private const string CommandHelpPartitionKey = "CommandHelp"; 23 | private readonly ILogger logger; 24 | 25 | public MetaDataFunction(ILogger logger) 26 | { 27 | this.logger = logger; 28 | } 29 | 30 | [Function("MetaData")] 31 | public async Task Run( 32 | [HttpTrigger(AuthorizationLevel.Anonymous, "get")] HttpRequestData req) 33 | { 34 | var client = TableClientFactory.Create(HelpTableName); 35 | HelpMetaData helpMetaData; 36 | if (ShouldRefresh(req)) 37 | { 38 | helpMetaData = CalculateMetaData(client, logger); 39 | } 40 | else 41 | { 42 | logger.LogInformation("Trying to get HelpMetaData from cache"); 43 | try 44 | { 45 | helpMetaData = client.GetEntity(MetaDataPartitionKey, MetaDataRowKey); 46 | } 47 | catch (RequestFailedException) 48 | { 49 | helpMetaData = CalculateMetaData(client, logger); 50 | } 51 | } 52 | 53 | var json = JsonSerializer.Serialize(helpMetaData); 54 | var response = req.CreateResponse(HttpStatusCode.OK); 55 | response.Headers.Add("Content-Type", "application/json"); 56 | await response.WriteStringAsync(json).ConfigureAwait(false); 57 | return response; 58 | } 59 | 60 | public static HelpMetaData CalculateMetaData(TableClient client, ILogger log) 61 | { 62 | log.LogInformation("Calculating meta data on HelpTable"); 63 | 64 | string filter = TableServiceClient.CreateQueryFilter($"PartitionKey eq {CommandHelpPartitionKey}"); 65 | var select = new[] { "CommandName", "ModuleName" }; 66 | var entities = client.Query(filter: filter, select: select).ToList(); 67 | 68 | var numAbout = entities 69 | .Count(r => r.CommandName?.StartsWith("about_", StringComparison.OrdinalIgnoreCase) ?? false); 70 | 71 | var moduleNames = entities 72 | .Select(r => r.ModuleName) 73 | .Where(moduleName => !string.IsNullOrEmpty(moduleName)) 74 | .Distinct(); 75 | 76 | var helpMetaData = new HelpMetaData() 77 | { 78 | PartitionKey = MetaDataPartitionKey, 79 | RowKey = MetaDataRowKey, 80 | NumberOfAboutArticles = numAbout, 81 | NumberOfCommands = entities.Count() - numAbout, 82 | NumberOfModules = moduleNames.Count(), 83 | ModuleNames = string.Join(',', moduleNames), 84 | LastPublished = Helpers.GetBuildDate(Assembly.GetExecutingAssembly()).ToLongDateString() 85 | }; 86 | 87 | try 88 | { 89 | _ = client.GetEntity(MetaDataPartitionKey, MetaDataRowKey); 90 | _ = client.UpsertEntity(helpMetaData); 91 | } 92 | catch (RequestFailedException) 93 | { 94 | _ = client.AddEntity(helpMetaData); 95 | } 96 | 97 | return helpMetaData; 98 | } 99 | 100 | private static bool ShouldRefresh(HttpRequestData req) 101 | { 102 | var query = req.Url.Query; 103 | if (string.IsNullOrEmpty(query)) 104 | { 105 | return false; 106 | } 107 | 108 | var pairs = query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries); 109 | foreach (var pair in pairs) 110 | { 111 | var kvp = pair.Split('=', 2); 112 | if (kvp.Length == 0) 113 | { 114 | continue; 115 | } 116 | 117 | if (!kvp[0].Equals("refresh", StringComparison.OrdinalIgnoreCase)) 118 | { 119 | continue; 120 | } 121 | 122 | var value = kvp.Length > 1 ? Uri.UnescapeDataString(kvp[1]) : string.Empty; 123 | return value.Equals("true", StringComparison.OrdinalIgnoreCase); 124 | } 125 | 126 | return false; 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/testfiles/oneliners.ps1: -------------------------------------------------------------------------------- 1 | help command -full 2 | Clear-Host 3 | get-history | more 4 | invoke-history 12 5 | invoke-history | export-csv c:\temp\output.txt 6 | start-transcript c:\temp\output.txt -IncludeInvocationHeader 7 | stop-transcript 8 | gip 9 | get-netadapter 10 | get-netadapter "MyConnection01" | get-netroute -addressfamily ipv4 11 | Get-NetTCPConnection | ? State -eq Established | sort Localport | FT -Autosize 12 | Get-DnsClientCache 13 | Get-SmbMapping 14 | test-connection DC01 15 | test-connection DC01 -count 999999999 16 | tnc DC01 -tr 17 | tnc DC01 -p 3389 18 | tnc DC01 -p 3389 -inf detailed 19 | resolve-dnsname DC01 20 | New-SmbMapping -LocalPath S: -RemotePath \\server\demo 21 | . 'C:\Program Files\Microsoft\Exchange Server\V14\bin\RemoteExchange.ps1' 22 | Get-Mailbox -ResultSize Unlimited | Get-ADPermission | Where-Object {($_.ExtendedRights -like "*send-as*") -and -not ($_.User -like "nt authority\self")} | Format-Table Identity, User -auto 23 | Get-Mailbox -ResultSize Unlimited | Get-MailboxPermission | Where-Object {($_.AccessRights -match "FullAccess") -and -not ($_.User -like "NT AUTHORITY\SELF")} | Format-Table Identity, User 24 | Get-Mailbox identity@dummy.com | Format-List *Quota 25 | Get-ActiveSyncDevice -filter {deviceaccessstate -eq 'quarantined'} | select identity, deviceid | fl 26 | Set-Mailbox identity@dummy.com -IssueWarningQuota 950MB -ProhibitSendQuota 970MB -ProhibitSendReceiveQuota 1GB -UseDatabaseQuotaDefaults $false 27 | Set-Mailbox -Identity "Full Name" -DeliverToMailboxAndForward $true -ForwardingAddress "Full Name" 28 | Set-Mailbox -Identity "Full Name" -ForwardingAddress $null -ForwardingSmtpAddress $null 29 | Get-ActiveSyncDevice -filter {deviceaccessstate -eq 'quarantined'} | select identity, deviceid | fl Set-CASMailbox –Identity identity@dummy.com –ActiveSyncAllowedDeviceIDs $DeviceId 30 | $env:computername 31 | [Management.ManagementDateTimeConverter]::ToDateTime((Get-WmiObject Win32_OperatingSystem -Property LastBootUpTime -ComputerName DC01).LastBootUpTime) 32 | Get-WmiObject win32_operatingsystem -ComputerName DC01 | select @{Name="Last Boot Time"; Expression={$_.ConvertToDateTime($_.LastBootUpTime)}}, PSComputerName 33 | rename-computer -name WS001 -newname WS-001 34 | restart-computer 35 | restart-computer -computername DC01 36 | stop-computer 37 | Start-Process -FilePath cmd.exe -Wait -WindowStyle Maximized 38 | disable-windowsoptionalfeature -FeatureName SMB1Protocol 39 | Get-ChildItem -Path hkcu:\ 40 | Set-Location hkcu: ; cd SOFTWARE; dir 41 | New-Item -Path hkcu:\new_key 42 | Remove-Item -Path hkcu:\new_key 43 | Set-ItemProperty -Path hkcu:\key -Name myProp -Value "testinggg" 44 | Get-Service s* | sort Status 45 | Get-Service someservice | where Status -eq running | sort DisplayName 46 | get-service -computername hyd-rdsh-10 | where DisplayName -match "myRegex_now_you_have_two_problems" 47 | start-service spooler 48 | stop-service fax 49 | restart-service spooler 50 | get-printer -computername hyd-rdsh-10 | where Type -eq Local | select ComputerName, Name, ShareName, Location, Comment | Out-GridView 51 | Get-Printer -ComputerName DC01 | where PrinterStatus -eq Error | fl Name,JobCount 52 | Get-PrintJob -ComputerName DC01 -PrinterName $myPrinterQueueName 53 | Get-PrintJob -ComputerName DC01 -PrinterName DC01 | where id -eq [job number] | fl JobStatus,UserName 54 | add-printer -connect \\computer\queue_name 55 | Remove-Printer -Name "[Printer Path and Name]" 56 | Get-ChildItem -Path c:\temp -Recurse -Include *.JPG 57 | Copy-Item -Path C:\Users\myUser\desktop\test.txt -Destination ~\Documents\ -Verbose 58 | Move-Item $myPath -Destination $myDestination -Verbose 59 | Rename-Item ~\Desktop\MyTestFile -NewName MyTestFile.ps1 60 | search-adaccount -accountinactive -datetime (Get-date).adddays(-30) -usersonly 61 | set-aduser $userName -title "Senior DevOps Engineer" 62 | disable-adaccount -identity $userName 63 | Get-WmiObject -Class ccm_application -Namespace root\ccm\clientsdk -ComputerName (get-content .\computers.txt) | Where-Object { ($_.InstallState -ne "Installed") -and ($_.ApplicabilityState -eq "Applicable") -and ($_.IsMachineTarget -eq $True) -and ($_.EvaluationState -ne 1)} | select FullName,__SERVER 64 | Get-ADDomainController –Filter * | Select Hostname, IsGlobalCatalog | Export-CSV C:\Temp\AllDomainControllerStatus.CSV -NoTypeInfo 65 | Backup-GPO –All –Path C:\Temp\AllGPO 66 | Get-HotFix –ID KB2877616 67 | Get-HotFix –ID KB2877616 –Computername WindowsServer1.TechGenix.com 68 | Get-EventLog –Log System –Newest 100 | Where-Object {$_.EventID –eq ‘1074’} | FT MachineName, UserName, TimeGenerated –AutoSize | Export-CSV C:\Temp\AllEvents.CSV -NoTypeInfo 69 | $cred = Get-Credential; $Session = New-PSSession -ConfigurationName Microsoft.Exchange -ConnectionUri https://ps.outlook.com/powershell -Credential $cred -Authentication Basic -AllowRedirection ; Import-PSSession $Session 70 | dir -r | % { $_.FullName.substring($pwd.Path.length+1) + $(if ($_.PsIsContainer) {'\'}) } 71 | dir | % { New-Object PSObject -Property @{ Name = $_.Name; Size = if($_.PSIsContainer) { (gci $_.FullName -Recurse | Measure Length -Sum).Sum } else {$_.Length}; Type = if($_.PSIsContainer) {'Directory'} else {'File'} } } 72 | Get-CimInstance -ClassName Win32_OperatingSystem | Select-Object -Property Build*,OSType,ServicePack* 73 | $env:path -split ';' | sort -Unique -------------------------------------------------------------------------------- /.github/workflows/deploy_app.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_dispatch: 3 | push: 4 | branches: 5 | - main 6 | name: Deploy app to Azure 7 | jobs: 8 | deployBackend: 9 | runs-on: windows-latest 10 | steps: 11 | - uses: actions/download-artifact@v4 12 | name: Download artifacts 13 | with: 14 | name: api 15 | path: backend_artifact 16 | - uses: azure/login@v2 17 | name: Authenticate with Azure 18 | with: 19 | creds: ${{ secrets.AZURE_SERVICE_PRINCIPAL }} 20 | - name: Deploy Function App package 21 | shell: pwsh 22 | run: | 23 | az functionapp deployment source config-zip ` 24 | --resource-group "${{ secrets.RESOURCE_GROUP_NAME }}".trim() ` 25 | --name "${{ secrets.FUNCTION_APP_NAME }}".trim() ` 26 | --src (Join-Path $PWD 'backend_artifact/functionapp.zip') 27 | - run: "az functionapp list |\n ConvertFrom-Json |\n where {$_.defaulthostname -match 'powershellexplainer' -and $_.state -eq 'running'} | \n ForEach-Object { Invoke-RestMethod -Uri \"https://$($_.DefaultHostName)/api/MetaData?refresh=true\" }\n" 28 | name: Calculate new HelpMetaData 29 | shell: pwsh 30 | needs: buildBackend 31 | name: Deploy Backend api 32 | buildBackend: 33 | runs-on: windows-latest 34 | steps: 35 | - uses: actions/setup-dotnet@v4 36 | with: 37 | dotnet-version: "10.0" 38 | - uses: actions/checkout@v4 39 | - run: Get-ChildItem -Path ./explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } 40 | name: Run code generators 41 | shell: pwsh 42 | - run: | 43 | Set-Location ./explainpowershell.analysisservice 44 | dotnet publish --configuration Release 45 | name: Publish with dotnet 46 | shell: pwsh 47 | - name: Zip publish output 48 | shell: pwsh 49 | run: | 50 | $publishDir = Join-Path $PWD 'explainpowershell.analysisservice/bin/Release/net10.0/publish' 51 | $zipPath = Join-Path $PWD 'explainpowershell.analysisservice/bin/Release/net10.0/functionapp.zip' 52 | if (Test-Path $zipPath) { 53 | Remove-Item $zipPath -Force 54 | } 55 | $entries = Get-ChildItem -LiteralPath $publishDir -Force 56 | if (-not $entries) { 57 | throw "No publish output found at $publishDir" 58 | } 59 | Compress-Archive -Path $entries.FullName -DestinationPath $zipPath -Force 60 | - uses: actions/upload-artifact@v4 61 | name: Publish artifacts 62 | with: 63 | name: api 64 | path: ./explainpowershell.analysisservice/bin/Release/net10.0/functionapp.zip 65 | name: Build Backend api 66 | buildFrontend: 67 | runs-on: windows-latest 68 | steps: 69 | - uses: actions/setup-dotnet@v4 70 | with: 71 | dotnet-version: "10.0" 72 | - uses: actions/checkout@v4 73 | - run: | 74 | $uri = "https://$("${{ secrets.FUNCTION_APP_NAME }}".trim()).azurewebsites.net/api/" 75 | Set-Content -Path ./explainpowershell.frontend/wwwroot/appsettings.json -Value (@{BaseAddress = $uri} | ConvertTo-Json) 76 | Get-Content -path ./explainpowershell.frontend/wwwroot/appsettings.json 77 | name: Set api url to correct url manually 78 | shell: pwsh 79 | - run: Get-ChildItem -Path ./explainpowershell.frontend/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } 80 | name: Run code generators 81 | shell: pwsh 82 | - run: dotnet build --configuration Release explainpowershell.frontend 83 | name: Build with dotnet 84 | - run: dotnet publish --configuration Release explainpowershell.frontend 85 | name: Publish with dotnet 86 | - uses: actions/upload-artifact@v4 87 | name: Publish artifacts 88 | with: 89 | name: webapp 90 | path: ./explainpowershell.frontend/bin/Release/net10.0/publish/wwwroot 91 | name: Build Frontend 92 | deployFrontend: 93 | runs-on: windows-latest 94 | steps: 95 | - uses: actions/download-artifact@v4 96 | name: Download artifacts 97 | with: 98 | name: webapp 99 | - uses: azure/login@v2 100 | name: Authenticate with Azure 101 | with: 102 | creds: ${{ secrets.AZURE_SERVICE_PRINCIPAL }} 103 | - run: "az storage blob delete-batch --account-name \"${{ secrets.STORAGE_ACCOUNT_NAME }}\".trim() --source `$web \n" 104 | name: Clean up previous deployment 105 | shell: pwsh 106 | - run: | 107 | az storage blob upload-batch --account-name "${{ secrets.STORAGE_ACCOUNT_NAME }}".trim() --source ./ --destination `$web 108 | name: Deploy to storage account 109 | shell: pwsh 110 | - run: | 111 | if ( -not [String]::IsNullOrEmpty("${{ secrets.AZURE_CDN_ENDPOINT }}".trim()) ) { 112 | az cdn endpoint purge -n "${{ secrets.AZURE_CDN_ENDPOINT }}".trim() --profile-name "${{ secrets.AZURE_CDN_ENDPOINT }}".trim() --content-paths "/*" --resource-group "${{ secrets.RESOURCE_GROUP_NAME }}".trim() 113 | "Az CDN endpoint purged" 114 | } 115 | name: Purge Azure CDN 116 | shell: pwsh 117 | needs: buildFrontend 118 | name: Deploy Frontend 119 | 120 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using explainpowershell.models; 3 | using NUnit.Framework; 4 | 5 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 6 | { 7 | [TestFixture] 8 | public class InMemoryHelpRepositoryTests 9 | { 10 | private InMemoryHelpRepository repository; 11 | 12 | [SetUp] 13 | public void Setup() 14 | { 15 | repository = new InMemoryHelpRepository(); 16 | } 17 | 18 | [Test] 19 | public void GetHelpForCommand_WithValidCommand_ReturnsEntity() 20 | { 21 | // Arrange 22 | var entity = new HelpEntity 23 | { 24 | CommandName = "Get-Process", 25 | Synopsis = "Gets the processes running on the local computer.", 26 | ModuleName = "Microsoft.PowerShell.Management" 27 | }; 28 | repository.AddHelpEntity(entity); 29 | 30 | // Act 31 | var result = repository.GetHelpForCommand("get-process"); 32 | 33 | // Assert 34 | Assert.IsNotNull(result); 35 | Assert.AreEqual("Get-Process", result.CommandName); 36 | } 37 | 38 | [Test] 39 | public void GetHelpForCommand_WithInvalidCommand_ReturnsNull() 40 | { 41 | // Act 42 | var result = repository.GetHelpForCommand("NonExistentCommand"); 43 | 44 | // Assert 45 | Assert.IsNull(result); 46 | } 47 | 48 | [Test] 49 | public void GetHelpForCommand_WithModule_ReturnsCorrectEntity() 50 | { 51 | // Arrange 52 | var entity1 = new HelpEntity 53 | { 54 | CommandName = "Test-Command", 55 | ModuleName = "Module1", 56 | Synopsis = "Test command from Module1" 57 | }; 58 | var entity2 = new HelpEntity 59 | { 60 | CommandName = "Test-Command", 61 | ModuleName = "Module2", 62 | Synopsis = "Test command from Module2" 63 | }; 64 | repository.AddHelpEntity(entity1); 65 | repository.AddHelpEntity(entity2); 66 | 67 | // Act 68 | var result1 = repository.GetHelpForCommand("Test-Command", "Module1"); 69 | var result2 = repository.GetHelpForCommand("Test-Command", "Module2"); 70 | 71 | // Assert 72 | Assert.IsNotNull(result1); 73 | Assert.AreEqual("Module1", result1.ModuleName); 74 | Assert.IsNotNull(result2); 75 | Assert.AreEqual("Module2", result2.ModuleName); 76 | } 77 | 78 | [Test] 79 | public void GetHelpForCommandRange_WithMatchingPrefix_ReturnsMultipleEntities() 80 | { 81 | // Arrange 82 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); 83 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" }); 84 | repository.AddHelpEntity(new HelpEntity { CommandName = "Set-Service" }); 85 | 86 | // Act 87 | var result = repository.GetHelpForCommandRange("Get-"); 88 | 89 | // Assert 90 | Assert.AreEqual(2, result.Count); 91 | Assert.IsTrue(result.Any(e => e.CommandName == "Get-Process")); 92 | Assert.IsTrue(result.Any(e => e.CommandName == "Get-Service")); 93 | } 94 | 95 | [Test] 96 | public void GetHelpForCommandRange_WithNoMatches_ReturnsEmptyList() 97 | { 98 | // Arrange 99 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); 100 | 101 | // Act 102 | var result = repository.GetHelpForCommandRange("Set-"); 103 | 104 | // Assert 105 | Assert.AreEqual(0, result.Count); 106 | } 107 | 108 | [Test] 109 | public void Clear_RemovesAllEntities() 110 | { 111 | // Arrange 112 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); 113 | repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" }); 114 | 115 | // Act 116 | repository.Clear(); 117 | var result = repository.GetHelpForCommand("Get-Process"); 118 | 119 | // Assert 120 | Assert.IsNull(result); 121 | } 122 | 123 | [Test] 124 | public void GetHelpForCommand_IsCaseInsensitive() 125 | { 126 | // Arrange 127 | var entity = new HelpEntity { CommandName = "Get-Process" }; 128 | repository.AddHelpEntity(entity); 129 | 130 | // Act 131 | var result1 = repository.GetHelpForCommand("GET-PROCESS"); 132 | var result2 = repository.GetHelpForCommand("get-process"); 133 | var result3 = repository.GetHelpForCommand("Get-Process"); 134 | 135 | // Assert 136 | Assert.IsNotNull(result1); 137 | Assert.IsNotNull(result2); 138 | Assert.IsNotNull(result3); 139 | Assert.AreEqual(result1.CommandName, result2.CommandName); 140 | Assert.AreEqual(result2.CommandName, result3.CommandName); 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /explainpowershell.frontend/Tree.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using MudBlazor; 5 | 6 | namespace explainpowershell.frontend 7 | { 8 | internal static class GenericTree 9 | { 10 | private const int MaxDepth = 256; 11 | 12 | /// 13 | /// Generates a MudBlazor-compatible tree from a flat collection. 14 | /// 15 | public static List> GenerateTree( 16 | this IEnumerable collection, 17 | Func idSelector, 18 | Func parentIdSelector, 19 | K rootId = default) 20 | { 21 | static K CanonicalizeKey(K key) 22 | { 23 | if (typeof(K) == typeof(string)) 24 | { 25 | var s = key as string; 26 | return (K)(object)(s ?? string.Empty); 27 | } 28 | 29 | return key; 30 | } 31 | 32 | var comparer = EqualityComparer.Default; 33 | var items = collection as IList ?? collection.ToList(); 34 | 35 | // Group children by parent id in one pass (O(n)). 36 | var childrenByParent = new Dictionary>(comparer); 37 | foreach (var item in items) 38 | { 39 | var parentKey = CanonicalizeKey(parentIdSelector(item)); 40 | if (!childrenByParent.TryGetValue(parentKey, out var list)) 41 | { 42 | list = new List(); 43 | childrenByParent[parentKey] = list; 44 | } 45 | 46 | list.Add(item); 47 | } 48 | 49 | var rootKey = CanonicalizeKey(rootId); 50 | if (!childrenByParent.TryGetValue(rootKey, out var rootItems) || rootItems.Count == 0) 51 | { 52 | // Special-case: when rootId is null/empty string, treat both null and empty as root. 53 | if (typeof(K) == typeof(string) && string.IsNullOrEmpty(rootKey as string)) 54 | { 55 | if (childrenByParent.TryGetValue((K)(object)string.Empty, out var emptyRootItems)) 56 | { 57 | rootItems = emptyRootItems; 58 | } 59 | } 60 | 61 | if (rootItems is null || rootItems.Count == 0) 62 | { 63 | return new List>(); 64 | } 65 | } 66 | 67 | var nodes = new List>(rootItems.Count); 68 | 69 | var stack = new Stack<(TreeItemData Node, K Id, int Depth, HashSet Path)>(); 70 | 71 | foreach (var item in rootItems) 72 | { 73 | var nodeId = CanonicalizeKey(idSelector(item)); 74 | var node = new TreeItemData 75 | { 76 | Value = item, 77 | Expanded = true 78 | }; 79 | 80 | nodes.Add(node); 81 | stack.Push((node, nodeId, 1, new HashSet(comparer) { nodeId })); 82 | } 83 | 84 | while (stack.Count > 0) 85 | { 86 | var (node, id, depth, path) = stack.Pop(); 87 | 88 | if (depth >= MaxDepth) 89 | { 90 | node.Children = null; 91 | node.Expandable = false; 92 | continue; 93 | } 94 | 95 | if (!childrenByParent.TryGetValue(id, out var children) || children.Count == 0) 96 | { 97 | node.Children = null; 98 | node.Expandable = false; 99 | continue; 100 | } 101 | 102 | var childNodes = new List>(children.Count); 103 | 104 | foreach (var child in children) 105 | { 106 | var childId = CanonicalizeKey(idSelector(child)); 107 | if (path.Contains(childId)) 108 | { 109 | continue; 110 | } 111 | 112 | childNodes.Add(new TreeItemData 113 | { 114 | Value = child, 115 | Expanded = true 116 | }); 117 | } 118 | 119 | if (childNodes.Count == 0) 120 | { 121 | node.Children = null; 122 | node.Expandable = false; 123 | continue; 124 | } 125 | 126 | node.Children = childNodes; 127 | node.Expandable = true; 128 | 129 | // Push in reverse so the first child is processed first. 130 | for (var i = childNodes.Count - 1; i >= 0; i--) 131 | { 132 | var childNode = childNodes[i]; 133 | var childId = CanonicalizeKey(idSelector(childNode.Value)); 134 | var childPath = new HashSet(path, comparer) { childId }; 135 | stack.Push((childNode, childId, depth + 1, childPath)); 136 | } 137 | } 138 | 139 | return nodes; 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # Azure Functions localsettings file 5 | **/local.settings.json 6 | 7 | # Test artifacts 8 | testResults.xml 9 | coverage.xml 10 | 11 | # User-specific files 12 | *.suo 13 | *.user 14 | *.userosscache 15 | *.sln.docstates 16 | 17 | # User-specific files (MonoDevelop/Xamarin Studio) 18 | *.userprefs 19 | 20 | # Build results 21 | [Dd]ebug/ 22 | [Dd]ebugPublic/ 23 | [Rr]elease/ 24 | [Rr]eleases/ 25 | x64/ 26 | x86/ 27 | bld/ 28 | [Bb]in/ 29 | [Oo]bj/ 30 | [Ll]og/ 31 | 32 | # Visual Studio 2015 cache/options directory 33 | .vs/ 34 | # Uncomment if you have tasks that create the project's static files in wwwroot 35 | #wwwroot/ 36 | 37 | # MSTest test Results 38 | [Tt]est[Rr]esult*/ 39 | [Bb]uild[Ll]og.* 40 | 41 | # NUNIT 42 | *.VisualState.xml 43 | TestResult.xml 44 | 45 | # Build Results of an ATL Project 46 | [Dd]ebugPS/ 47 | [Rr]eleasePS/ 48 | dlldata.c 49 | 50 | # DNX 51 | project.lock.json 52 | project.fragment.lock.json 53 | artifacts/ 54 | 55 | *_i.c 56 | *_p.c 57 | *_i.h 58 | *.ilk 59 | *.meta 60 | *.obj 61 | *.pch 62 | *.pdb 63 | *.pgc 64 | *.pgd 65 | *.rsp 66 | *.sbr 67 | *.tlb 68 | *.tli 69 | *.tlh 70 | *.tmp 71 | *.tmp_proj 72 | *.log 73 | *.vspscc 74 | *.vssscc 75 | .builds 76 | *.pidb 77 | *.svclog 78 | *.scc 79 | 80 | # Chutzpah Test files 81 | _Chutzpah* 82 | 83 | # Visual C++ cache files 84 | ipch/ 85 | *.aps 86 | *.ncb 87 | *.opendb 88 | *.opensdf 89 | *.sdf 90 | *.cachefile 91 | *.VC.db 92 | *.VC.VC.opendb 93 | 94 | # Visual Studio profiler 95 | *.psess 96 | *.vsp 97 | *.vspx 98 | *.sap 99 | 100 | # TFS 2012 Local Workspace 101 | $tf/ 102 | 103 | # Guidance Automation Toolkit 104 | *.gpState 105 | 106 | # ReSharper is a .NET coding add-in 107 | _ReSharper*/ 108 | *.[Rr]e[Ss]harper 109 | *.DotSettings.user 110 | 111 | # JustCode is a .NET coding add-in 112 | .JustCode 113 | 114 | # TeamCity is a build add-in 115 | _TeamCity* 116 | 117 | # DotCover is a Code Coverage Tool 118 | *.dotCover 119 | 120 | # NCrunch 121 | _NCrunch_* 122 | .*crunch*.local.xml 123 | nCrunchTemp_* 124 | 125 | # MightyMoose 126 | *.mm.* 127 | AutoTest.Net/ 128 | 129 | # Web workbench (sass) 130 | .sass-cache/ 131 | 132 | # Installshield output folder 133 | [Ee]xpress/ 134 | 135 | # DocProject is a documentation generator add-in 136 | DocProject/buildhelp/ 137 | DocProject/Help/*.HxT 138 | DocProject/Help/*.HxC 139 | DocProject/Help/*.hhc 140 | DocProject/Help/*.hhk 141 | DocProject/Help/*.hhp 142 | DocProject/Help/Html2 143 | DocProject/Help/html 144 | 145 | # Click-Once directory 146 | publish/ 147 | 148 | # Publish Web Output 149 | *.[Pp]ublish.xml 150 | *.azurePubxml 151 | # TODO: Comment the next line if you want to checkin your web deploy settings 152 | # but database connection strings (with potential passwords) will be unencrypted 153 | #*.pubxml 154 | *.publishproj 155 | 156 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 157 | # checkin your Azure Web App publish settings, but sensitive information contained 158 | # in these scripts will be unencrypted 159 | PublishScripts/ 160 | 161 | # NuGet Packages 162 | *.nupkg 163 | # The packages folder can be ignored because of Package Restore 164 | **/packages/* 165 | # except build/, which is used as an MSBuild target. 166 | !**/packages/build/ 167 | # Uncomment if necessary however generally it will be regenerated when needed 168 | #!**/packages/repositories.config 169 | # NuGet v3's project.json files produces more ignoreable files 170 | *.nuget.props 171 | *.nuget.targets 172 | 173 | # Microsoft Azure Build Output 174 | csx/ 175 | *.build.csdef 176 | 177 | # Microsoft Azure Emulator 178 | ecf/ 179 | rcf/ 180 | 181 | # Windows Store app package directories and files 182 | AppPackages/ 183 | BundleArtifacts/ 184 | Package.StoreAssociation.xml 185 | _pkginfo.txt 186 | 187 | # Visual Studio cache files 188 | # files ending in .cache can be ignored 189 | *.[Cc]ache 190 | # but keep track of directories ending in .cache 191 | !*.[Cc]ache/ 192 | 193 | # Others 194 | ClientBin/ 195 | ~$* 196 | *~ 197 | *.dbmdl 198 | *.dbproj.schemaview 199 | *.jfm 200 | *.pfx 201 | *.publishsettings 202 | node_modules/ 203 | orleans.codegen.cs 204 | 205 | # Since there are multiple workflows, uncomment next line to ignore bower_components 206 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 207 | #bower_components/ 208 | 209 | # RIA/Silverlight projects 210 | Generated_Code/ 211 | 212 | # Backup & report files from converting an old project file 213 | # to a newer Visual Studio version. Backup files are not needed, 214 | # because we have git ;-) 215 | _UpgradeReport_Files/ 216 | Backup*/ 217 | UpgradeLog*.XML 218 | UpgradeLog*.htm 219 | 220 | # SQL Server files 221 | *.mdf 222 | *.ldf 223 | 224 | # Business Intelligence projects 225 | *.rdl.data 226 | *.bim.layout 227 | *.bim_*.settings 228 | 229 | # Microsoft Fakes 230 | FakesAssemblies/ 231 | 232 | # GhostDoc plugin setting file 233 | *.GhostDoc.xml 234 | 235 | # Node.js Tools for Visual Studio 236 | .ntvs_analysis.dat 237 | 238 | # Visual Studio 6 build log 239 | *.plg 240 | 241 | # Visual Studio 6 workspace options file 242 | *.opt 243 | 244 | # Visual Studio LightSwitch build output 245 | **/*.HTMLClient/GeneratedArtifacts 246 | **/*.DesktopClient/GeneratedArtifacts 247 | **/*.DesktopClient/ModelManifest.xml 248 | **/*.Server/GeneratedArtifacts 249 | **/*.Server/ModelManifest.xml 250 | _Pvt_Extensions 251 | 252 | # Paket dependency manager 253 | .paket/paket.exe 254 | paket-files/ 255 | 256 | # FAKE - F# Make 257 | .fake/ 258 | 259 | # JetBrains Rider 260 | .idea/ 261 | *.sln.iml 262 | 263 | # CodeRush 264 | .cr/ 265 | 266 | # Python Tools for Visual Studio (PTVS) 267 | __pycache__/ 268 | *.pyc 269 | 270 | # Ignore azureite local db 271 | .azurite/ 272 | 273 | # Ingore generated code files 274 | **/*.generated.cs -------------------------------------------------------------------------------- /explainpowershell.helpcollector/HelpCollector.Functions.ps1: -------------------------------------------------------------------------------- 1 | function ConvertTo-LearnDocumentationUri { 2 | [CmdletBinding()] 3 | param( 4 | [Parameter(Mandatory)] 5 | [string]$Uri 6 | ) 7 | 8 | $normalized = $Uri.Trim() 9 | if ([string]::IsNullOrWhiteSpace($normalized)) { 10 | return $Uri 11 | } 12 | 13 | # Prefer HTTPS 14 | $normalized = $normalized -replace '^http://', 'https://' 15 | 16 | # docs.microsoft.com redirects to learn.microsoft.com; normalize for consistency. 17 | $normalized = $normalized -replace '^https://docs\.microsoft\.com', 'https://learn.microsoft.com' 18 | 19 | return $normalized 20 | } 21 | 22 | function Get-TitleFromHtml { 23 | [CmdletBinding()] 24 | param( 25 | [Parameter(Mandatory)] 26 | [string]$Html 27 | ) 28 | 29 | $m = [regex]::Match($Html, ']*>(.*?)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline) 30 | if ($m.Success) { 31 | return ([System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) -replace '<[^>]+>', '').Trim() 32 | } 33 | 34 | return $null 35 | } 36 | 37 | function Get-SynopsisFromHtml { 38 | [CmdletBinding()] 39 | param( 40 | [Parameter(Mandatory)] 41 | [string]$Html, 42 | 43 | [Parameter()] 44 | [string]$Cmd 45 | ) 46 | 47 | if ([string]::IsNullOrWhiteSpace($Html)) { 48 | return $null 49 | } 50 | 51 | $regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline 52 | 53 | # 1) Learn pages usually expose a meta description. 54 | $meta = [regex]::Match($Html, '', $regexOptions) 55 | if ($meta.Success) { 56 | return ([System.Net.WebUtility]::HtmlDecode($meta.Groups[1].Value)).Trim() 57 | } 58 | 59 | # 2) Learn pages often have a summary paragraph. 60 | $summary = [regex]::Match($Html, ']*class=["\'']summary["\''][^>]*>(.*?)

', $regexOptions) 61 | if ($summary.Success) { 62 | $text = $summary.Groups[1].Value 63 | $text = [System.Net.WebUtility]::HtmlDecode($text) 64 | $text = ($text -replace '<[^>]+>', '').Trim() 65 | if (-not [string]::IsNullOrWhiteSpace($text)) { 66 | return $text 67 | } 68 | } 69 | 70 | # 3) About_language_keywords sometimes has a per-keyword section. 71 | if (-not [string]::IsNullOrWhiteSpace($Cmd)) { 72 | $escapedCmd = [regex]::Escape($Cmd) 73 | $pattern = ']*id=[''"]' + $escapedCmd + '[''"][^>]*>.*?\s*]*>(.*?)

' 74 | $section = [regex]::Match($Html, $pattern, $regexOptions) 75 | if ($section.Success) { 76 | $text = $section.Groups[1].Value 77 | $text = [System.Net.WebUtility]::HtmlDecode($text) 78 | $text = ($text -replace '<[^>]+>', '').Trim() 79 | if (-not [string]::IsNullOrWhiteSpace($text)) { 80 | return $text 81 | } 82 | } 83 | } 84 | 85 | return $null 86 | } 87 | 88 | function Get-SynopsisFromUri { 89 | [CmdletBinding()] 90 | param( 91 | [Parameter(Mandatory)] 92 | [string]$Uri, 93 | 94 | [Parameter()] 95 | [string]$Cmd 96 | ) 97 | 98 | $normalizedUri = ConvertTo-LearnDocumentationUri -Uri $Uri 99 | 100 | try { 101 | # Use Invoke-WebRequest to reliably get HTML content; IRM sometimes tries to parse. 102 | $response = Invoke-WebRequest -Uri $normalizedUri -ErrorAction Stop 103 | $html = $response.Content 104 | 105 | $synopsis = Get-SynopsisFromHtml -Html $html -Cmd $Cmd 106 | if (-not [string]::IsNullOrWhiteSpace($synopsis)) { 107 | return @($true, $synopsis) 108 | } 109 | 110 | $title = Get-TitleFromHtml -Html $html 111 | return @($false, $title) 112 | } 113 | catch { 114 | return @($false, $null) 115 | } 116 | } 117 | 118 | function Get-Synopsis { 119 | param( 120 | $Help, 121 | $Cmd, 122 | $DocumentationLink, 123 | $description 124 | ) 125 | 126 | if ($null -eq $help) { 127 | return @($null, $null) 128 | } 129 | 130 | $synopsis = $help.Synopsis.Trim() 131 | 132 | if ($synopsis -like '') { 133 | Write-Verbose "$($cmd.name) - Empty synopsis, trying to get synopsis from description." 134 | $description = $help.description.Text 135 | if ([string]::IsNullOrEmpty($description)) { 136 | Write-Verbose "$($cmd.name) - Empty description." 137 | } 138 | else { 139 | $synopsis = $description.Trim().Split('.')[0].Trim() 140 | } 141 | } 142 | 143 | if ($synopsis -match "^$($cmd.Name) .*[-\[\]<>]" -or $synopsis -like '') { 144 | # If synopsis starts with the name of the verb, it's not a synopsis. 145 | $synopsis = $null 146 | 147 | if ([string]::IsNullOrEmpty($DocumentationLink) -or $DocumentationLink -in $script:badUrls) { 148 | } 149 | else { 150 | Write-Verbose "$($cmd.name) - Trying to get missing synopsis from Uri" 151 | $success, $synopsis = Get-SynopsisFromUri -Uri $DocumentationLink -Cmd $cmd.Name -verbose:$false 152 | 153 | if ($null -eq $synopsis -or -not $success) { 154 | if ($synopsis -notmatch "^$($cmd.Name) .*[-\[\]<>]") { 155 | Write-Warning "!!$($cmd.name) - Bad online help uri, '$DocumentationLink' is about '$synopsis'" 156 | $script:badUrls += $DocumentationLink 157 | $DocumentationLink = $null 158 | $synopsis = $null 159 | } 160 | } 161 | } 162 | } 163 | 164 | if ($null -ne $synopsis -and $synopsis -match "^$($cmd.Name) .*[-\[\]<>]") { 165 | $synopsis = $null 166 | } 167 | 168 | return @($synopsis, $DocumentationLink) 169 | } 170 | -------------------------------------------------------------------------------- /.tours/tour-of-the-ai-explanation-feature.tour: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://aka.ms/codetour-schema", 3 | "title": "Tour of the AI explanation feature", 4 | "steps": [ 5 | { 6 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", 7 | "description": "The AI explanation is intentionally *not* generated during the main AST analysis call.\n\nThis endpoint parses the PowerShell input into an AST and walks it with `AstVisitorExplainer` to produce the structured explanation nodes (the data that becomes the tree you see in the UI).\n\nThat AST-based result is returned quickly so the UI can render immediately.", 8 | "line": 25 9 | }, 10 | { 11 | "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", 12 | "description": "Key design choice: the AST endpoint always sets `AiExplanation` to an empty string.\n\nThe AI summary is fetched via a separate endpoint *after* the AST explanation tree is available. This keeps the main analysis deterministic and avoids coupling UI responsiveness to an external AI call.", 13 | "line": 82 14 | }, 15 | { 16 | "file": "explainpowershell.frontend/Pages/Index.razor.cs", 17 | "description": "Frontend starts by calling the AST analysis endpoint (`SyntaxAnalyzer`).\n\nThis request returns the expanded code plus a flat list of explanation nodes (each has an `Id` and optional `ParentId`).", 18 | "line": 120 19 | }, 20 | { 21 | "file": "explainpowershell.frontend/Pages/Index.razor.cs", 22 | "description": "Once the AST analysis result comes back, the frontend builds the explanation tree (based on `Id` / `ParentId`) for display in the `MudTreeView`.\n\nAt this point, the user already has a useful explanation from the AST visitor.", 23 | "line": 152 24 | }, 25 | { 26 | "file": "explainpowershell.frontend/Pages/Index.razor.cs", 27 | "description": "Immediately after the tree is available, the AI request is kicked off *in the background*.\n\nNotice the fire-and-forget pattern (`_ = ...`) so the AST UI is not blocked while the AI endpoint runs.", 28 | "line": 157 29 | }, 30 | { 31 | "file": "explainpowershell.frontend/Pages/Index.razor.cs", 32 | "description": "`LoadAiExplanationAsync` constructs a payload containing:\n- the original PowerShell code\n- the full AST analysis result\n\nThen it POSTs to the backend `AiExplanation` endpoint.\n\nIf the call fails, the UI silently continues without the AI summary (AI is treated as optional).", 33 | "line": 160 34 | }, 35 | { 36 | "file": "explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs", 37 | "description": "This is the actual HTTP call that requests the AI explanation.\n\nIt sends both the code and the AST result so the backend can build a prompt that is grounded in the already-produced explanation nodes and help metadata.", 38 | "line": 69 39 | }, 40 | { 41 | "file": "explainpowershell.frontend/Pages/Index.razor", 42 | "description": "The UI has a dedicated 'AI explanation' card.\n\nIt's only shown when either:\n- `AiExplanationLoading` is true (spinner), or\n- a non-empty `AiExplanation` has arrived.\n\nThis makes the feature feel additive: the AST explanation tree appears first, then the AI summary appears when ready.", 43 | "line": 34 44 | }, 45 | { 46 | "file": "explainpowershell.analysisservice/AiExplanationFunction.cs", 47 | "description": "Backend entrypoint for the AI feature: an Azure Function named `AiExplanation`.\n\nIt accepts an `AiExplanationRequest` that includes both the PowerShell code and the `AnalysisResult` produced earlier by the AST endpoint.", 48 | "line": 23 49 | }, 50 | { 51 | "file": "explainpowershell.analysisservice/AiExplanationFunction.cs", 52 | "description": "After validating/deserializing the request, this function delegates the real work to `IAiExplanationService.GenerateAsync(...)`.\n\nThis separation keeps the HTTP handler thin and makes the behavior easier to test.", 53 | "line": 63 54 | }, 55 | { 56 | "file": "explainpowershell.analysisservice/Program.cs", 57 | "description": "The AI feature is wired up through DI.\n\nA `ChatClient` is registered only when configuration is present (`Endpoint`, `ApiKey`, `DeploymentName`) and `Enabled` is true. If not configured, the factory returns `null` and AI remains disabled.", 58 | "line": 36 59 | }, 60 | { 61 | "file": "explainpowershell.analysisservice/Services/AiExplanationOptions.cs", 62 | "description": "AI behavior is controlled via `AiExplanationOptions`:\n- the system prompt (how the AI should behave)\n- an example prompt/response (few-shot guidance)\n- payload size guardrails (`MaxPayloadCharacters`)\n- request timeout (`RequestTimeoutSeconds`)\n\nThese settings are loaded from the `AiExplanation` config section.", 63 | "line": 6 64 | }, 65 | { 66 | "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", 67 | "description": "First guardrail: if DI did not create a `ChatClient`, the service returns `(null, null)` and logs that AI is unavailable.\n\nThis is what makes the feature optional without breaking the main AST explanation flow.", 68 | "line": 30 69 | }, 70 | { 71 | "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", 72 | "description": "Prompt grounding & size safety: the service builds a ‘slim’ version of the analysis result and serializes it to JSON for the prompt.\n\nIf the JSON is too large, it progressively reduces details (e.g., trims help fields, limits explanation count) so the request stays under `MaxPayloadCharacters`.", 73 | "line": 57 74 | }, 75 | { 76 | "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", 77 | "description": "Finally, the service builds a chat message list and calls `CompleteChatAsync`.\n\nThe response is reduced to a best-effort single sentence (per the prompt), and the model name is returned too so the UI can display what model produced the summary.", 78 | "line": 92 79 | }, 80 | { 81 | "file": "explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1", 82 | "description": "The AI endpoint has Pester integration tests.\n\nNotably, there’s coverage for:\n- accepting a valid `AnalysisResult` payload\n- handling very large payloads (50KB+) gracefully (exercise the payload reduction path)\n- an end-to-end workflow: call SyntaxAnalyzer first, then call AiExplanation with that output.", 83 | "line": 3 84 | } 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs: -------------------------------------------------------------------------------- 1 | #nullable enable 2 | 3 | using System; 4 | using System.Collections.Generic; 5 | using System.IO; 6 | using System.Net; 7 | using System.Security.Claims; 8 | using System.Text; 9 | using System.Text.Json; 10 | using System.Threading.Tasks; 11 | using explainpowershell.models; 12 | using ExplainPowershell.SyntaxAnalyzer; 13 | using Microsoft.Azure.Functions.Worker; 14 | using Microsoft.Azure.Functions.Worker.Http; 15 | using Microsoft.Extensions.Logging; 16 | using NUnit.Framework; 17 | 18 | namespace ExplainPowershell.SyntaxAnalyzer.Tests 19 | { 20 | [TestFixture] 21 | public class SyntaxAnalyzerFunctionTests 22 | { 23 | [Test] 24 | public async Task Run_DoesNotRequireAzureWebJobsStorage_WhenHelpRepositoryInjected() 25 | { 26 | Environment.SetEnvironmentVariable("AzureWebJobsStorage", null); 27 | 28 | var loggerFactory = LoggerFactory.Create(_ => { }); 29 | var logger = loggerFactory.CreateLogger(); 30 | 31 | var helpRepository = new InMemoryHelpRepository(); 32 | var function = new SyntaxAnalyzerFunction(logger, helpRepository); 33 | 34 | var request = new Code { PowershellCode = "Get-Process" }; 35 | var bodyJson = JsonSerializer.Serialize(request); 36 | 37 | var req = new TestHttpRequestData(bodyJson); 38 | var resp = await function.Run(req); 39 | 40 | Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); 41 | 42 | resp.Body.Position = 0; 43 | using var reader = new StreamReader(resp.Body, Encoding.UTF8); 44 | var responseJson = await reader.ReadToEndAsync(); 45 | 46 | var analysisResult = JsonSerializer.Deserialize(responseJson, new JsonSerializerOptions 47 | { 48 | PropertyNameCaseInsensitive = true 49 | }); 50 | 51 | Assert.That(analysisResult, Is.Not.Null); 52 | Assert.That(analysisResult!.ExpandedCode, Is.Not.Empty); 53 | } 54 | 55 | private sealed class TestHttpRequestData : HttpRequestData 56 | { 57 | private readonly MemoryStream body; 58 | private readonly HttpHeadersCollection headers = new(); 59 | private readonly Uri url; 60 | 61 | public TestHttpRequestData(string bodyJson) 62 | : base(new TestFunctionContext()) 63 | { 64 | body = new MemoryStream(Encoding.UTF8.GetBytes(bodyJson)); 65 | url = new Uri("http://127.0.0.1/api/SyntaxAnalyzer"); 66 | } 67 | 68 | public override Stream Body => body; 69 | 70 | public override HttpHeadersCollection Headers => headers; 71 | 72 | public override IReadOnlyCollection Cookies => Array.Empty(); 73 | 74 | public override Uri Url => url; 75 | 76 | public override IEnumerable Identities => Array.Empty(); 77 | 78 | public override string Method => "POST"; 79 | 80 | public override HttpResponseData CreateResponse() 81 | { 82 | return new TestHttpResponseData(FunctionContext); 83 | } 84 | } 85 | 86 | private sealed class TestHttpResponseData : HttpResponseData 87 | { 88 | public TestHttpResponseData(FunctionContext functionContext) 89 | : base(functionContext) 90 | { 91 | Headers = new HttpHeadersCollection(); 92 | Body = new MemoryStream(); 93 | Cookies = new TestHttpCookies(); 94 | } 95 | 96 | public override HttpStatusCode StatusCode { get; set; } 97 | 98 | public override HttpHeadersCollection Headers { get; set; } 99 | 100 | public override Stream Body { get; set; } 101 | 102 | public override HttpCookies Cookies { get; } 103 | } 104 | 105 | private sealed class TestHttpCookies : HttpCookies 106 | { 107 | private readonly Dictionary cookies = new(StringComparer.OrdinalIgnoreCase); 108 | 109 | public override void Append(IHttpCookie cookie) 110 | { 111 | cookies[cookie.Name] = cookie; 112 | } 113 | 114 | public override void Append(string name, string value) 115 | { 116 | cookies[name] = new TestHttpCookie { Name = name, Value = value }; 117 | } 118 | 119 | public override IHttpCookie CreateNew() 120 | { 121 | return new TestHttpCookie(); 122 | } 123 | 124 | private sealed class TestHttpCookie : IHttpCookie 125 | { 126 | public string Name { get; set; } = string.Empty; 127 | public string Value { get; set; } = string.Empty; 128 | public string? Domain { get; set; } 129 | public string? Path { get; set; } 130 | public DateTimeOffset? Expires { get; set; } 131 | public bool? Secure { get; set; } 132 | public bool? HttpOnly { get; set; } 133 | public SameSite SameSite { get; set; } 134 | public double? MaxAge { get; set; } 135 | } 136 | } 137 | 138 | private sealed class TestFunctionContext : FunctionContext 139 | { 140 | private IDictionary items = new Dictionary(); 141 | 142 | public override string InvocationId { get; } = Guid.NewGuid().ToString("n"); 143 | 144 | public override string FunctionId { get; } = "TestFunction"; 145 | 146 | public override TraceContext TraceContext => throw new NotImplementedException(); 147 | 148 | public override BindingContext BindingContext => throw new NotImplementedException(); 149 | 150 | public override RetryContext RetryContext => null!; 151 | 152 | public override IServiceProvider InstanceServices 153 | { 154 | get => throw new NotImplementedException(); 155 | set => throw new NotImplementedException(); 156 | } 157 | 158 | public override FunctionDefinition FunctionDefinition => throw new NotImplementedException(); 159 | 160 | public override IDictionary Items 161 | { 162 | get => items; 163 | set => items = value; 164 | } 165 | 166 | public override IInvocationFeatures Features => throw new NotImplementedException(); 167 | } 168 | } 169 | } 170 | --------------------------------------------------------------------------------