diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..739bb9c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,65 @@ +# Copilot instructions for Explain PowerShell + +These instructions are for GitHub Copilot Chat/Edits when working in this repository. + +## Repo overview (what this project is) +- A PowerShell oneliner explainer. +- Backend: Azure Functions (.NET) in `explainpowershell.analysisservice/`. +- Frontend: Blazor in `explainpowershell.frontend/`. +- Shared models: `explainpowershell.models/`. +- Tests: Pester tests in `explainpowershell.analysisservice.tests/`. +- Infra: Bicep in `explainpowershell.azureinfra/`. + +## Architecture & flow +- The primary explanation is AST-based: + - `SyntaxAnalyzer` parses PowerShell into an AST and produces a list of `Explanation` nodes. +- The AI feature is additive and optional: + - The AST analysis returns immediately; the AI call is a separate endpoint invoked after the tree is available. + - Frontend triggers AI in the background so the UI remains responsive. + +## Editing guidelines (preferred behavior) +- Keep in mind this project is open source and intended to be cross platform. +- Follow existing code style and patterns. +- Favor readability and maintainability. +- Prefer small, surgical changes; avoid unrelated refactors. +- Preserve existing public APIs and JSON shapes unless explicitly requested. +- Keep AI functionality optional and non-blocking. + - If AI configuration is missing, the app should still work (AI can silently no-op). +- Use existing patterns in the codebase (logging, DI, options, error handling). +- Don’t add new external dependencies unless necessary and justified. + +## C# conventions +- Prefer async/await end-to-end. +- Handle nullability deliberately; avoid introducing new nullable warnings. +- Use `System.Text.Json` where the project already does; don’t mix serializers in the same flow unless required. + +## Unit tests +- Aim for high coverage on new features. +- Focus on behavior verification over implementation details. +- When adding tests, follow existing patterns in `explainpowershell.analysisservice.tests/`. + +## Building +- On fresh clones, run all code generators before building: `Get-ChildItem -Path $PSScriptRoot/explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName }` + +## PowerShell / Pester conventions +- Keep tests deterministic and fast; avoid relying on external services unless explicitly an integration test. +- When adding tests, follow the existing Pester structure and naming. +- Before adding Pester tests, consider if the behavior can be verified in C# unit tests first. + +## Running locally +- 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. +- 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. +- You can load helper methods to test the functionapp locally by importing the following scripts in your PowerShell session: +```powershell +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.ps1 +. C:\Users\JosKoelewijn\GitNoOneDrive\explainpowershell/explainpowershell.analysisservice.tests/Get-MetaData.ps1 +``` + +## How to validate changes +- Prefer the repo task: run the VS Code task named `run tests` (Pester). +- If you need a build check, use the VS Code `build` task. + +## Documentation +- When adding developer-facing features, also update or add a CodeTour in `.tours/` when it improves onboarding. diff --git a/.tours/high-level-tour-of-the-application.tour b/.tours/high-level-tour-of-the-application.tour index 089e37c..217e20e 100644 --- a/.tours/high-level-tour-of-the-application.tour +++ b/.tours/high-level-tour-of-the-application.tour @@ -5,87 +5,57 @@ { "file": "explainpowershell.frontend/Pages/Index.razor", "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.", - "line": 12, - "selection": { - "start": { - "line": 16, - "character": 34 - }, - "end": { - "line": 22, - "character": 23 - } - } + "line": 15 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "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. ", - "line": 24 + "line": 25 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "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.", - "line": 53 + "line": 68 }, { - "file": "explainpowershell.analysisservice/AstVisitorExplainer.cs", + "file": "explainpowershell.analysisservice/AstVisitorExplainer_statements.cs", "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. ", - "line": 577 + "line": 178 }, { - "file": "explainpowershell.analysisservice/AstVisitorExplainer.cs", + "file": "explainpowershell.analysisservice/AstVisitorExplainer_statements.cs", "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.", - "line": 580 + "line": 181 }, { "file": "explainpowershell.models/Explanation.cs", "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.", - "line": 2 + "line": 3 }, { - "file": "explainpowershell.analysisservice/AstVisitorExplainer.cs", + "file": "explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs", "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.", - "line": 24, - "selection": { - "start": { - "line": 397, - "character": 55 - }, - "end": { - "line": 397, - "character": 58 - } - } + "line": 29 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "if there were any parse errors, we get the message for that here.", - "line": 61 + "line": 79 }, { "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", "description": "We send our list of explanations, and any parse error messages back to the frontend.", - "line": 65 + "line": 88 }, { - "file": "explainpowershell.frontend/Pages/Index.razor", + "file": "explainpowershell.frontend/Pages/Index.razor.cs", "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. ", - "line": 250, - "selection": { - "start": { - "line": 29, - "character": 29 - }, - "end": { - "line": 29, - "character": 38 - } - } + "line": 152 }, { "file": "explainpowershell.frontend/Pages/Index.razor", "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", - "line": 29 + "line": 52 } ] } \ No newline at end of file diff --git a/.tours/tour-of-the-ai-explanation-feature.tour b/.tours/tour-of-the-ai-explanation-feature.tour new file mode 100644 index 0000000..dddbf3f --- /dev/null +++ b/.tours/tour-of-the-ai-explanation-feature.tour @@ -0,0 +1,86 @@ +{ + "$schema": "https://aka.ms/codetour-schema", + "title": "Tour of the AI explanation feature", + "steps": [ + { + "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", + "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.", + "line": 25 + }, + { + "file": "explainpowershell.analysisservice/SyntaxAnalyzer.cs", + "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.", + "line": 82 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "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`).", + "line": 120 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "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.", + "line": 152 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "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.", + "line": 157 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor.cs", + "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).", + "line": 160 + }, + { + "file": "explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs", + "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.", + "line": 69 + }, + { + "file": "explainpowershell.frontend/Pages/Index.razor", + "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.", + "line": 34 + }, + { + "file": "explainpowershell.analysisservice/AiExplanationFunction.cs", + "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.", + "line": 23 + }, + { + "file": "explainpowershell.analysisservice/AiExplanationFunction.cs", + "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.", + "line": 63 + }, + { + "file": "explainpowershell.analysisservice/Program.cs", + "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.", + "line": 36 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationOptions.cs", + "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.", + "line": 6 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", + "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.", + "line": 30 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", + "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`.", + "line": 57 + }, + { + "file": "explainpowershell.analysisservice/Services/AiExplanationService.cs", + "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.", + "line": 92 + }, + { + "file": "explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1", + "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.", + "line": 3 + } + ] +} diff --git a/.tours/tour-of-the-azure-bootstrapper.tour b/.tours/tour-of-the-azure-bootstrapper.tour index f29a2cb..5218b70 100644 --- a/.tours/tour-of-the-azure-bootstrapper.tour +++ b/.tours/tour-of-the-azure-bootstrapper.tour @@ -5,7 +5,7 @@ { "file": "azuredeploymentbootstrapper.ps1", "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.", - "line": 13 + "line": 1 }, { "file": ".github/workflows/deploy_azure_infra.yml", diff --git a/.tours/tour-of-the-help-collector.tour b/.tours/tour-of-the-help-collector.tour index d66d266..db3dbae 100644 --- a/.tours/tour-of-the-help-collector.tour +++ b/.tours/tour-of-the-help-collector.tour @@ -9,13 +9,18 @@ }, { "file": ".github/workflows/add_module_help.yml", - "description": "A little bit above here, the requested module is installed on the GitHub action runner and here, 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. ", - "line": 46 + "description": "If the module is not installed on the machine running this pipeline, try to install it.", + "line": 25 + }, + { + "file": ".github/workflows/add_module_help.yml", + "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. ", + "line": 39 }, { "file": ".github/workflows/add_module_help.yml", "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.", - "line": 68 + "line": 74 }, { "file": "explainpowershell.helpcollector/helpcollector.ps1", @@ -24,8 +29,8 @@ }, { "file": "explainpowershell.helpcollector/helpwriter.ps1", - "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 a want a certain row, I ask for `(PartitionKey='x' RowKey='y')`, if my 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. ", - "line": 48 + "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. ", + "line": 117 }, { "file": "explainpowershell.helpcollector/BulkHelpCollector.ps1", diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 23abfa0..fc03f54 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,14 +1,11 @@ { "recommendations": [ + "azurite.azurite", "ms-azuretools.vscode-azurefunctions", + "ms-dotnettools.blazorwasm-companion", "ms-dotnettools.csharp", - "ms-vscode.powershell", "ms-dotnettools.vscode-dotnet-runtime", - "ms-dotnettools.blazorwasm-companion", - "vsls-contrib.codetour", - "derivitec-ltd.vscode-dotnet-adapter", - "ms-vscode.test-adapter-converter", - "hbenl.vscode-test-explorer", - "azurite.azurite" + "ms-vscode.powershell", + "vsls-contrib.codetour" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 1aa9d64..69509c4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,8 +2,8 @@ "version": "0.2.0", "compounds": [ { - "name": "debug solution", - "configurations": ["debug functionApp", "debug wasm"], + "name": "Debug solution", + "configurations": ["Debug functionApp", "Debug wasm"], "outFiles": [ "./explainpowershell.analysisservice/SyntaxAnalyzer.cs", "./explainpowershell.frontend/Pages/Index.razor" @@ -12,22 +12,15 @@ ], "configurations": [ { - "name": "Test functionApp", - "type": "PowerShell", - "request": "launch", - "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1", - "cwd": "${workspaceFolder}", - "internalConsoleOptions": "openOnSessionStart" - }, - { - "name": "debug functionApp", + "name": "Debug functionApp", "type": "coreclr", "request": "attach", "processId": "${command:azureFunctions.pickProcess}", - "internalConsoleOptions": "neverOpen" + "internalConsoleOptions": "neverOpen", + "preLaunchTask": "func: host start" }, { - "name": "debug wasm", + "name": "Debug wasm", "type": "blazorwasm", "request": "launch", "cwd": "${workspaceFolder}/explainpowershell.frontend/", @@ -40,5 +33,15 @@ "script": "${file}", "cwd": "${file}" }, + { + "name": "Test solution", + "type": "PowerShell", + "request": "launch", + "script": "${workspaceFolder}/explainpowershell.analysisservice.tests/Start-AllTests.ps1", + "args": ["-Output Detailed"], + "cwd": "${workspaceFolder}", + "internalConsoleOptions": "openOnSessionStart", + "preLaunchTask": "startstorageemulator" + } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 2cb1987..0dd642f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,10 +4,6 @@ "azureFunctions.projectRuntime": "~4", "debug.internalConsoleOptions": "neverOpen", "azureFunctions.preDeployTask": "publish", - "razor.disableBlazorDebugPrompt": true, - "testExplorer.useNativeTesting": true, - "dotnetCoreExplorer.logpanel": true, - "dotnetCoreExplorer.searchpatterns": "explainpowershell.analysisservice.tests/**/bin/**/explain*tests.dll", "dotnetAcquisitionExtension.existingDotnetPath": [ "/usr/bin/dotnet" ], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9a05dfc..86d55bb 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -8,7 +8,7 @@ "isDefault": true }, "type": "shell", - "command": "Invoke-Pester -Configuration (New-PesterConfiguration -Hashtable @{ Run = @{ Path = './explainpowershell.analysisservice.tests' }; Output = @{ Verbosity = 'Detailed' } })", + "command": "$env:AiExplanation__Enabled='false'; $env:AiExplanation__Endpoint=''; $env:AiExplanation__ApiKey=''; $env:AiExplanation__DeploymentName=''; Invoke-Pester -Configuration (New-PesterConfiguration -Hashtable @{ Run = @{ Path = './explainpowershell.analysisservice.tests' }; Output = @{ Verbosity = 'Detailed' } })", "presentation": { "echo": true, "reveal": "always", @@ -58,6 +58,7 @@ "problemMatcher": "$msCompile" }, { + "label": "func: host start", "type": "func", "options": { "cwd": "${workspaceFolder}/explainpowershell.analysisservice/" @@ -100,8 +101,7 @@ { "label": "startstorageemulator", "command": "${command:azurite.start}", - "isBackground": true, - "problemMatcher": [] + "isBackground": true } ] } diff --git a/README.md b/README.md index 86ffaf2..1f40ceb 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,17 @@ # Explain PowerShell -PowerShell version of [explainshell.com](explainshell.com) +PowerShell version of [explainshell.com](https://explainshell.com) On ExplainShell.com, you can enter a Linux terminal oneliner, and the site will analyze it, and return snippets from the proper man-pages, in an effort to explain the oneliner. I have created a similar thing but for PowerShell here: https://www.explainpowershell.com -If you'd like a tour of this repo, open the repo in VSCode (from here with the '.' key), and install the [CodeTour](vsls-contrib.codetour) extension. In the Explorer View, you will now see CodeTour all the way at the bottom left. There currently are four code tours available: -- High level tour of the application -- Tour of development container -- Tour of the Azure bootstrapper -- Tour of the help collector +If you'd like a tour of this repo, open the repo in VS Code (from here with the '.' key), and install the [CodeTour](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.codetour) extension. In the Explorer View, you will now see CodeTour all the way at the bottom left. There currently are four code tours available: +- [High level tour of the application](.tours/high-level-tour-of-the-application.tour) +- [Tour of the AI explanation feature](.tours/tour-of-the-ai-explanation-feature.tour) +- [Tour of the Azure bootstrapper](.tours/tour-of-the-azure-bootstrapper.tour) +- [Tour of the help collector](.tours/tour-of-the-help-collector.tour) ## Goal diff --git a/bootstrap.ps1 b/bootstrap.ps1 index a067166..99bc131 100644 --- a/bootstrap.ps1 +++ b/bootstrap.ps1 @@ -1,6 +1,7 @@ [CmdletBinding()] param( - [Switch]$Force + [Switch]$Force, + [Switch]$UpdateProfile ) $minPwsh = [Version]'7.4' @@ -84,17 +85,41 @@ $commandsToAddToProfile = @( ". $PSScriptRoot/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1" ". $PSScriptRoot/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.ps1" ". $PSScriptRoot/explainpowershell.analysisservice.tests/Get-MetaData.ps1" + ". $PSScriptRoot/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1" ) -if ( !(Test-Path -Path $profile.CurrentUserAllHosts) ) { - New-Item -Path $profile.CurrentUserAllHosts -Force -ItemType file | Out-Null +$isInteractive = $null -ne $Host.UI -and $null -ne $Host.UI.RawUI -and -not $env:CI -and -not $env:GITHUB_ACTIONS + +$profileNeedsUpdate = $false +if (Test-Path -Path $profile.CurrentUserAllHosts) { + $profileContents = Get-Content -Path $profile.CurrentUserAllHosts + if ($null -eq $profileContents -or $profileContents.split("`n") -notcontains $commandsToAddToProfile[0]) { + $profileNeedsUpdate = $true + } +} +else { + $profileNeedsUpdate = $true +} + +$shouldUpdateProfile = $UpdateProfile +if (-not $shouldUpdateProfile -and $profileNeedsUpdate) { + if ($isInteractive) { + $answer = Read-Host "Update PowerShell profile '$($profile.CurrentUserAllHosts)' with helper imports? (y/N)" + $shouldUpdateProfile = $answer -match '^(y|yes)$' + } + else { + Write-Host "Skipping profile update (non-interactive). Re-run with -UpdateProfile to enable." + } } -$profileContents = Get-Content -Path $profile.CurrentUserAllHosts -if ($null -eq $profileContents -or - $profileContents.split("`n") -notcontains $commandsToAddToProfile[0]) { +if ($shouldUpdateProfile -and $profileNeedsUpdate) { + if ( !(Test-Path -Path $profile.CurrentUserAllHosts) ) { + New-Item -Path $profile.CurrentUserAllHosts -Force -ItemType file | Out-Null + } + Write-Host -ForegroundColor Green 'Add settings to PowerShell profile' Add-Content -Path $profile.CurrentUserAllHosts -Value $commandsToAddToProfile + # Copy profile contents to VSCode profile too: Microsoft.VSCode_profile.ps1 Get-Content -Path $profile.CurrentUserAllHosts | Set-Content -Path ($profile.CurrentUserAllHosts @@ -154,6 +179,6 @@ foreach ($module in $modulesToProcess) { } Write-Host -ForegroundColor Green 'Running tests to see if everything works' -& $PSScriptRoot/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 -Output Detailed +& $PSScriptRoot/explainpowershell.analysisservice.tests/Start-AllTests.ps1 -Output Detailed Write-Host -ForegroundColor Green "Done. You now have the functions 'Get-HelpDatabaseData', 'Invoke-SyntaxAnalyzer' and 'Get-MetaData' available for ease of testing." \ No newline at end of file diff --git a/explainpowershell.analysisservice.tests/AI-TESTS-README.md b/explainpowershell.analysisservice.tests/AI-TESTS-README.md index 56a7813..5da6ccf 100644 --- a/explainpowershell.analysisservice.tests/AI-TESTS-README.md +++ b/explainpowershell.analysisservice.tests/AI-TESTS-README.md @@ -60,13 +60,13 @@ End-to-end integration tests covering: ### All Tests ```powershell cd explainpowershell.analysisservice.tests -.\Start-AllBackendTests.ps1 -Output Detailed +.\Start-AllTests.ps1 -Output Detailed ``` ### Unit Tests Only (C#) ```powershell cd explainpowershell.analysisservice.tests -.\Start-AllBackendTests.ps1 -SkipIntegrationTests -Output Detailed +.\Start-AllTests.ps1 -SkipIntegrationTests -Output Detailed ``` Or using dotnet CLI: @@ -77,7 +77,7 @@ dotnet test --verbosity normal ### Integration Tests Only (Pester) ```powershell cd explainpowershell.analysisservice.tests -.\Start-AllBackendTests.ps1 -SkipUnitTests -Output Detailed +.\Start-AllTests.ps1 -SkipUnitTests -Output Detailed ``` Or using Pester directly: diff --git a/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 b/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 index 299d857..4d7849c 100644 --- a/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Get-HelpDatabaseData.Tests.ps1 @@ -8,7 +8,7 @@ Describe 'Get-HelpDatabaseData' { $data = Get-HelpDatabaseData -RowKey 'about_pwsh' $data.Properties.CommandName | Should -BeExactly 'about_Pwsh' - $data.Properties.DocumentationLink | Should -Match 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Pwsh' + $data.Properties.DocumentationLink | Should -Match 'https://(learn|docs).microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Pwsh' $data.Properties.ModuleName | Should -BeNullOrEmpty $data.Properties.Synopsis | Should -BeExactly 'Explains how to use the pwsh command-line interface. Displays the command-line parameters and describes the syntax.' } diff --git a/explainpowershell.analysisservice.tests/Get-MetaData.ps1 b/explainpowershell.analysisservice.tests/Get-MetaData.ps1 index d840c0b..1ddec13 100644 --- a/explainpowershell.analysisservice.tests/Get-MetaData.ps1 +++ b/explainpowershell.analysisservice.tests/Get-MetaData.ps1 @@ -3,11 +3,11 @@ function Get-MetaData { [switch] $Refresh ) - $uri = 'http://localhost:7071/api/MetaData' + $uri = 'http://127.0.0.1:7071/api/MetaData' if ( $Refresh ) { $uri += '?refresh=true' } - + Invoke-RestMethod -Uri $uri } diff --git a/explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1 b/explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1 new file mode 100644 index 0000000..807ace5 --- /dev/null +++ b/explainpowershell.analysisservice.tests/HelpCollectorHtmlScraper.Tests.ps1 @@ -0,0 +1,54 @@ +Describe "HelpCollector HTML synopsis scraper" { + BeforeAll { + . "$PSScriptRoot/../explainpowershell.helpcollector/HelpCollector.Functions.ps1" + } + + It "Normalizes docs.microsoft.com to learn.microsoft.com and forces https" { + ConvertTo-LearnDocumentationUri -Uri 'http://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_return' | + Should -Be 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_return' + } + + It "Extracts synopsis from summary paragraph" { + $html = @' + + +t + +

about_Return

+

Returns from the current scope.

+ + +'@ + + Get-SynopsisFromHtml -Html $html -Cmd 'return' | Should -Be 'Returns from the current scope.' + } + + It "Extracts synopsis from meta description" { + $html = @' + + + + + +

about_Throw

+ + +'@ + + Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws a terminating error.' + } + + It "Extracts keyword synopsis from about_language_keywords section" { + $html = @' + + +

about_Language_Keywords

+

throw

+

Throws an exception.

+ + +'@ + + Get-SynopsisFromHtml -Html $html -Cmd 'throw' | Should -Be 'Throws an exception.' + } +} diff --git a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 index 6220808..3415338 100644 --- a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.Tests.ps1 @@ -3,15 +3,25 @@ using namespace Microsoft.PowerShell.Commands Describe "AI Explanation Integration Tests" { BeforeAll { - . $PSScriptRoot/Invoke-SyntaxAnalyzer.ps1 - . $PSScriptRoot/Start-FunctionApp.ps1 - . $PSScriptRoot/Test-IsAzuriteUp.ps1 - # Save original AI config to restore later $script:originalAiEnabled = $env:AiExplanation__Enabled $script:originalAiEndpoint = $env:AiExplanation__Endpoint $script:originalAiApiKey = $env:AiExplanation__ApiKey $script:originalAiDeploymentName = $env:AiExplanation__DeploymentName + + # IMPORTANT: AI enabled/configured state is read at Functions host startup. + # Keep tests deterministic and fast by disabling outbound AI calls. + $env:AiExplanation__Enabled = 'false' + $env:AiExplanation__Endpoint = '' + $env:AiExplanation__ApiKey = '' + $env:AiExplanation__DeploymentName = '' + + . $PSScriptRoot/Invoke-SyntaxAnalyzer.ps1 + . $PSScriptRoot/Invoke-AiExplanation.ps1 + . $PSScriptRoot/Start-FunctionApp.ps1 + . $PSScriptRoot/Test-IsAzuriteUp.ps1 + + $script:baseUri = 'http://127.0.0.1:7071/api' } AfterAll { @@ -32,14 +42,12 @@ Describe "AI Explanation Integration Tests" { Context "AiExplanation Function Endpoint" { - It "Should return 200 OK when AI is disabled" -Skip { - # Note: Skipped because AI enabled state is determined at function app startup. + It "Should return 200 OK when AI is disabled" -Skip:($env:AiExplanation__Enabled -ne 'false') { + # Note: you should skip this test if AI is not disabled, because AI enabled state is determined at function app startup. # Changing environment variables at runtime doesn't reload the DI container. # To test AI disabled behavior, restart function app with AiExplanation__Enabled=false # Arrange - $env:AiExplanation__Enabled = "false" - Start-Sleep -Milliseconds 500 # Give function app time to reload config $requestBody = @{ PowershellCode = "Get-Process" @@ -56,7 +64,7 @@ Describe "AI Explanation Integration Tests" { # Act $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $requestBody ` -ContentType "application/json" ` @@ -70,9 +78,7 @@ Describe "AI Explanation Integration Tests" { It "Should accept valid analysis result" { # Arrange - $requestBody = @{ - PowershellCode = "Get-Process" - AnalysisResult = @{ + $analysisResult = @{ ExpandedCode = "Get-Process" ParseErrorMessage = "" Explanations = @( @@ -91,16 +97,10 @@ Describe "AI Explanation Integration Tests" { DetectedModules = @( @{ ModuleName = "Microsoft.PowerShell.Management" } ) - } - } | ConvertTo-Json -Depth 10 + } # Act & Assert - Should not throw - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult -BaseUri $script:baseUri $response.StatusCode | Should -Be 200 $content = $response.Content | ConvertFrom-Json @@ -121,7 +121,7 @@ Describe "AI Explanation Integration Tests" { # Act & Assert - Should return 400 BadRequest for validation error { Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $requestBody ` -ContentType "application/json" ` @@ -131,22 +131,14 @@ Describe "AI Explanation Integration Tests" { It "Should handle empty explanations list" { # Arrange - $requestBody = @{ - PowershellCode = "Get-Process" - AnalysisResult = @{ + $analysisResult = @{ ExpandedCode = "Get-Process" Explanations = @() DetectedModules = @() - } - } | ConvertTo-Json -Depth 10 + } # Act - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode "Get-Process" -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $response.StatusCode | Should -Be 200 @@ -169,26 +161,24 @@ Describe "AI Explanation Integration Tests" { } } + $code = "Get-Process | Where-Object Name -Like 'chrome*'" + $analysisResult = @{ + ExpandedCode = $code + Explanations = $explanations + DetectedModules = @( + @{ ModuleName = "Microsoft.PowerShell.Management" } + ) + } + $requestBody = @{ - PowershellCode = "Get-Process | Where-Object Name -Like 'chrome*'" - AnalysisResult = @{ - ExpandedCode = "Get-Process | Where-Object Name -Like 'chrome*'" - Explanations = $explanations - DetectedModules = @( - @{ ModuleName = "Microsoft.PowerShell.Management" } - ) - } + PowershellCode = $code + AnalysisResult = $analysisResult } | ConvertTo-Json -Depth 10 Write-Host "Payload size: $($requestBody.Length) bytes" # Act - Should handle payload reduction gracefully - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $response.StatusCode | Should -Be 200 @@ -198,9 +188,7 @@ Describe "AI Explanation Integration Tests" { It "Should return model name in response" { # Arrange - $requestBody = @{ - PowershellCode = "gps" - AnalysisResult = @{ + $analysisResult = @{ ExpandedCode = "Get-Process" Explanations = @( @{ @@ -209,16 +197,10 @@ Describe "AI Explanation Integration Tests" { Description = "Gets processes" } ) - } - } | ConvertTo-Json -Depth 10 + } # Act - $response = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $requestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $response = Invoke-AiExplanation -PowershellCode "gps" -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $content = $response.Content | ConvertFrom-Json @@ -228,35 +210,6 @@ Describe "AI Explanation Integration Tests" { } } - Context "Configuration Validation" { - - It "Should respect MaxPayloadCharacters configuration" { - # This is implicitly tested by the large payload test - # The service should reduce payload size when it exceeds configured limit - $true | Should -Be $true - } - - It "Should use configured system prompt" { - # Arrange - Set custom system prompt - $customPrompt = "You are a test assistant for PowerShell." - $env:AiExplanation__SystemPrompt = $customPrompt - Start-Sleep -Milliseconds 500 - - # Note: We can't directly verify the prompt is used without AI credentials - # This test validates the configuration is accepted - $env:AiExplanation__SystemPrompt | Should -Be $customPrompt - } - - It "Should use configured timeout" { - # Arrange - $env:AiExplanation__RequestTimeoutSeconds = "5" - Start-Sleep -Milliseconds 500 - - # Verify configuration is set - $env:AiExplanation__RequestTimeoutSeconds | Should -Be "5" - } - } - Context "Error Handling" { It "Should handle malformed JSON gracefully" { @@ -266,7 +219,7 @@ Describe "AI Explanation Integration Tests" { # Act & Assert { Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $badJson ` -ContentType "application/json" ` @@ -284,7 +237,7 @@ Describe "AI Explanation Integration Tests" { # Act & Assert - Should return 400 BadRequest for validation error { Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` + -Uri "$script:baseUri/aiexplanation" ` -Method Post ` -Body $requestBody ` -ContentType "application/json" ` @@ -298,21 +251,11 @@ Describe "AI Explanation Integration Tests" { It "Should work in complete analysis workflow" { # Arrange - First get regular analysis $code = 'Get-Process | Where-Object CPU -gt 100' - [BasicHtmlWebResponseObject]$analysisResponse = Invoke-SyntaxAnalyzer -PowerShellCode $code + [BasicHtmlWebResponseObject]$analysisResponse = Invoke-SyntaxAnalyzer -PowerShellCode $code -BaseUri $script:baseUri $analysisResult = $analysisResponse.Content | ConvertFrom-Json # Act - Then request AI explanation - $aiRequestBody = @{ - PowershellCode = $code - AnalysisResult = $analysisResult - } | ConvertTo-Json -Depth 10 - - $aiResponse = Invoke-WebRequest ` - -Uri "http://localhost:7071/api/aiexplanation" ` - -Method Post ` - -Body $aiRequestBody ` - -ContentType "application/json" ` - -ErrorAction Stop + $aiResponse = Invoke-AiExplanation -PowershellCode $code -AnalysisResult $analysisResult -BaseUri $script:baseUri # Assert $aiResponse.StatusCode | Should -Be 200 diff --git a/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 new file mode 100644 index 0000000..0efe126 --- /dev/null +++ b/explainpowershell.analysisservice.tests/Invoke-AiExplanation.ps1 @@ -0,0 +1,64 @@ +# Ensure we use the repo-local (optimized) helper implementation. +# This avoids accidentally calling a stale `Invoke-SyntaxAnalyzer` already loaded in the caller's session. +$invokeSyntaxAnalyzerPath = Join-Path -Path $PSScriptRoot -ChildPath 'Invoke-SyntaxAnalyzer.ps1' +if (Test-Path -LiteralPath $invokeSyntaxAnalyzerPath) { + . $invokeSyntaxAnalyzerPath +} + +function Invoke-AiExplanation { + param( + [Parameter(Mandatory)] + [string]$PowershellCode, + + [Parameter()] + [object]$AnalysisResult, + + [Parameter()] + [string]$BaseUri = 'http://127.0.0.1:7071/api', + + [Parameter()] + [int]$TimeoutSec = 30, + + [Parameter()] + [switch]$AsObject, + + [Parameter()] + [switch]$AiExplanation + ) + + $ErrorActionPreference = 'stop' + + if (-not $AnalysisResult) { + if (Get-Command -Name Invoke-SyntaxAnalyzer -ErrorAction SilentlyContinue) { + $analysisResponse = Invoke-SyntaxAnalyzer -PowershellCode $PowershellCode -BaseUri $BaseUri -TimeoutSec $TimeoutSec + $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json + } + else { + $analysisBody = @{ PowershellCode = $PowershellCode } | ConvertTo-Json + + $analysisResponse = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $analysisBody -ContentType 'application/json' -TimeoutSec $TimeoutSec + $AnalysisResult = $analysisResponse.Content | ConvertFrom-Json + } + } + + $body = @{ + PowershellCode = $PowershellCode + AnalysisResult = $AnalysisResult + } | ConvertTo-Json -Depth 20 + + # Note: the function route is `AiExplanation`, but the Functions host is case-insensitive. + + $response = Invoke-WebRequest -Uri "$BaseUri/aiexplanation" -Method Post -Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec + + if ($AsObject -or $AiExplanation) { + $result = $response.Content | ConvertFrom-Json + + if ($AiExplanation) { + return $result.AiExplanation + } + + return $result + } + + return $response +} diff --git a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 index 9e97969..a3607f2 100644 --- a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.Tests.ps1 @@ -15,6 +15,43 @@ Describe "Invoke-SyntaxAnalyzer" { $content.Explanations[0].HelpResult.DocumentationLink | Should -Match "about_Classes" } + It "Explains return statement" { + $explanations = Invoke-SyntaxAnalyzer -PowershellCode "return 42" -Explanations + $returnExplanation = $explanations | Where-Object { $_.TextToHighlight -eq 'return' } | Select-Object -First 1 + + $returnExplanation | Should -Not -BeNullOrEmpty + $returnExplanation.CommandName | Should -Be 'return statement' + $returnLink = $returnExplanation.HelpResult.DocumentationLink + $returnLink | Should -Not -BeNullOrEmpty + $returnLink | Should -Match 'about_Return|#return' + if ($returnLink -match 'about_Return') { + $returnExplanation.HelpResult.RelatedLinks | Should -Match '#return' + } + } + + It "Explains throw statement" { + $explanations = Invoke-SyntaxAnalyzer -PowershellCode "throw 'boom'" -Explanations + $throwExplanation = $explanations | Where-Object { $_.TextToHighlight -eq 'throw' } | Select-Object -First 1 + + $throwExplanation | Should -Not -BeNullOrEmpty + $throwExplanation.CommandName | Should -Be 'throw statement' + $throwLink = $throwExplanation.HelpResult.DocumentationLink + $throwLink | Should -Not -BeNullOrEmpty + $throwLink | Should -Match 'about_Throw|#throw' + if ($throwLink -match 'about_Throw') { + $throwExplanation.HelpResult.RelatedLinks | Should -Match '#throw' + } + } + + It "Explains trap statement" { + $explanations = Invoke-SyntaxAnalyzer -PowershellCode "trap { continue }" -Explanations + $trapExplanation = $explanations | Where-Object { $_.TextToHighlight -eq 'trap' } | Select-Object -First 1 + + $trapExplanation | Should -Not -BeNullOrEmpty + $trapExplanation.CommandName | Should -Be 'trap statement' + $trapExplanation.HelpResult.DocumentationLink | Should -Match 'about_Trap' + } + It "Should display correct help for assigment operators" { $code = '$D=[Datetime]::Now' [BasicHtmlWebResponseObject]$result = Invoke-SyntaxAnalyzer -PowerShellCode $code diff --git a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 index 83f5d2b..959be71 100644 --- a/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 +++ b/explainpowershell.analysisservice.tests/Invoke-SyntaxAnalyzer.ps1 @@ -1,16 +1,31 @@ function Invoke-SyntaxAnalyzer { param( + [Parameter(Mandatory)] [string]$PowershellCode, + + [Parameter()] + [string]$BaseUri = 'http://127.0.0.1:7071/api', + + [Parameter()] + [int]$TimeoutSec = 30, + [switch]$Explanations ) $ErrorActionPreference = 'stop' - $body = @{ - PowershellCode=$PowershellCode - } | ConvertTo-Json + $body = @{ PowershellCode = $PowershellCode } | ConvertTo-Json - $response = Invoke-WebRequest -Uri "http://localhost:7071/api/SyntaxAnalyzer" -Method Post -Body $body + # Invoke-WebRequest can emit expensive per-request progress UI, which adds significant overhead + # in tight test loops on Windows. Suppress progress only for this request to keep tests fast. + $originalProgressPreference = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' + try { + $response = Invoke-WebRequest -Uri "$BaseUri/SyntaxAnalyzer" -Method Post -Body $body -ContentType 'application/json' -TimeoutSec $TimeoutSec + } + finally { + $ProgressPreference = $originalProgressPreference + } if ($Explanations) { return $response.Content | ConvertFrom-Json | Select-Object -Expandproperty Explanations diff --git a/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 b/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 deleted file mode 100644 index cb62541..0000000 --- a/explainpowershell.analysisservice.tests/Start-AllBackendTests.ps1 +++ /dev/null @@ -1,47 +0,0 @@ -[CmdletBinding()] -param( - [Parameter()] - [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] - [string]$Output = 'Normal', - [switch]$SkipIntegrationTests, - [switch]$SkipUnitTests -) - -$c = New-PesterConfiguration -Hashtable @{ - Output = @{ - Verbosity = $Output - } -} - -$PSScriptRoot - -# Run all code generators -Get-ChildItem -Path $PSScriptRoot/../explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } - -$opp = $ProgressPreference -$ProgressPreference = 'SilentlyContinue' - -Push-Location -Path $PSScriptRoot/ - if (-not $SkipIntegrationTests) { - # Integration Tests - Write-Host -ForegroundColor Cyan "`n####`n#### Starting Integration tests`n" - . ./Test-IsPrerequisitesRunning.ps1 - $werePrerequisitesAlreadyRunning = Test-IsPrerequisitesRunning -ports 7071 - Invoke-Pester -Configuration $c - if (-not $werePrerequisitesAlreadyRunning) { - Get-Job | Stop-Job -PassThru | Remove-Job -Force - } - } - if (-not $SkipUnitTests) { - # Unit Tests - Write-Host -ForegroundColor Cyan "`n####`n#### Starting Unit tests`n" - Write-Host -ForegroundColor Green "Building tests.." - # we want the verbosity for the build step to be quiet - dotnet build --verbosity quiet --nologo - Write-Host -ForegroundColor Green "Running tests.." - # for the test step we want to be able to adjust the verbosity - dotnet test --no-build --nologo --verbosity $Output - } -Pop-Location - -$ProgressPreference = $opp \ No newline at end of file diff --git a/explainpowershell.analysisservice.tests/Start-AllTests.ps1 b/explainpowershell.analysisservice.tests/Start-AllTests.ps1 new file mode 100644 index 0000000..c8ce7c6 --- /dev/null +++ b/explainpowershell.analysisservice.tests/Start-AllTests.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +param( + [Parameter()] + [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] + [string]$Output = 'Normal', + [switch]$SkipIntegrationTests, + [switch]$SkipUnitTests, + + # By default, keep test runs deterministic and fast by disabling outbound AI calls. + # Opt-in for real AI calls when needed (e.g., manual/local integration). + [switch]$EnableAiCalls +) + +$c = New-PesterConfiguration -Hashtable @{ + Output = @{ + Verbosity = $Output + } +} + +$PSScriptRoot + +# Save/restore environment so running tests doesn't permanently affect the user's session. +$script:originalAiEnabled = $env:AiExplanation__Enabled +$script:originalAiEndpoint = $env:AiExplanation__Endpoint +$script:originalAiApiKey = $env:AiExplanation__ApiKey +$script:originalAiDeploymentName = $env:AiExplanation__DeploymentName + +try { + if (-not $EnableAiCalls) { + $env:AiExplanation__Enabled = $false + $env:AiExplanation__Endpoint = '' + $env:AiExplanation__ApiKey = '' + $env:AiExplanation__DeploymentName = '' + } + + # Run all code generators + Get-ChildItem -Path $PSScriptRoot/../explainpowershell.analysisservice/ -Recurse -Filter *_code_generator.ps1 | ForEach-Object { & $_.FullName } + + Push-Location -Path $PSScriptRoot/ + if (-not $SkipIntegrationTests) { + # Integration Tests + Write-Host -ForegroundColor Cyan "`n####`n#### Starting Integration tests`n" + . ./Test-IsPrerequisitesRunning.ps1 + $werePrerequisitesAlreadyRunning = Test-IsPrerequisitesRunning -ports 7071 + Invoke-Pester -Configuration $c + if (-not $werePrerequisitesAlreadyRunning) { + Get-Job | Stop-Job -PassThru | Remove-Job -Force + } + } + if (-not $SkipUnitTests) { + # Unit Tests + Write-Host -ForegroundColor Cyan "`n####`n#### Starting Unit tests`n" + Write-Host -ForegroundColor Green "Building tests.." + Set-Location $PSScriptRoot/.. + # we want the verbosity for the build step to be quiet + dotnet build --verbosity quiet --nologo + Write-Host -ForegroundColor Green "Running tests.." + # for the test step we want to be able to adjust the verbosity + dotnet test --no-build --nologo --verbosity $Output + } + Pop-Location + +} +finally { + if ($null -ne $script:originalAiEnabled) { $env:AiExplanation__Enabled = $script:originalAiEnabled } else { Remove-Item Env:AiExplanation__Enabled -ErrorAction SilentlyContinue } + if ($null -ne $script:originalAiEndpoint) { $env:AiExplanation__Endpoint = $script:originalAiEndpoint } else { Remove-Item Env:AiExplanation__Endpoint -ErrorAction SilentlyContinue } + if ($null -ne $script:originalAiApiKey) { $env:AiExplanation__ApiKey = $script:originalAiApiKey } else { Remove-Item Env:AiExplanation__ApiKey -ErrorAction SilentlyContinue } + if ($null -ne $script:originalAiDeploymentName) { $env:AiExplanation__DeploymentName = $script:originalAiDeploymentName } else { Remove-Item Env:AiExplanation__DeploymentName -ErrorAction SilentlyContinue } +} \ No newline at end of file diff --git a/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 b/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 index ee5a7f2..2637fc7 100644 --- a/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 +++ b/explainpowershell.analysisservice.tests/Start-FunctionApp.ps1 @@ -32,8 +32,7 @@ if (-not (Test-IsPrerequisitesRunning -ports 7071)) { } until ((IsTimedOut -Start $start -TimeOut $timeOut) -or (Test-IsPrerequisitesRunning -ports 7071)) } catch { - throw $_ - Write-Warning "Error: $($_.Message)" + throw } } diff --git a/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj b/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj index 3847500..6365930 100644 --- a/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj +++ b/explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj @@ -6,7 +6,7 @@ - + diff --git a/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs new file mode 100644 index 0000000..fc0d924 --- /dev/null +++ b/explainpowershell.analysisservice.tests/helpers/InMemoryHelpRepository.cs @@ -0,0 +1,88 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using explainpowershell.models; +using ExplainPowershell.SyntaxAnalyzer.Repositories; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + /// + /// In-memory implementation of IHelpRepository for testing + /// + public class InMemoryHelpRepository : IHelpRepository + { + private readonly Dictionary helpData = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Add help data for testing + /// + public void AddHelpEntity(HelpEntity entity) + { + if (entity == null) throw new ArgumentNullException(nameof(entity)); + + var key = entity.CommandName ?? string.Empty; + if (!string.IsNullOrEmpty(entity.ModuleName)) + { + key = $"{key} {entity.ModuleName}"; + } + + helpData[key] = entity; + } + + /// + /// Clear all help data + /// + public void Clear() + { + helpData.Clear(); + } + + /// + public HelpEntity GetHelpForCommand(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return null; + } + + // First try exact match + if (helpData.TryGetValue(commandName, out var entity)) + { + return entity; + } + + // If not found, try to find a command with this name in any module + var key = helpData.Keys.FirstOrDefault(k => + k.Equals(commandName, StringComparison.OrdinalIgnoreCase) || + k.StartsWith($"{commandName} ", StringComparison.OrdinalIgnoreCase)); + + return key != null ? helpData[key] : null; + } + + /// + public HelpEntity GetHelpForCommand(string commandName, string moduleName) + { + if (string.IsNullOrEmpty(commandName) || string.IsNullOrEmpty(moduleName)) + { + return null; + } + + var key = $"{commandName} {moduleName}"; + return helpData.TryGetValue(key, out var entity) ? entity : null; + } + + /// + public List GetHelpForCommandRange(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return new List(); + } + + return helpData + .Where(kvp => kvp.Key.StartsWith(commandName, StringComparison.OrdinalIgnoreCase)) + .Select(kvp => kvp.Value) + .ToList(); + } + } +} diff --git a/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs new file mode 100644 index 0000000..f17bb1e --- /dev/null +++ b/explainpowershell.analysisservice.tests/helpers/TestHelpData.cs @@ -0,0 +1,71 @@ +using explainpowershell.models; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + internal static class TestHelpData + { + public static void SeedAboutTopics(InMemoryHelpRepository repository) + { + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Classes", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Classes" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Foreach", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_For", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Remote_Variables", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", + RelatedLinks = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Scopes", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Scopes" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Return", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Throw", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_language_keywords", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_trap", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" + }); + + repository.AddHelpEntity(new HelpEntity + { + CommandName = "about_Switch", + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Switch" + }); + } + } +} diff --git a/explainpowershell.analysisservice.tests/testfiles/test_get_help.json b/explainpowershell.analysisservice.tests/testfiles/test_get_help.json index 67f8501..39516d9 100644 --- a/explainpowershell.analysisservice.tests/testfiles/test_get_help.json +++ b/explainpowershell.analysisservice.tests/testfiles/test_get_help.json @@ -3,14 +3,14 @@ "CommandName": "Get-Help", "DefaultParameterSet": "AllUsersView", "Description": "The `Get-Help` cmdlet displays information about PowerShell concepts and commands, including cmdlets, functions, Common Information Model (CIM) commands, workflows, providers, aliases, and scripts.\nTo get help for a PowerShell cmdlet, type `Get-Help` followed by the cmdlet name, such as: `Get-Help Get-Process`.\nConceptual help articles in PowerShell begin with about_ , such as about_Comparison_Operators . To see all about_ articles, type `Get-Help about_*`. To see a particular article, type `Get-Help about_`, such as `Get-Help about_Comparison_Operators`.\nTo get help for a PowerShell provider, type `Get-Help` followed by the provider name. For example, to get help for the Certificate provider, type `Get-Help Certificate`.\nYou can also type `help` or `man`, which displays one screen of text at a time. Or, ` -?`, that is identical to `Get-Help`, but only works for cmdlets.\n`Get-Help` gets the help content that it displays from help files on your computer. Without the help files, `Get-Help` displays only basic information about cmdlets. Some PowerShell modules include help files. Beginning in PowerShell 3.0, the modules that come with the Windows operating system don't include help files. To download or update the help files for a module in PowerShell 3.0, use the `Update-Help` cmdlet.\nYou can also view the PowerShell help documents online in the Microsoft Docs. To get the online version of a help file, use the Online parameter, such as: `Get-Help Get-Process -Online`. To read all the PowerShell documentation, see the Microsoft Docs PowerShell Documentation (/powershell).\nIf you type `Get-Help` followed by the exact name of a help article, or by a word unique to a help article, `Get-Help` displays the article's content. If you specify the exact name of a command alias, `Get-Help` displays the help for the original command. If you enter a word or word pattern that appears in several help article titles, `Get-Help` displays a list of the matching titles. If you enter any text that doesn't appear in any help article titles, `Get-Help` displays a list of articles that include that text in their contents.\n`Get-Help` can get help articles for all supported languages and locales. `Get-Help` first looks for help files in the locale set for Windows, then in the parent locale, such as pt for pt-BR , and then in a fallback locale. Beginning in PowerShell 3.0, if `Get-Help` doesn't find help in the fallback locale, it looks for help articles in English, en-US , before it returns an error message or displaying auto-generated help.\nFor information about the symbols that `Get-Help` displays in the command syntax diagram, see about_Command_Syntax (./About/about_Command_Syntax.md). For information about parameter attributes, such as Required and Position , see about_Parameters (./About/about_Parameters.md).\n>[!NOTE] > In PowerShell 3.0 and PowerShell 4.0, `Get-Help` can't find About articles in modules unless > the module is imported into the current session. This is a known issue. To get About articles > in a module, import the module, either by using the `Import-Module` cmdlet or by running a cmdlet > that's included in the module.", - "DocumentationLink": "https://docs.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", + "DocumentationLink": "https://learn.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", "InputTypes": "None", "ModuleName": "Microsoft.PowerShell.Core", "ModuleVersion": "7.2.3.500", - "ModuleProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core", + "ModuleProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core", "Parameters": "[{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays help only for items in the specified category and their aliases. Conceptual articles are in the HelpFile category.\\nThe acceptable values for this parameter are as follows:\\n- Alias\\n- Cmdlet\\n- Provider\\n- General\\n- FAQ\\n- Glossary\\n- HelpFile\\n- ScriptCommand\\n- Function\\n- Filter\\n- ExternalScript\\n- All\\n- DefaultHelp\\n- Workflow\\n- DscResource\\n- Class\\n- Configuration\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Category\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays commands with the specified component value, such as Exchange . Enter a component name. Wildcard characters are permitted. This parameter has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Component\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Adds parameter descriptions and examples to the basic help display. This parameter is effective only when the help files are installed on the computer. It has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Detailed\",\"ParameterSets\":{\"DetailedView\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays only the name, synopsis, and examples. To display only the examples, type `(Get-Help ).Examples`.\\nThis parameter is effective only when the help files are installed on the computer. It has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Examples\",\"ParameterSets\":{\"Examples\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays the entire help article for a cmdlet. Full includes parameter descriptions and attributes, examples, input and output object types, and additional notes.\\nThis parameter is effective only when the help files are installed on the computer. It has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Full\",\"ParameterSets\":{\"AllUsersView\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays help for items with the specified functionality. Enter the functionality. Wildcard characters are permitted. This parameter has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Functionality\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Gets help about the specified command or concept. Enter the name of a cmdlet, function, provider, script, or workflow, such as `Get-Member`, a conceptual article name, such as `about_Objects`, or an alias, such as `ls`. Wildcard characters are permitted in cmdlet and provider names, but you can't use wildcard characters to find the names of function help and script help articles.\\nTo get help for a script that isn't located in a path that's listed in the `$env:Path` environment variable, type the script's path and file name.\\nIf you enter the exact name of a help article, `Get-Help` displays the article contents.\\nIf you enter a word or word pattern that appears in several help article titles, `Get-Help` displays a list of the matching titles.\\nIf you enter any text that doesn't match any help article titles, `Get-Help` displays a list of articles that include that text in their contents.\\nThe names of conceptual articles, such as `about_Objects`, must be entered in English, even in non-English versions of PowerShell.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Name\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":0,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":true,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"True (ByPropertyName)\",\"Position\":\"0\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays the online version of a help article in the default browser. This parameter is valid only for cmdlet, function, workflow, and script help articles. You can't use the Online parameter with `Get-Help` in a remote session.\\nFor information about supporting this feature in help articles that you write, see about_Comment_Based_Help (./About/about_Comment_Based_Help.md), and Supporting Online Help (/powershell/scripting/developer/module/supporting-online-help), and Writing Help for PowerShell Cmdlets (/powershell/scripting/developer/help/writing-help-for-windows-powershell-cmdlets).\",\"Globbing\":\"false\",\"IsDynamic\":false,\"Name\":\"Online\",\"ParameterSets\":{\"Online\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":true,\"TypeName\":\"System.Management.Automation.SwitchParameter\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays only the detailed descriptions of the specified parameters. Wildcards are permitted. This parameter has no effect on displays of conceptual ( About_ ) help.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Parameter\",\"ParameterSets\":{\"Parameters\":{\"IsMandatory\":true,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Gets help that explains how the cmdlet works in the specified provider path. Enter a PowerShell provider path.\\nThis parameter gets a customized version of a cmdlet help article that explains how the cmdlet works in the specified PowerShell provider path. This parameter is effective only for help about a provider cmdlet and only when the provider includes a custom version of the provider cmdlet help article in its help file. To use this parameter, install the help file for the module that includes the provider.\\nTo see the custom cmdlet help for a provider path, go to the provider path location and enter a `Get-Help` command or, from any path location, use the Path parameter of `Get-Help` to specify the provider path. You can also find custom cmdlet help online in the provider help section of the help articles.\\nFor more information about PowerShell providers, see about_Providers (./About/about_Providers.md).\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Path\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String\"},{\"Aliases\":\"none\",\"DefaultValue\":\"None\",\"Description\":\"Displays help customized for the specified user role. Enter a role. Wildcard characters are permitted.\\nEnter the role that the user plays in an organization. Some cmdlets display different text in their help files based on the value of this parameter. This parameter has no effect on help for the core cmdlets.\",\"Globbing\":\"true\",\"IsDynamic\":false,\"Name\":\"Role\",\"ParameterSets\":{\"__AllParameterSets\":{\"IsMandatory\":false,\"Position\":-2147483648,\"ValueFromPipeline\":false,\"ValueFromPipelineByPropertyName\":false,\"ValueFromRemainingArguments\":false,\"HelpMessage\":null,\"HelpMessageBaseName\":null,\"HelpMessageResourceId\":null}},\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"false\",\"SwitchParameter\":false,\"TypeName\":\"System.String[]\"},{\"Aliases\":\"none\",\"DefaultValue\":\"False\",\"Description\":\"Displays the help topic in a window for easier reading. The window includes a Find search feature and a Settings box that lets you set options for the display, including options to display only selected sections of a help topic.\\nThe ShowWindow parameter supports help topics for commands (cmdlets, functions, CIM commands, scripts) and conceptual About articles. It does not support provider help.\\nThis parameter was reintroduced in PowerShell 7.0.\",\"Globbing\":\"false\",\"IsDynamic\":null,\"Name\":\"ShowWindow\",\"ParameterSets\":null,\"PipelineInput\":\"False\",\"Position\":\"named\",\"Required\":\"true\",\"SwitchParameter\":null,\"TypeName\":\"System.Management.Automation.SwitchParameter\"}]", "ParameterSetNames": "AllUsersView, DetailedView, Examples, Online, Parameters", - "RelatedLinks": "https://docs.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", + "RelatedLinks": "https://learn.microsoft.com/powershell/module/microsoft.powershell.core/get-help?view=powershell-7.2&WT.mc_id=ps-gethelp", "ReturnValues": "ExtendedCmdletHelpInfo, System.String, MamlCommandHelpInfo", "Synopsis": "Displays information about PowerShell commands and concepts.", "Syntax": "Get-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] -Detailed [-Functionality ] [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] -Examples [-Functionality ] [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Full] [-Functionality ] [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Functionality ] -Online [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Functionality ] -Parameter [-Path ] [-Role ] []\n\nGet-Help [[-Name] ] [-Category {Alias | Cmdlet | Provider | General | FAQ | Glossary | HelpFile | ScriptCommand | Function | Filter | ExternalScript | All | DefaultHelp | Workflow | DscResource | Class | Configuration}] [-Component ] [-Functionality ] [-Path ] [-Role ] -ShowWindow []" diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs index 2e0b868..fb9432c 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer.tests.cs @@ -6,8 +6,6 @@ using System.Management.Automation.Language; using System.Text.Json; using System.Threading.Tasks; - -using Azure.Data.Tables; using explainpowershell.models; using NUnit.Framework; @@ -21,13 +19,15 @@ public class GetAstVisitorExplainerTests public void Setup() { var mockILogger = new LoggerDouble(); - var tableClient = new TableClient( - "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;", - "HelpData"); + + // Unit tests should not depend on Azurite/Table Storage. Seed only the help topics + // required by these assertions. + var helpRepository = new InMemoryHelpRepository(); + TestHelpData.SeedAboutTopics(helpRepository); explainer = new( extentText: string.Empty, - client: tableClient, + helpRepository: helpRepository, log: mockILogger, tokens: null); } @@ -62,7 +62,7 @@ public void ShouldGenerateHelpForUsingExpressions() "A variable named 'var', with the 'using' scope modifier: a local variable used in a remote scope.", res.Explanations[1].Description); Assert.AreEqual( - "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", + "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Remote_Variables", res.Explanations[1].HelpResult?.DocumentationLink); Assert.AreEqual( "Scoped variable", @@ -83,7 +83,7 @@ public void ShoudGenerateHelpForForeachStatements() res.Explanations[0].Description); Assert.AreEqual( - "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach", + "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Foreach", res.Explanations[0].HelpResult?.DocumentationLink); } @@ -98,8 +98,74 @@ public void ShoudGenerateHelpForForStatements() res.Explanations[0].Description); Assert.AreEqual( - "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For", + "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_For", res.Explanations[0].HelpResult?.DocumentationLink); } + + [Test] + public void ShouldGenerateHelpForReturnStatement() + { + ScriptBlock.Create("return 42").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "return"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("return statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Return")); + Assert.That(explanation.HelpResult?.RelatedLinks, Does.Contain("about_language_keywords").And.Contain("#return")); + } + + [Test] + public void ShouldGenerateHelpForThrowStatement() + { + ScriptBlock.Create("throw 'boom'").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "throw"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("throw statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Throw")); + Assert.That(explanation.HelpResult?.RelatedLinks, Does.Contain("about_language_keywords").And.Contain("#throw")); + } + + [Test] + public void ShouldGenerateHelpForTrapStatement() + { + ScriptBlock.Create("trap { continue }").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "trap"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("trap statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Trap")); + Assert.That(explanation.Description, Does.Contain("trap handler")); + } + + [Test] + public void ShouldGenerateHelpForSwitchStatement() + { + ScriptBlock.Create("switch ($x) { 1 { 'one' } default { 'other' } }").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + var explanation = res.Explanations.SingleOrDefault(e => e.TextToHighlight == "switch"); + + Assert.That(explanation, Is.Not.Null); + Assert.That(explanation.CommandName, Is.EqualTo("switch statement")); + Assert.That(explanation.HelpResult?.DocumentationLink, Does.Contain("about_Switch")); + Assert.That(explanation.HelpResult?.RelatedLinks, Does.Contain("about_language_keywords").And.Contain("#switch")); + } + + [Test] + public void AnalysisResult_HasRootExplanation_WithNullParentId() + { + ScriptBlock.Create("Get-Process").Ast.Visit(explainer); + AnalysisResult res = explainer.GetAnalysisResult(); + + Assert.That(res.Explanations, Is.Not.Empty); + Assert.That(res.Explanations.Any(e => e.ParentId == null), Is.True); + } } } \ No newline at end of file diff --git a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs index a9d836f..9fd9ad2 100644 --- a/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs +++ b/explainpowershell.analysisservice.tests/tests/AstVisitorExplainer_command.tests.cs @@ -6,8 +6,6 @@ using System.Management.Automation.Language; using System.Text.Json; using System.Threading.Tasks; - -using Azure.Data.Tables; using explainpowershell.models; using NUnit.Framework; @@ -21,13 +19,11 @@ public class GetAstVisitorExplainer_commandTests public void Setup() { var mockILogger = new LoggerDouble(); - var tableClient = new TableClient( - "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;", - "HelpData"); + var helpRepository = new InMemoryHelpRepository(); explainer = new( extentText: string.Empty, - client: tableClient, + helpRepository: helpRepository, log: mockILogger, tokens: null); } diff --git a/explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs b/explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs new file mode 100644 index 0000000..e2014a9 --- /dev/null +++ b/explainpowershell.analysisservice.tests/tests/InMemoryHelpRepository.tests.cs @@ -0,0 +1,143 @@ +using System.Linq; +using explainpowershell.models; +using NUnit.Framework; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + [TestFixture] + public class InMemoryHelpRepositoryTests + { + private InMemoryHelpRepository repository; + + [SetUp] + public void Setup() + { + repository = new InMemoryHelpRepository(); + } + + [Test] + public void GetHelpForCommand_WithValidCommand_ReturnsEntity() + { + // Arrange + var entity = new HelpEntity + { + CommandName = "Get-Process", + Synopsis = "Gets the processes running on the local computer.", + ModuleName = "Microsoft.PowerShell.Management" + }; + repository.AddHelpEntity(entity); + + // Act + var result = repository.GetHelpForCommand("get-process"); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual("Get-Process", result.CommandName); + } + + [Test] + public void GetHelpForCommand_WithInvalidCommand_ReturnsNull() + { + // Act + var result = repository.GetHelpForCommand("NonExistentCommand"); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void GetHelpForCommand_WithModule_ReturnsCorrectEntity() + { + // Arrange + var entity1 = new HelpEntity + { + CommandName = "Test-Command", + ModuleName = "Module1", + Synopsis = "Test command from Module1" + }; + var entity2 = new HelpEntity + { + CommandName = "Test-Command", + ModuleName = "Module2", + Synopsis = "Test command from Module2" + }; + repository.AddHelpEntity(entity1); + repository.AddHelpEntity(entity2); + + // Act + var result1 = repository.GetHelpForCommand("Test-Command", "Module1"); + var result2 = repository.GetHelpForCommand("Test-Command", "Module2"); + + // Assert + Assert.IsNotNull(result1); + Assert.AreEqual("Module1", result1.ModuleName); + Assert.IsNotNull(result2); + Assert.AreEqual("Module2", result2.ModuleName); + } + + [Test] + public void GetHelpForCommandRange_WithMatchingPrefix_ReturnsMultipleEntities() + { + // Arrange + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" }); + repository.AddHelpEntity(new HelpEntity { CommandName = "Set-Service" }); + + // Act + var result = repository.GetHelpForCommandRange("Get-"); + + // Assert + Assert.AreEqual(2, result.Count); + Assert.IsTrue(result.Any(e => e.CommandName == "Get-Process")); + Assert.IsTrue(result.Any(e => e.CommandName == "Get-Service")); + } + + [Test] + public void GetHelpForCommandRange_WithNoMatches_ReturnsEmptyList() + { + // Arrange + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); + + // Act + var result = repository.GetHelpForCommandRange("Set-"); + + // Assert + Assert.AreEqual(0, result.Count); + } + + [Test] + public void Clear_RemovesAllEntities() + { + // Arrange + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Process" }); + repository.AddHelpEntity(new HelpEntity { CommandName = "Get-Service" }); + + // Act + repository.Clear(); + var result = repository.GetHelpForCommand("Get-Process"); + + // Assert + Assert.IsNull(result); + } + + [Test] + public void GetHelpForCommand_IsCaseInsensitive() + { + // Arrange + var entity = new HelpEntity { CommandName = "Get-Process" }; + repository.AddHelpEntity(entity); + + // Act + var result1 = repository.GetHelpForCommand("GET-PROCESS"); + var result2 = repository.GetHelpForCommand("get-process"); + var result3 = repository.GetHelpForCommand("Get-Process"); + + // Assert + Assert.IsNotNull(result1); + Assert.IsNotNull(result2); + Assert.IsNotNull(result3); + Assert.AreEqual(result1.CommandName, result2.CommandName); + Assert.AreEqual(result2.CommandName, result3.CommandName); + } + } +} diff --git a/explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs b/explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs new file mode 100644 index 0000000..4d5b657 --- /dev/null +++ b/explainpowershell.analysisservice.tests/tests/SyntaxAnalyzerFunction.tests.cs @@ -0,0 +1,169 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; +using explainpowershell.models; +using ExplainPowershell.SyntaxAnalyzer; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Azure.Functions.Worker.Http; +using Microsoft.Extensions.Logging; +using NUnit.Framework; + +namespace ExplainPowershell.SyntaxAnalyzer.Tests +{ + [TestFixture] + public class SyntaxAnalyzerFunctionTests + { + [Test] + public async Task Run_DoesNotRequireAzureWebJobsStorage_WhenHelpRepositoryInjected() + { + Environment.SetEnvironmentVariable("AzureWebJobsStorage", null); + + var loggerFactory = LoggerFactory.Create(_ => { }); + var logger = loggerFactory.CreateLogger(); + + var helpRepository = new InMemoryHelpRepository(); + var function = new SyntaxAnalyzerFunction(logger, helpRepository); + + var request = new Code { PowershellCode = "Get-Process" }; + var bodyJson = JsonSerializer.Serialize(request); + + var req = new TestHttpRequestData(bodyJson); + var resp = await function.Run(req); + + Assert.That(resp.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + + resp.Body.Position = 0; + using var reader = new StreamReader(resp.Body, Encoding.UTF8); + var responseJson = await reader.ReadToEndAsync(); + + var analysisResult = JsonSerializer.Deserialize(responseJson, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + + Assert.That(analysisResult, Is.Not.Null); + Assert.That(analysisResult!.ExpandedCode, Is.Not.Empty); + } + + private sealed class TestHttpRequestData : HttpRequestData + { + private readonly MemoryStream body; + private readonly HttpHeadersCollection headers = new(); + private readonly Uri url; + + public TestHttpRequestData(string bodyJson) + : base(new TestFunctionContext()) + { + body = new MemoryStream(Encoding.UTF8.GetBytes(bodyJson)); + url = new Uri("http://127.0.0.1/api/SyntaxAnalyzer"); + } + + public override Stream Body => body; + + public override HttpHeadersCollection Headers => headers; + + public override IReadOnlyCollection Cookies => Array.Empty(); + + public override Uri Url => url; + + public override IEnumerable Identities => Array.Empty(); + + public override string Method => "POST"; + + public override HttpResponseData CreateResponse() + { + return new TestHttpResponseData(FunctionContext); + } + } + + private sealed class TestHttpResponseData : HttpResponseData + { + public TestHttpResponseData(FunctionContext functionContext) + : base(functionContext) + { + Headers = new HttpHeadersCollection(); + Body = new MemoryStream(); + Cookies = new TestHttpCookies(); + } + + public override HttpStatusCode StatusCode { get; set; } + + public override HttpHeadersCollection Headers { get; set; } + + public override Stream Body { get; set; } + + public override HttpCookies Cookies { get; } + } + + private sealed class TestHttpCookies : HttpCookies + { + private readonly Dictionary cookies = new(StringComparer.OrdinalIgnoreCase); + + public override void Append(IHttpCookie cookie) + { + cookies[cookie.Name] = cookie; + } + + public override void Append(string name, string value) + { + cookies[name] = new TestHttpCookie { Name = name, Value = value }; + } + + public override IHttpCookie CreateNew() + { + return new TestHttpCookie(); + } + + private sealed class TestHttpCookie : IHttpCookie + { + public string Name { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string? Domain { get; set; } + public string? Path { get; set; } + public DateTimeOffset? Expires { get; set; } + public bool? Secure { get; set; } + public bool? HttpOnly { get; set; } + public SameSite SameSite { get; set; } + public double? MaxAge { get; set; } + } + } + + private sealed class TestFunctionContext : FunctionContext + { + private IDictionary items = new Dictionary(); + + public override string InvocationId { get; } = Guid.NewGuid().ToString("n"); + + public override string FunctionId { get; } = "TestFunction"; + + public override TraceContext TraceContext => throw new NotImplementedException(); + + public override BindingContext BindingContext => throw new NotImplementedException(); + + public override RetryContext RetryContext => null!; + + public override IServiceProvider InstanceServices + { + get => throw new NotImplementedException(); + set => throw new NotImplementedException(); + } + + public override FunctionDefinition FunctionDefinition => throw new NotImplementedException(); + + public override IDictionary Items + { + get => items; + set => items = value; + } + + public override IInvocationFeatures Features => throw new NotImplementedException(); + } + } +} diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs index 5458d86..95404e4 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_classes.cs @@ -94,12 +94,12 @@ public override AstVisitAction VisitFunctionMember(FunctionMemberAst functionMem var howManyParameters = functionMemberAst.Parameters.Count == 0 ? string.Empty : $"has {functionMemberAst.Parameters.Count} parameters and "; - description = $"A constructor, a special method, used to set things up within the object. Constructors have the same name as the class. This constructor {howManyParameters}is called when [{(functionMemberAst.Parent as TypeDefinitionAst).Name}]::new({parameterSignature}) is used."; - helpResult.DocumentationLink += "#constructor"; + description = $"A constructor, a special method, used to set things up within the object. Constructors have the same name as the class. This constructor {howManyParameters}is called when [{(functionMemberAst.Parent as TypeDefinitionAst)?.Name ?? "Unknown"}]::new({parameterSignature}) is used."; + helpResult?.DocumentationLink += "#constructor"; } else { - helpResult.DocumentationLink += "#class-methods"; + helpResult?.DocumentationLink += "#class-methods"; var modifier = "M"; modifier = functionMemberAst.IsHidden ? "A hidden m" : modifier; modifier = functionMemberAst.IsStatic ? "A static m" : modifier; @@ -119,23 +119,27 @@ public override AstVisitAction VisitFunctionMember(FunctionMemberAst functionMem public override AstVisitAction VisitPropertyMember(PropertyMemberAst propertyMemberAst) { - HelpEntity helpResult = null; + HelpEntity? helpResult = null; var description = ""; - if ((propertyMemberAst.Parent as TypeDefinitionAst).IsClass) + var parentType = propertyMemberAst.Parent as TypeDefinitionAst; + if (parentType?.IsClass == true) { var attributes = propertyMemberAst.Attributes.Count >= 0 ? $", with attributes '{string.Join(", ", propertyMemberAst.Attributes.Select(p => p.TypeName.Name))}'." : "."; description = $"Property '{propertyMemberAst.Name}' of type '{propertyMemberAst.PropertyType.TypeName.FullName}'{attributes}"; - helpResult = HelpTableQuery("about_classes"); - helpResult.DocumentationLink += "#class-properties"; + helpResult = HelpTableQuery(Constants.AboutTopics.AboutClasses); + if (helpResult != null) + { + helpResult.DocumentationLink += "#class-properties"; + } } - if ((propertyMemberAst.Parent as TypeDefinitionAst).IsEnum) + if (parentType?.IsEnum == true) { description = $"Enum label '{propertyMemberAst.Name}', with value '{propertyMemberAst.InitialValue}'."; - helpResult = HelpTableQuery("about_enum"); + helpResult = HelpTableQuery(Constants.AboutTopics.AboutEnum); } explanations.Add(new Explanation() diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs index 7a2679c..99967f8 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_command.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_command.cs @@ -23,12 +23,12 @@ public override AstVisitAction VisitCommand(CommandAst commandAst) string resolvedCmd = Helpers.ResolveAlias(cmdName) ?? cmdName; - HelpEntity helpResult; + HelpEntity? helpResult; if (string.IsNullOrEmpty(moduleName)) { var helpResults = HelpTableQueryRange(resolvedCmd); helpResult = helpResults?.FirstOrDefault(); - if (helpResults.Count > 1) + if (helpResults?.Count > 1) { this.errorMessage = $"The command '{helpResult?.CommandName}' is present in more than one module: '{string.Join("', '", helpResults.Select(r => r.ModuleName))}'. Explicitly prepend the module name to the command to select one: '{helpResults.First().ModuleName}\\{helpResult?.CommandName}'"; } @@ -131,12 +131,12 @@ public override AstVisitAction VisitCommandParameter(CommandParameterAst command var parentCommandExplanation = explanations.FirstOrDefault(e => e.Id == exp.ParentId); - ParameterData matchedParameter; - if (parentCommandExplanation.HelpResult?.Parameters != null) + ParameterData? matchedParameter = null; + if (parentCommandExplanation?.HelpResult?.Parameters != null) { try { - matchedParameter = Helpers.MatchParam(commandParameterAst.ParameterName, parentCommandExplanation.HelpResult?.Parameters); + matchedParameter = Helpers.MatchParam(commandParameterAst.ParameterName, parentCommandExplanation.HelpResult.Parameters); if (matchedParameter != null) { @@ -180,7 +180,7 @@ public override AstVisitAction VisitCommandParameter(CommandParameterAst command .Append("__AllParameterSets") .ToArray(); - var paramSetData = Helpers.GetParameterSetData(matchedParameter, availableParamSets); + var paramSetData = Helpers.GetParameterSetData(matchedParameter, availableParamSets ?? Array.Empty()); if (paramSetData.Count > 1) { @@ -191,7 +191,7 @@ public override AstVisitAction VisitCommandParameter(CommandParameterAst command var paramSetName = paramSetData.Select(p => p.ParameterSetName).FirstOrDefault(); if (paramSetName == "__AllParameterSets") { - if (availableParamSets.Length > 1) + if (availableParamSets?.Length > 1) { exp.Description += $"\nThis parameter is present in all parameter sets."; } diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs index 4dbbac7..967d7a6 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_expressions.cs @@ -12,7 +12,7 @@ public partial class AstVisitorExplainer : AstVisitor2 public override AstVisitAction VisitArrayExpression(ArrayExpressionAst arrayExpressionAst) { var helpResult = HelpTableQuery("about_arrays"); - helpResult.DocumentationLink += "#the-array-sub-expression-operator"; + helpResult?.DocumentationLink += "#the-array-sub-expression-operator"; explanations.Add( new Explanation() @@ -209,21 +209,21 @@ public override AstVisitAction VisitTypeExpression(TypeExpressionAst typeExpress typeExpressionAst.Parent is CommandExpressionAst || typeExpressionAst.Parent is AssignmentStatementAst) { - HelpEntity help = null; + HelpEntity? help = null; var description = string.Empty; if (typeExpressionAst.TypeName.IsArray) { description = $"Array of '{typeExpressionAst.TypeName.Name}'"; help = new HelpEntity() { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec/chapter-04" + DocumentationLink = Constants.Documentation.Chapter04TypeSystem }; } else if (typeExpressionAst.TypeName.IsGeneric) { description = $"Generic type"; help = new HelpEntity() { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec/chapter-04#44-generic-types" + DocumentationLink = Constants.Documentation.Chapter04GenericTypes }; } @@ -302,7 +302,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var } } - if (varName == "_" | string.Equals(varName, "PSItem", StringComparison.OrdinalIgnoreCase)) + if (varName == "_" || string.Equals(varName, "PSItem", StringComparison.OrdinalIgnoreCase)) { suffix = ", a built-in variable that holds the current element from the objects being passed in from the pipeline."; explanation.CommandName = "Pipeline iterator variable"; @@ -330,7 +330,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var varName = split.LastOrDefault(); standard = $"named '{varName}'"; - if (variableExpressionAst.VariablePath.IsGlobal | variableExpressionAst.VariablePath.IsScript) + if (variableExpressionAst.VariablePath.IsGlobal || variableExpressionAst.VariablePath.IsScript) { suffix = $" in '{identifier}' scope "; explanation.CommandName = "Scoped variable"; @@ -361,7 +361,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var suffix = ", with the 'using' scope modifier: a local variable used in a remote scope."; explanation.HelpResult = HelpTableQuery("about_Remote_Variables"); explanation.CommandName = "Scoped variable"; - explanation.HelpResult.RelatedLinks += HelpTableQuery("about_Scopes")?.DocumentationLink; + explanation.HelpResult?.RelatedLinks += HelpTableQuery("about_Scopes")?.DocumentationLink; } explanation.Description = $"A{prefix}variable {standard}{suffix}"; @@ -374,7 +374,7 @@ public override AstVisitAction VisitVariableExpression(VariableExpressionAst var public override AstVisitAction VisitTernaryExpression(TernaryExpressionAst ternaryExpressionAst) { var helpResult = HelpTableQuery("about_if"); - helpResult.DocumentationLink += "#using-the-ternary-operator-syntax"; + helpResult?.DocumentationLink += "#using-the-ternary-operator-syntax"; explanations.Add(new Explanation() { diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs index 7515034..243b48d 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_general.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_general.cs @@ -22,7 +22,7 @@ public override AstVisitAction VisitAttribute(AttributeAst attributeAst) new Explanation() { CommandName = "CmdletBinding Attribute", - HelpResult = HelpTableQuery("about_Functions_CmdletBindingAttribute"), + HelpResult = HelpTableQuery(Constants.AboutTopics.AboutFunctionsCmdletBindingAttribute), Description = "The CmdletBinding attribute adds common parameters to your script or function, among other things.", }.AddDefaults(attributeAst, explanations)); @@ -46,7 +46,7 @@ public override AstVisitAction VisitFileRedirection(FileRedirectionAst redirecti { Description = $"{redirectsOrAppends} output {fromStream}to location '{redirectionAst.Location}'.", CommandName = "File redirection operator", - HelpResult = HelpTableQuery("about_redirection"), + HelpResult = HelpTableQuery(Constants.AboutTopics.AboutRedirection), TextToHighlight = ">" }.AddDefaults(redirectionAst, explanations)); @@ -70,7 +70,7 @@ public override AstVisitAction VisitHashtable(HashtableAst hashtableAst) { Description = $"An object that holds key-value pairs, optimized for hash-searching for keys.{keysString}", CommandName = "Hash table", - HelpResult = HelpTableQuery("about_hash_tables"), + HelpResult = HelpTableQuery(Constants.AboutTopics.AboutHashTables), TextToHighlight = "@{" }.AddDefaults(hashtableAst, explanations)); @@ -147,7 +147,7 @@ public override AstVisitAction VisitScriptBlock(ScriptBlockAst scriptBlockAst) public override AstVisitAction VisitStatementBlock(StatementBlockAst statementBlockAst) { - if (statementBlockAst.Parent is TryStatementAst & + if (statementBlockAst.Parent is TryStatementAst && // Ugly hack. Finally block is undistinguisable from the Try block, except for textual position. statementBlockAst.Extent.StartColumnNumber > statementBlockAst.Parent.Extent.StartColumnNumber + 5) { @@ -175,14 +175,14 @@ public override AstVisitAction VisitTypeConstraint(TypeConstraintAst typeConstra var typeName = typeConstraintAst.TypeName.Name; var accelerator = "."; var cmdName = "Type constraint"; - HelpEntity help = null; + HelpEntity? help = null; var (acceleratorName, acceleratorFullTypeName) = Helpers.ResolveAccelerator(typeName); if (acceleratorName != null) { typeName = acceleratorName; accelerator = $", which is a type accelerator for '{acceleratorFullTypeName}'"; - help = HelpTableQuery("about_type_accelerators"); + help = HelpTableQuery(Constants.AboutTopics.AboutTypeAccelerators); cmdName = "Type accelerator"; } else if (typeConstraintAst.Parent is ConvertExpressionAst) diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs index a11b821..5cc669c 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_helpers.cs @@ -7,23 +7,24 @@ using explainpowershell.models; using explainpowershell.SyntaxAnalyzer.ExtensionMethods; -using Azure.Data.Tables; +using ExplainPowershell.SyntaxAnalyzer.Repositories; using Microsoft.Extensions.Logging; namespace ExplainPowershell.SyntaxAnalyzer { public partial class AstVisitorExplainer : AstVisitor2 { - private const char filterChar = '!'; - private const char separatorChar = ' '; - private const string PartitionKey = "CommandHelp"; + private const char filterChar = Constants.TableStorage.RangeFilterChar; + private const char separatorChar = Constants.TableStorage.CommandModuleSeparator; + private const string PartitionKey = Constants.TableStorage.CommandHelpPartitionKey; private readonly List explanations = new(); - private string errorMessage; + private string errorMessage = string.Empty; private string extent; private int offSet = 0; - private readonly TableClient tableClient; + private readonly IHelpRepository helpRepository; private readonly ILogger log; - private readonly Token[] tokens; + private readonly Token[]? tokens; + private readonly Dictionary unhandledAstTypeCounts = new(StringComparer.OrdinalIgnoreCase); public AnalysisResult GetAnalysisResult() { @@ -31,6 +32,31 @@ public AnalysisResult GetAnalysisResult() ExplainSemiColons(); + if (unhandledAstTypeCounts.Count > 0) + { + var totalUnhandled = unhandledAstTypeCounts.Values.Sum(); + var ordered = unhandledAstTypeCounts + .OrderByDescending(kvp => kvp.Value) + .ThenBy(kvp => kvp.Key) + .ToList(); + + const int maxTypesToLog = 10; + var topTypes = string.Join(", ", + ordered + .Take(maxTypesToLog) + .Select(kvp => $"{kvp.Key}({kvp.Value})")); + + var extraTypes = ordered.Count > maxTypesToLog + ? $" (+{ordered.Count - maxTypesToLog} more types)" + : string.Empty; + + log.LogInformation( + "Unhandled AST nodes encountered: {UnhandledCount}. Types: {UnhandledTypes}{ExtraTypes}", + totalUnhandled, + topTypes, + extraTypes); + } + foreach (var exp in explanations) { if (exp.HelpResult == null) @@ -70,7 +96,7 @@ private void ExplainSemiColons() var (description, _) = Helpers.TokenExplainer(TokenKind.Semi); var help = new HelpEntity { - DocumentationLink = "https://docs.microsoft.com/en-us/powershell/scripting/lang-spec/chapter-08#82-pipeline-statements" + DocumentationLink = Constants.Documentation.Chapter08PipelineStatements }; explanations.Add( @@ -84,9 +110,9 @@ private void ExplainSemiColons() } } - public AstVisitorExplainer(string extentText, TableClient client, ILogger log, Token[] tokens) + public AstVisitorExplainer(string extentText, IHelpRepository helpRepository, ILogger log, Token[]? tokens) { - tableClient = client; + this.helpRepository = helpRepository ?? throw new ArgumentNullException(nameof(helpRepository)); this.log = log; extent = extentText; this.tokens = tokens; @@ -100,36 +126,19 @@ private static bool HasSpecialVars(string varName) return false; } - private HelpEntity HelpTableQuery(string resolvedCmd) + private HelpEntity? HelpTableQuery(string resolvedCmd) { - string filter = TableServiceClient.CreateQueryFilter($"PartitionKey eq {PartitionKey} and RowKey eq {resolvedCmd.ToLower()}"); - var entities = tableClient.Query(filter: filter); - var helpResult = entities.FirstOrDefault(); - return helpResult; + return helpRepository.GetHelpForCommand(resolvedCmd); } - private HelpEntity HelpTableQuery(string resolvedCmd, string moduleName) + private HelpEntity? HelpTableQuery(string resolvedCmd, string moduleName) { - var rowKey = $"{resolvedCmd.ToLower()}{separatorChar}{moduleName.ToLower()}"; - return HelpTableQuery(rowKey); + return helpRepository.GetHelpForCommand(resolvedCmd, moduleName); } private List HelpTableQueryRange(string resolvedCmd) { - if (string.IsNullOrEmpty(resolvedCmd)) - { - return new List { new HelpEntity() }; - } - - // Getting a range from Azure Table storage works based on ascii char filtering. You can match prefixes. I use a space ' ' (char)32 as a divider - // between the name of a command and the name of its module for commands that appear in more than one module. Filtering this way makes sure I - // only match entries with ' '. - // filterChar = (char)33 = '!'. - string rowKeyFilter = $"{resolvedCmd.ToLower()}{filterChar}"; - string filter = TableServiceClient.CreateQueryFilter( - $"PartitionKey eq {PartitionKey} and RowKey ge {resolvedCmd.ToLower()} and RowKey lt {rowKeyFilter}"); - var entities = tableClient.Query(filter: filter); - return entities.ToList(); + return helpRepository.GetHelpForCommandRange(resolvedCmd); } private void ExpandAliasesInExtent(CommandAst cmd, string resolvedCmd) @@ -163,7 +172,8 @@ private void AstExplainer(Ast ast) CommandName = splitAstType }.AddDefaults(ast, explanations)); - log.LogWarning($"Unhandled ast: {splitAstType}"); + unhandledAstTypeCounts.TryGetValue(splitAstType, out var current); + unhandledAstTypeCounts[splitAstType] = current + 1; } public static List GetApprovedVerbs() diff --git a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs index db2d074..75a2bcb 100644 --- a/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs +++ b/explainpowershell.analysisservice/AstVisitorExplainer_statements.cs @@ -136,7 +136,7 @@ public override AstVisitAction VisitExitStatement(ExitStatementAst exitStatement $", with an exit code of '{exitStatementAst.Pipeline.Extent.Text}'."; var helpResult = HelpTableQuery("about_language_keywords"); - helpResult.DocumentationLink += "#exit"; + helpResult?.DocumentationLink += "#exit"; explanations.Add( new Explanation() @@ -191,29 +191,158 @@ public override AstVisitAction VisitIfStatement(IfStatementAst ifStmtAst) public override AstVisitAction VisitReturnStatement(ReturnStatementAst returnStatementAst) { - // TODO: add return statement explanation - AstExplainer(returnStatementAst); + var returnedValue = string.IsNullOrEmpty(returnStatementAst.Pipeline?.Extent?.Text) + ? string.Empty + : $" returning '{returnStatementAst.Pipeline.Extent.Text}'."; + + var helpResult = HelpTableQuery("about_Return") + ?? new HelpEntity + { + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Return"; + } + + var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink + ?? "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#return"; + + if (string.IsNullOrEmpty(helpResult.RelatedLinks)) + { + helpResult.RelatedLinks = languageKeywordsLink; + } + else if (!helpResult.RelatedLinks.Contains(languageKeywordsLink, StringComparison.OrdinalIgnoreCase)) + { + helpResult.RelatedLinks += ", " + languageKeywordsLink; + } + + explanations.Add( + new Explanation() + { + CommandName = "return statement", + HelpResult = helpResult, + Description = $"Returns from the current scope{returnedValue}", + TextToHighlight = "return" + }.AddDefaults(returnStatementAst, explanations)); + return base.VisitReturnStatement(returnStatementAst); } public override AstVisitAction VisitSwitchStatement(SwitchStatementAst switchStatementAst) { - // TODO: add switch statement explanation - AstExplainer(switchStatementAst); + var flags = switchStatementAst.Flags; + + var flagText = flags == SwitchFlags.None + ? string.Empty + : $" using flags: {flags}."; + + var inputText = string.IsNullOrEmpty(switchStatementAst.Condition?.Extent?.Text) + ? "" + : $" over '{switchStatementAst.Condition.Extent.Text}'"; + + var helpResult = HelpTableQuery("about_Switch") + ?? new HelpEntity + { + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Switch" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Switch"; + } + + var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink + ?? "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#switch"; + + if (string.IsNullOrEmpty(helpResult.RelatedLinks)) + { + helpResult.RelatedLinks = languageKeywordsLink; + } + else if (!helpResult.RelatedLinks.Contains(languageKeywordsLink, StringComparison.OrdinalIgnoreCase)) + { + helpResult.RelatedLinks += ", " + languageKeywordsLink; + } + + explanations.Add( + new Explanation() + { + CommandName = "switch statement", + HelpResult = helpResult, + Description = $"Evaluates input{inputText} and runs the first matching clause.{flagText}", + TextToHighlight = "switch" + }.AddDefaults(switchStatementAst, explanations)); + return base.VisitSwitchStatement(switchStatementAst); } public override AstVisitAction VisitThrowStatement(ThrowStatementAst throwStatementAst) { - // TODO: add throw statement explanation - AstExplainer(throwStatementAst); + var thrownValue = string.IsNullOrEmpty(throwStatementAst.Pipeline?.Extent?.Text) + ? string.Empty + : $" with value '{throwStatementAst.Pipeline.Extent.Text}'."; + + var helpResult = HelpTableQuery("about_Throw") + ?? new HelpEntity + { + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Throw"; + } + + var languageKeywordsLink = (HelpTableQuery("about_language_keywords")?.DocumentationLink + ?? "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_language_keywords") + "#throw"; + + if (string.IsNullOrEmpty(helpResult.RelatedLinks)) + { + helpResult.RelatedLinks = languageKeywordsLink; + } + else if (!helpResult.RelatedLinks.Contains(languageKeywordsLink, StringComparison.OrdinalIgnoreCase)) + { + helpResult.RelatedLinks += ", " + languageKeywordsLink; + } + + explanations.Add( + new Explanation() + { + CommandName = "throw statement", + HelpResult = helpResult, + Description = $"Throws a terminating error (exception){thrownValue}", + TextToHighlight = "throw" + }.AddDefaults(throwStatementAst, explanations)); + return base.VisitThrowStatement(throwStatementAst); } public override AstVisitAction VisitTrap(TrapStatementAst trapStatementAst) { - // TODO: add trap explanation - AstExplainer(trapStatementAst); + var trapTypeText = trapStatementAst.TrapType == null + ? "any error" + : $"errors of type '{trapStatementAst.TrapType.TypeName.Name}'"; + + var helpResult = HelpTableQuery("about_trap") + ?? new HelpEntity + { + DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap" + }; + + if (string.IsNullOrEmpty(helpResult.DocumentationLink)) + { + helpResult.DocumentationLink = "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_Trap"; + } + + explanations.Add(new Explanation() + { + CommandName = "trap statement", + HelpResult = helpResult, + Description = $"Defines a trap handler that runs when {trapTypeText} occurs in the current scope.", + TextToHighlight = "trap" + }.AddDefaults(trapStatementAst, explanations)); + return base.VisitTrap(trapStatementAst); } diff --git a/explainpowershell.analysisservice/Constants.cs b/explainpowershell.analysisservice/Constants.cs new file mode 100644 index 0000000..d695a79 --- /dev/null +++ b/explainpowershell.analysisservice/Constants.cs @@ -0,0 +1,60 @@ +namespace ExplainPowershell.SyntaxAnalyzer +{ + /// + /// Application-wide constants + /// + public static class Constants + { + /// + /// Azure Table Storage constants + /// + public static class TableStorage + { + /// + /// The partition key used for command help entries in Azure Table Storage + /// + public const string CommandHelpPartitionKey = "CommandHelp"; + + /// + /// The default table name for help data + /// + public const string HelpDataTableName = "HelpData"; + + /// + /// Character used for filtering in range queries (ASCII 33 = '!') + /// + public const char RangeFilterChar = '!'; + + /// + /// Separator character used between command name and module name (ASCII 32 = ' ') + /// + public const char CommandModuleSeparator = ' '; + } + + /// + /// PowerShell documentation link constants + /// + public static class Documentation + { + public const string MicrosoftDocsBase = "https://learn.microsoft.com/en-us/powershell/scripting/lang-spec"; + public const string Chapter04TypeSystem = MicrosoftDocsBase + "/chapter-04"; + public const string Chapter04GenericTypes = Chapter04TypeSystem + "#44-generic-types"; + public const string Chapter08PipelineStatements = MicrosoftDocsBase + "/chapter-08#82-pipeline-statements"; + } + + /// + /// PowerShell about topics + /// + public static class AboutTopics + { + public const string AboutClasses = "about_classes"; + public const string AboutEnum = "about_enum"; + public const string AboutFunctions = "about_functions"; + public const string AboutFunctionsCmdletBindingAttribute = "about_Functions_CmdletBindingAttribute"; + public const string AboutHashTables = "about_hash_tables"; + public const string AboutOperators = "about_operators"; + public const string AboutRedirection = "about_redirection"; + public const string AboutTypeAccelerators = "about_type_accelerators"; + } + } +} diff --git a/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs b/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs index 84f5186..6c1e5c4 100644 --- a/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs +++ b/explainpowershell.analysisservice/ExtensionMethods/SyntaxAnalyzerExtensions.cs @@ -32,40 +32,42 @@ private static string GenerateId(this Token token) return $"{token.Extent.StartLineNumber}.{token.Extent.StartOffset}.{token.Extent.EndOffset}.{token.Kind}"; } - public static string TryFindParentExplanation(Ast ast, List explanations, int level = 0) + public static string? TryFindParentExplanation(Ast ast, List explanations, int level = 0) { - if (explanations.Count == 0 | ast.Parent == null) + if (explanations.Count == 0 || ast.Parent == null) return null; var parentId = ast.Parent.GenerateId(); - if ((!explanations.Any(e => e.Id == parentId)) & level < 100) + if ((!explanations.Any(e => e.Id == parentId)) && level < 100) { return TryFindParentExplanation(ast.Parent, explanations, ++level); } if (level >= 99) - return string.Empty; + return null; return parentId; } - public static string TryFindParentExplanation(Token token, List explanations) + public static string? TryFindParentExplanation(Token token, List explanations) { var start = token.Extent.StartOffset; var explanationsBeforeToken = explanations.Where(e => GetEndOffSet(e) <= start); if (!explanationsBeforeToken.Any()) { - return string.Empty; + return null; } var closestNeigbour = explanationsBeforeToken.Max(e => GetEndOffSet(e)); - return explanationsBeforeToken.FirstOrDefault(t => GetEndOffSet(t) == closestNeigbour).Id; + return explanationsBeforeToken.FirstOrDefault(t => GetEndOffSet(t) == closestNeigbour)?.Id; } private static int GetEndOffSet(Explanation e) { + if (e.Id == null) + return -1; return int.Parse(e.Id.Split('.')[2]); } } diff --git a/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs b/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs index c057db2..0c4cb98 100644 --- a/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs +++ b/explainpowershell.analysisservice/Helpers/GetParameterSetData.cs @@ -21,9 +21,9 @@ public static List GetParameterSetData(ParameterData paramData new ParameterSetData() { ParameterSetName = paramSet, - HelpMessage = foundParamSet.GetProperty("HelpMessage").GetString(), - HelpMessageBaseName = foundParamSet.GetProperty("HelpMessageBaseName").GetString(), - HelpMessageResourceId = foundParamSet.GetProperty("HelpMessageResourceId").GetString(), + HelpMessage = foundParamSet.GetProperty("HelpMessage").GetString() ?? string.Empty, + HelpMessageBaseName = foundParamSet.GetProperty("HelpMessageBaseName").GetString() ?? string.Empty, + HelpMessageResourceId = foundParamSet.GetProperty("HelpMessageResourceId").GetString() ?? string.Empty, IsMandatory = foundParamSet.GetProperty("IsMandatory").GetBoolean(), Position = foundParamSet.GetProperty("Position").GetInt32(), ValueFromPipeline = foundParamSet.GetProperty("ValueFromPipeline").GetBoolean(), diff --git a/explainpowershell.analysisservice/Helpers/MatchParam.cs b/explainpowershell.analysisservice/Helpers/MatchParam.cs index 024846f..d96d327 100644 --- a/explainpowershell.analysisservice/Helpers/MatchParam.cs +++ b/explainpowershell.analysisservice/Helpers/MatchParam.cs @@ -10,9 +10,9 @@ namespace ExplainPowershell.SyntaxAnalyzer { public static partial class Helpers { - public static ParameterData MatchParam(string foundParameter, string json) + public static ParameterData? MatchParam(string foundParameter, string json) { - List doc; + List? doc; List matchedParam = new(); try { @@ -31,11 +31,11 @@ public static ParameterData MatchParam(string foundParameter, string json) if (!string.Equals(foundParameter, "none", StringComparison.OrdinalIgnoreCase)) { matchedParam = doc.Where( - p => p.Aliases.Split(", ") + p => (p.Aliases?.Split(", ") .All( q => q.StartsWith( foundParameter, - StringComparison.InvariantCultureIgnoreCase))).ToList(); + StringComparison.InvariantCultureIgnoreCase))) ?? false).ToList(); } if (matchedParam.Count == 0) @@ -43,14 +43,14 @@ public static ParameterData MatchParam(string foundParameter, string json) // If no aliases match, then try partial parameter names for static params (aliases and static params take precedence) matchedParam = doc.Where( p => ! (p.IsDynamic ?? false) && - p.Name.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase)).ToList(); + (p.Name?.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase) ?? false)).ToList(); } if (matchedParam.Count == 0) { // If no aliases or static params match, then try partial parameter names for dynamic params too. matchedParam = doc.Where( - p => p.Name.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase)).ToList(); + p => p.Name?.StartsWith(foundParameter, StringComparison.OrdinalIgnoreCase) ?? false).ToList(); } if (matchedParam.Count == 0) diff --git a/explainpowershell.analysisservice/MetaData.cs b/explainpowershell.analysisservice/MetaData.cs index 015220e..1759844 100644 --- a/explainpowershell.analysisservice/MetaData.cs +++ b/explainpowershell.analysisservice/MetaData.cs @@ -66,7 +66,7 @@ public static HelpMetaData CalculateMetaData(TableClient client, ILogger log) var entities = client.Query(filter: filter, select: select).ToList(); var numAbout = entities - .Count(r => r.CommandName.StartsWith("about_", StringComparison.OrdinalIgnoreCase)); + .Count(r => r.CommandName?.StartsWith("about_", StringComparison.OrdinalIgnoreCase) ?? false); var moduleNames = entities .Select(r => r.ModuleName) diff --git a/explainpowershell.analysisservice/Program.cs b/explainpowershell.analysisservice/Program.cs index d50d724..de16068 100644 --- a/explainpowershell.analysisservice/Program.cs +++ b/explainpowershell.analysisservice/Program.cs @@ -1,4 +1,8 @@ +using Azure.Data.Tables; +using explainpowershell.analysisservice; using explainpowershell.analysisservice.Services; +using ExplainPowershell.SyntaxAnalyzer; +using ExplainPowershell.SyntaxAnalyzer.Repositories; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -24,9 +28,12 @@ { services.AddLogging(); services.Configure(context.Configuration.GetSection(AiExplanationOptions.SectionName)); + + services.AddSingleton(sp => TableClientFactory.Create(Constants.TableStorage.HelpDataTableName)); + services.AddSingleton(sp => new TableStorageHelpRepository(sp.GetRequiredService())); // Register ChatClient factory - services.AddSingleton(sp => + services.AddSingleton(sp => { var options = sp.GetRequiredService>().Value; var logger = sp.GetRequiredService>(); @@ -39,7 +46,7 @@ if (!isConfigured) { logger.LogWarning("AI explanation ChatClient not configured. AI features will be disabled."); - return null; + return null!; } logger.LogInformation( diff --git a/explainpowershell.analysisservice/Repositories/IHelpRepository.cs b/explainpowershell.analysisservice/Repositories/IHelpRepository.cs new file mode 100644 index 0000000..b7197a1 --- /dev/null +++ b/explainpowershell.analysisservice/Repositories/IHelpRepository.cs @@ -0,0 +1,32 @@ +using explainpowershell.models; + +namespace ExplainPowershell.SyntaxAnalyzer.Repositories +{ + /// + /// Repository interface for accessing PowerShell help data + /// + public interface IHelpRepository + { + /// + /// Query help data for a specific command + /// + /// The command name to query + /// The help entity if found, null otherwise + HelpEntity? GetHelpForCommand(string commandName); + + /// + /// Query help data for a specific command in a specific module + /// + /// The command name to query + /// The module name containing the command + /// The help entity if found, null otherwise + HelpEntity? GetHelpForCommand(string commandName, string moduleName); + + /// + /// Query help data for commands matching a prefix + /// + /// The command name prefix to query + /// List of matching help entities + List GetHelpForCommandRange(string commandName); + } +} diff --git a/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs b/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs new file mode 100644 index 0000000..4eb8ad6 --- /dev/null +++ b/explainpowershell.analysisservice/Repositories/TableStorageHelpRepository.cs @@ -0,0 +1,64 @@ +using Azure.Data.Tables; +using explainpowershell.models; + +namespace ExplainPowershell.SyntaxAnalyzer.Repositories +{ + /// + /// Implementation of IHelpRepository using Azure Table Storage + /// + public class TableStorageHelpRepository : IHelpRepository + { + private readonly TableClient tableClient; + + public TableStorageHelpRepository(TableClient tableClient) + { + this.tableClient = tableClient ?? throw new ArgumentNullException(nameof(tableClient)); + } + + /// + public HelpEntity? GetHelpForCommand(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return null; + } + + string filter = TableServiceClient.CreateQueryFilter( + $"PartitionKey eq {Constants.TableStorage.CommandHelpPartitionKey} and RowKey eq {commandName.ToLower()}"); + var entities = tableClient.Query(filter: filter); + return entities.FirstOrDefault(); + } + + /// + public HelpEntity? GetHelpForCommand(string commandName, string moduleName) + { + if (string.IsNullOrEmpty(commandName) || string.IsNullOrEmpty(moduleName)) + { + return null; + } + + var rowKey = $"{commandName.ToLower()}{Constants.TableStorage.CommandModuleSeparator}{moduleName.ToLower()}"; + return GetHelpForCommand(rowKey); + } + + /// + public List GetHelpForCommandRange(string commandName) + { + if (string.IsNullOrEmpty(commandName)) + { + return new List(); + } + + // Getting a range from Azure Table storage works based on ascii char filtering. You can match prefixes. + // We use a space ' ' (char)32 as a divider between the name of a command and the name of its module + // for commands that appear in more than one module. Filtering this way makes sure we only match + // entries with ' '. + // filterChar = (char)33 = '!'. + string rowKeyFilter = $"{commandName.ToLower()}{Constants.TableStorage.RangeFilterChar}"; + string filter = TableServiceClient.CreateQueryFilter( + $"PartitionKey eq {Constants.TableStorage.CommandHelpPartitionKey} and RowKey ge {commandName.ToLower()} and RowKey lt {rowKeyFilter}"); + var entities = tableClient.Query(filter: filter); + return entities.ToList(); + } + } +} diff --git a/explainpowershell.analysisservice/Services/AiExplanationService.cs b/explainpowershell.analysisservice/Services/AiExplanationService.cs index 24b1721..65205be 100644 --- a/explainpowershell.analysisservice/Services/AiExplanationService.cs +++ b/explainpowershell.analysisservice/Services/AiExplanationService.cs @@ -165,7 +165,7 @@ private static AnalysisResult ReducePayloadSize(AnalysisResult result, int targe ExpandedCode = result.ExpandedCode, ParseErrorMessage = result.ParseErrorMessage, DetectedModules = result.DetectedModules, - Explanations = result.Explanations? + Explanations = result.Explanations .Select(e => new Explanation { Id = e.Id, @@ -197,7 +197,7 @@ private static AnalysisResult ReducePayloadSize(AnalysisResult result, int targe } // Last resort: remove help results entirely, keep only basic explanations - reduced.Explanations = result.Explanations? + reduced.Explanations = result.Explanations .Select(e => new Explanation { Id = e.Id, diff --git a/explainpowershell.analysisservice/SyntaxAnalyzer.cs b/explainpowershell.analysisservice/SyntaxAnalyzer.cs index 8d127fd..56b2def 100644 --- a/explainpowershell.analysisservice/SyntaxAnalyzer.cs +++ b/explainpowershell.analysisservice/SyntaxAnalyzer.cs @@ -2,32 +2,30 @@ using System.Net; using System.Text; using explainpowershell.analysisservice; -using explainpowershell.analysisservice.Services; using explainpowershell.models; +using ExplainPowershell.SyntaxAnalyzer.Repositories; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; -using Newtonsoft.Json; +using System.Text.Json; namespace ExplainPowershell.SyntaxAnalyzer { public sealed class SyntaxAnalyzerFunction { - private const string HelpTableName = "HelpData"; private readonly ILogger logger; - private readonly IAiExplanationService aiExplanationService; + private readonly IHelpRepository helpRepository; - public SyntaxAnalyzerFunction(ILogger logger, IAiExplanationService aiExplanationService) + public SyntaxAnalyzerFunction(ILogger logger, IHelpRepository helpRepository) { this.logger = logger; - this.aiExplanationService = aiExplanationService; + this.helpRepository = helpRepository; } [Function("SyntaxAnalyzer")] public async Task Run( [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequestData req) { - var tableClient = TableClientFactory.Create(HelpTableName); string requestBody; using (var reader = new StreamReader(req.Body)) { @@ -39,9 +37,21 @@ public async Task Run( return CreateResponse(req, HttpStatusCode.BadRequest, "Empty request. Pass powershell code in the request body for an AST analysis."); } - var code = JsonConvert - .DeserializeObject(requestBody) - ?.PowershellCode ?? string.Empty; + Code? request; + try + { + request = JsonSerializer.Deserialize(requestBody, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }); + } + catch (Exception e) + { + logger.LogError(e, "Failed to deserialize SyntaxAnalyzer request"); + return CreateResponse(req, HttpStatusCode.BadRequest, "Invalid request format. Pass powershell code in the request body for an AST analysis."); + } + + var code = request?.PowershellCode ?? string.Empty; logger.LogInformation("PowerShell code sent: {Code}", code); @@ -55,7 +65,7 @@ public async Task Run( AnalysisResult analysisResult; try { - var visitor = new AstVisitorExplainer(ast.Extent.Text, tableClient, logger, tokens); + var visitor = new AstVisitorExplainer(ast.Extent.Text, helpRepository: helpRepository, logger, tokens); ast.Visit(visitor); analysisResult = visitor.GetAnalysisResult(); } diff --git a/explainpowershell.analysisservice/explainpowershell.csproj b/explainpowershell.analysisservice/explainpowershell.csproj index c2eb5ba..029f3cf 100644 --- a/explainpowershell.analysisservice/explainpowershell.csproj +++ b/explainpowershell.analysisservice/explainpowershell.csproj @@ -12,8 +12,7 @@ - - + diff --git a/explainpowershell.frontend.tests/TreeTests.cs b/explainpowershell.frontend.tests/TreeTests.cs new file mode 100644 index 0000000..3e17c10 --- /dev/null +++ b/explainpowershell.frontend.tests/TreeTests.cs @@ -0,0 +1,34 @@ +using explainpowershell.models; + +namespace explainpowershell.frontend.tests; + +public class TreeTests +{ + [Test] + public void GenerateTree_TreatsNullAndEmptyParentIdAsRoot() + { + var explanations = new List + { + new() { Id = "1", ParentId = "", CommandName = "root-empty" }, + new() { Id = "2", ParentId = null, CommandName = "root-null" }, + new() { Id = "1.1", ParentId = "1", CommandName = "child" }, + }; + + var tree = explanations.GenerateTree(e => e.Id, e => e.ParentId); + + Assert.That(tree, Has.Count.EqualTo(2)); + + var rootEmpty = tree.Single(t => t.Value is not null && t.Value.Id == "1"); + Assert.That(rootEmpty.Value, Is.Not.Null); + Assert.That(rootEmpty.Children, Is.Not.Null); + var rootEmptyChildren = rootEmpty.Children!; + Assert.That(rootEmptyChildren, Has.Count.EqualTo(1)); + var onlyChild = rootEmptyChildren.Single(); + Assert.That(onlyChild.Value, Is.Not.Null); + Assert.That(onlyChild.Value!.Id, Is.EqualTo("1.1")); + + var rootNull = tree.Single(t => t.Value is not null && t.Value.Id == "2"); + Assert.That(rootNull.Value, Is.Not.Null); + Assert.That(rootNull.Children, Is.Null); + } +} diff --git a/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj b/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj new file mode 100644 index 0000000..98db677 --- /dev/null +++ b/explainpowershell.frontend.tests/explainpowershell.frontend.tests.csproj @@ -0,0 +1,22 @@ + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/explainpowershell.frontend/App.razor b/explainpowershell.frontend/App.razor index 3a2af53..6f67a6e 100644 --- a/explainpowershell.frontend/App.razor +++ b/explainpowershell.frontend/App.razor @@ -1,4 +1,4 @@ - + diff --git a/explainpowershell.frontend/Clients/AiExplanationResponse.cs b/explainpowershell.frontend/Clients/AiExplanationResponse.cs new file mode 100644 index 0000000..9e9ac8b --- /dev/null +++ b/explainpowershell.frontend/Clients/AiExplanationResponse.cs @@ -0,0 +1,8 @@ +namespace explainpowershell.frontend.Clients; + +public sealed class AiExplanationResponse +{ + public string AiExplanation { get; set; } = string.Empty; + + public string ModelName { get; set; } = string.Empty; +} diff --git a/explainpowershell.frontend/Clients/ApiCallResult.cs b/explainpowershell.frontend/Clients/ApiCallResult.cs new file mode 100644 index 0000000..ab5489a --- /dev/null +++ b/explainpowershell.frontend/Clients/ApiCallResult.cs @@ -0,0 +1,36 @@ +#nullable enable + +using System.Net; + +namespace explainpowershell.frontend.Clients; + +public sealed class ApiCallResult +{ + private ApiCallResult() { } + + public bool IsSuccess { get; private init; } + + public HttpStatusCode StatusCode { get; private init; } + + public T? Value { get; private init; } + + public string? ErrorMessage { get; private init; } + + public static ApiCallResult Success(T value, HttpStatusCode statusCode = HttpStatusCode.OK) + => new() + { + IsSuccess = true, + StatusCode = statusCode, + Value = value, + ErrorMessage = null + }; + + public static ApiCallResult Failure(string? errorMessage, HttpStatusCode statusCode) + => new() + { + IsSuccess = false, + StatusCode = statusCode, + Value = default, + ErrorMessage = errorMessage + }; +} diff --git a/explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs b/explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs new file mode 100644 index 0000000..fe98453 --- /dev/null +++ b/explainpowershell.frontend/Clients/ISyntaxAnalyzerClient.cs @@ -0,0 +1,15 @@ +using System.Threading; +using System.Threading.Tasks; +using explainpowershell.models; + +namespace explainpowershell.frontend.Clients; + +public interface ISyntaxAnalyzerClient +{ + Task> AnalyzeAsync(Code code, CancellationToken cancellationToken = default); + + Task> GetAiExplanationAsync( + Code code, + AnalysisResult analysisResult, + CancellationToken cancellationToken = default); +} diff --git a/explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs b/explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs new file mode 100644 index 0000000..711f4df --- /dev/null +++ b/explainpowershell.frontend/Clients/SyntaxAnalyzerClient.cs @@ -0,0 +1,94 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using explainpowershell.models; + +namespace explainpowershell.frontend.Clients; + +public sealed class SyntaxAnalyzerClient : ISyntaxAnalyzerClient +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient http; + + public SyntaxAnalyzerClient(HttpClient http) + { + this.http = http; + } + + public async Task> AnalyzeAsync(Code code, CancellationToken cancellationToken = default) + { + try + { + using var response = await http.PostAsJsonAsync("SyntaxAnalyzer", code, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + var reason = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return ApiCallResult.Failure(reason, response.StatusCode); + } + + var analysisResult = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); + if (analysisResult is null) + { + return ApiCallResult.Failure("Empty response from SyntaxAnalyzer.", response.StatusCode); + } + + return ApiCallResult.Success(analysisResult, response.StatusCode); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + return ApiCallResult.Failure($"Request failed: {ex.Message}", HttpStatusCode.ServiceUnavailable); + } + } + + public async Task> GetAiExplanationAsync( + Code code, + AnalysisResult analysisResult, + CancellationToken cancellationToken = default) + { + try + { + var aiRequest = new + { + PowershellCode = code.PowershellCode, + AnalysisResult = analysisResult + }; + + using var response = await http.PostAsJsonAsync("AiExplanation", aiRequest, cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + return ApiCallResult.Failure(null, response.StatusCode); + } + + var aiResponse = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken).ConfigureAwait(false); + if (aiResponse is null) + { + return ApiCallResult.Success(new AiExplanationResponse(), response.StatusCode); + } + + return ApiCallResult.Success(aiResponse, response.StatusCode); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + // AI is optional; treat failures as empty response. + return ApiCallResult.Success(new AiExplanationResponse(), HttpStatusCode.OK); + } + } +} diff --git a/explainpowershell.frontend/InternalsVisibleTo.cs b/explainpowershell.frontend/InternalsVisibleTo.cs new file mode 100644 index 0000000..d01b8e8 --- /dev/null +++ b/explainpowershell.frontend/InternalsVisibleTo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("explainpowershell.frontend.tests")] diff --git a/explainpowershell.frontend/Pages/Index.razor.cs b/explainpowershell.frontend/Pages/Index.razor.cs index 1bd2deb..8181b93 100644 --- a/explainpowershell.frontend/Pages/Index.razor.cs +++ b/explainpowershell.frontend/Pages/Index.razor.cs @@ -1,21 +1,20 @@ using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Web; using System.Collections.Generic; -using System.Net.Http; -using System.Text.Json; using System.Threading.Tasks; +using System.Threading; using explainpowershell.models; using System.Linq; -using System.Net.Http.Json; using MudBlazor; using System; +using explainpowershell.frontend.Clients; namespace explainpowershell.frontend.Pages { public partial class Index : ComponentBase { [Inject] - private HttpClient Http { get; set; } + private ISyntaxAnalyzerClient SyntaxAnalyzerClient { get; set; } private string TitleMargin { get; set; }= "mt-16"; private Dictionary SyntaxPopoverIsOpen { get; set; }= new(); private Dictionary CommandDetailsPopoverIsOpen { get; set; } = new(); @@ -29,6 +28,10 @@ public partial class Index : ComponentBase { private List> TreeItems { get; set; } = new(); private bool ShouldShrinkTitle { get; set; } = false; private bool HasNoExplanations => TreeItems.Count == 0; + + private long _activeSearchId; + private CancellationTokenSource _aiExplanationCts; + private bool _disposed; private string InputValue { get { return _inputValue; @@ -56,6 +59,20 @@ protected override Task OnInitializedAsync() return DoSearch(); } + public void Dispose() + { + _disposed = true; + try + { + _aiExplanationCts?.Cancel(); + _aiExplanationCts?.Dispose(); + } + catch + { + // Best effort cleanup. + } + } + private void ToggleSyntaxPopoverIsOpen(string id) { SyntaxPopoverIsOpen[id] = !SyntaxPopoverIsOpen[id]; @@ -75,6 +92,14 @@ private void ShrinkTitle() private async Task DoSearch() { + // New search: cancel any in-flight AI request and advance request id. + Interlocked.Increment(ref _activeSearchId); + _aiExplanationCts?.Cancel(); + _aiExplanationCts?.Dispose(); + _aiExplanationCts = new CancellationTokenSource(); + var searchId = _activeSearchId; + var aiCancellationToken = _aiExplanationCts.Token; + HideExpandedCode = true; Waiting = false; RequestHasError = false; @@ -92,26 +117,16 @@ private async Task DoSearch() Waiting = true; var code = new Code() { PowershellCode = InputValue }; - HttpResponseMessage temp; - try { - temp = await Http.PostAsJsonAsync("SyntaxAnalyzer", code); - } - catch { - RequestHasError = true; - Waiting = false; - ReasonPhrase = "oops!"; - return; - } - - if (!temp.IsSuccessStatusCode) + var analyzeResult = await SyntaxAnalyzerClient.AnalyzeAsync(code); + if (!analyzeResult.IsSuccess || analyzeResult.Value is null) { RequestHasError = true; Waiting = false; - ReasonPhrase = await temp.Content.ReadAsStringAsync(); + ReasonPhrase = string.IsNullOrWhiteSpace(analyzeResult.ErrorMessage) ? "oops!" : analyzeResult.ErrorMessage; return; } - var analysisResult = await JsonSerializer.DeserializeAsync(temp.Content.ReadAsStream()); + var analysisResult = analyzeResult.Value; if (!string.IsNullOrEmpty(analysisResult.ParseErrorMessage)) { @@ -139,37 +154,44 @@ private async Task DoSearch() AiExplanation = null; // Will be loaded separately // Start fetching AI explanation in background - _ = LoadAiExplanationAsync(code, analysisResult); + _ = LoadAiExplanationAsync(code, analysisResult, searchId, aiCancellationToken); } - private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResult) + private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResult, long searchId, CancellationToken cancellationToken) { + // Ignore stale requests. + if (searchId != _activeSearchId || _disposed) + { + return; + } + AiExplanationLoading = true; - StateHasChanged(); + await InvokeAsync(StateHasChanged); try { - var aiRequest = new - { - PowershellCode = code.PowershellCode, - AnalysisResult = analysisResult - }; + var aiResult = await SyntaxAnalyzerClient.GetAiExplanationAsync(code, analysisResult, cancellationToken); - var response = await Http.PostAsJsonAsync("AiExplanation", aiRequest); + if (searchId != _activeSearchId || _disposed) + { + return; + } - if (response.IsSuccessStatusCode) + if (aiResult.IsSuccess && aiResult.Value is not null) { - var aiResult = await JsonSerializer.DeserializeAsync(response.Content.ReadAsStream()); - AiExplanation = aiResult?.AiExplanation ?? string.Empty; - AiModelName = aiResult?.ModelName ?? string.Empty; + AiExplanation = aiResult.Value.AiExplanation ?? string.Empty; + AiModelName = aiResult.Value.ModelName ?? string.Empty; } else { - // Silently fail - AI explanation is optional AiExplanation = string.Empty; AiModelName = string.Empty; } } + catch (OperationCanceledException) + { + // Expected when a newer search cancels the in-flight AI request. + } catch (Exception ex) { // Silently fail (log only) - AI explanation is optional @@ -180,18 +202,15 @@ private async Task LoadAiExplanationAsync(Code code, AnalysisResult analysisResu } finally { - AiExplanationLoading = false; - StateHasChanged(); + if (searchId == _activeSearchId && !_disposed) + { + AiExplanationLoading = false; + await InvokeAsync(StateHasChanged); + } } } private string _inputValue; private string AiModelName { get; set; } - - private class AiExplanationResponse - { - public string AiExplanation { get; set; } = string.Empty; - public string? ModelName { get; set; } - } } } \ No newline at end of file diff --git a/explainpowershell.frontend/Program.cs b/explainpowershell.frontend/Program.cs index 8fa58d2..453067b 100644 --- a/explainpowershell.frontend/Program.cs +++ b/explainpowershell.frontend/Program.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using explainpowershell.frontend.Clients; using System; using System.Collections.Generic; using System.Net.Http; @@ -22,6 +23,8 @@ public static async Task Main(string[] args) BaseAddress = new Uri( builder.Configuration.GetValue("BaseAddress"))}); + builder.Services.AddScoped(); + builder.Services.AddMudServices(); await builder.Build().RunAsync(); diff --git a/explainpowershell.frontend/Properties/launchSettings.json b/explainpowershell.frontend/Properties/launchSettings.json index 7c0e339..8a1b6f6 100644 --- a/explainpowershell.frontend/Properties/launchSettings.json +++ b/explainpowershell.frontend/Properties/launchSettings.json @@ -18,7 +18,7 @@ }, "DefaultBlazor.Wasm": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/explainpowershell.frontend/Tree.cs b/explainpowershell.frontend/Tree.cs index 8502e85..c4bda3e 100644 --- a/explainpowershell.frontend/Tree.cs +++ b/explainpowershell.frontend/Tree.cs @@ -7,6 +7,8 @@ namespace explainpowershell.frontend { internal static class GenericTree { + private const int MaxDepth = 256; + /// /// Generates a MudBlazor-compatible tree from a flat collection. /// @@ -16,19 +18,122 @@ public static List> GenerateTree( Func parentIdSelector, K rootId = default) { - var nodes = new List>(); + static K CanonicalizeKey(K key) + { + if (typeof(K) == typeof(string)) + { + var s = key as string; + return (K)(object)(s ?? string.Empty); + } + + return key; + } + + var comparer = EqualityComparer.Default; + var items = collection as IList ?? collection.ToList(); + + // Group children by parent id in one pass (O(n)). + var childrenByParent = new Dictionary>(comparer); + foreach (var item in items) + { + var parentKey = CanonicalizeKey(parentIdSelector(item)); + if (!childrenByParent.TryGetValue(parentKey, out var list)) + { + list = new List(); + childrenByParent[parentKey] = list; + } - foreach (var item in collection.Where(c => EqualityComparer.Default.Equals(parentIdSelector(c), rootId))) + list.Add(item); + } + + var rootKey = CanonicalizeKey(rootId); + if (!childrenByParent.TryGetValue(rootKey, out var rootItems) || rootItems.Count == 0) { - var children = collection.GenerateTree(idSelector, parentIdSelector, idSelector(item)); + // Special-case: when rootId is null/empty string, treat both null and empty as root. + if (typeof(K) == typeof(string) && string.IsNullOrEmpty(rootKey as string)) + { + if (childrenByParent.TryGetValue((K)(object)string.Empty, out var emptyRootItems)) + { + rootItems = emptyRootItems; + } + } - nodes.Add(new TreeItemData + if (rootItems is null || rootItems.Count == 0) + { + return new List>(); + } + } + + var nodes = new List>(rootItems.Count); + + var stack = new Stack<(TreeItemData Node, K Id, int Depth, HashSet Path)>(); + + foreach (var item in rootItems) + { + var nodeId = CanonicalizeKey(idSelector(item)); + var node = new TreeItemData { Value = item, - Expanded = true, - Expandable = children.Count > 0, - Children = children.Count == 0 ? null : children - }); + Expanded = true + }; + + nodes.Add(node); + stack.Push((node, nodeId, 1, new HashSet(comparer) { nodeId })); + } + + while (stack.Count > 0) + { + var (node, id, depth, path) = stack.Pop(); + + if (depth >= MaxDepth) + { + node.Children = null; + node.Expandable = false; + continue; + } + + if (!childrenByParent.TryGetValue(id, out var children) || children.Count == 0) + { + node.Children = null; + node.Expandable = false; + continue; + } + + var childNodes = new List>(children.Count); + + foreach (var child in children) + { + var childId = CanonicalizeKey(idSelector(child)); + if (path.Contains(childId)) + { + continue; + } + + childNodes.Add(new TreeItemData + { + Value = child, + Expanded = true + }); + } + + if (childNodes.Count == 0) + { + node.Children = null; + node.Expandable = false; + continue; + } + + node.Children = childNodes; + node.Expandable = true; + + // Push in reverse so the first child is processed first. + for (var i = childNodes.Count - 1; i >= 0; i--) + { + var childNode = childNodes[i]; + var childId = CanonicalizeKey(idSelector(childNode.Value)); + var childPath = new HashSet(path, comparer) { childId }; + stack.Push((childNode, childId, depth + 1, childPath)); + } } return nodes; diff --git a/explainpowershell.frontend/explainpowershell.frontend.csproj b/explainpowershell.frontend/explainpowershell.frontend.csproj index 1f254f4..75722c7 100644 --- a/explainpowershell.frontend/explainpowershell.frontend.csproj +++ b/explainpowershell.frontend/explainpowershell.frontend.csproj @@ -3,8 +3,8 @@ net10.0 - - + + diff --git a/explainpowershell.helpcollector/HelpCollector.Functions.ps1 b/explainpowershell.helpcollector/HelpCollector.Functions.ps1 new file mode 100644 index 0000000..723a62c --- /dev/null +++ b/explainpowershell.helpcollector/HelpCollector.Functions.ps1 @@ -0,0 +1,169 @@ +function ConvertTo-LearnDocumentationUri { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Uri + ) + + $normalized = $Uri.Trim() + if ([string]::IsNullOrWhiteSpace($normalized)) { + return $Uri + } + + # Prefer HTTPS + $normalized = $normalized -replace '^http://', 'https://' + + # docs.microsoft.com redirects to learn.microsoft.com; normalize for consistency. + $normalized = $normalized -replace '^https://docs\.microsoft\.com', 'https://learn.microsoft.com' + + return $normalized +} + +function Get-TitleFromHtml { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Html + ) + + $m = [regex]::Match($Html, ']*>(.*?)', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline) + if ($m.Success) { + return ([System.Net.WebUtility]::HtmlDecode($m.Groups[1].Value) -replace '<[^>]+>', '').Trim() + } + + return $null +} + +function Get-SynopsisFromHtml { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Html, + + [Parameter()] + [string]$Cmd + ) + + if ([string]::IsNullOrWhiteSpace($Html)) { + return $null + } + + $regexOptions = [System.Text.RegularExpressions.RegexOptions]::IgnoreCase -bor [System.Text.RegularExpressions.RegexOptions]::Singleline + + # 1) Learn pages usually expose a meta description. + $meta = [regex]::Match($Html, '', $regexOptions) + if ($meta.Success) { + return ([System.Net.WebUtility]::HtmlDecode($meta.Groups[1].Value)).Trim() + } + + # 2) Learn pages often have a summary paragraph. + $summary = [regex]::Match($Html, ']*class=["\'']summary["\''][^>]*>(.*?)

', $regexOptions) + if ($summary.Success) { + $text = $summary.Groups[1].Value + $text = [System.Net.WebUtility]::HtmlDecode($text) + $text = ($text -replace '<[^>]+>', '').Trim() + if (-not [string]::IsNullOrWhiteSpace($text)) { + return $text + } + } + + # 3) About_language_keywords sometimes has a per-keyword section. + if (-not [string]::IsNullOrWhiteSpace($Cmd)) { + $escapedCmd = [regex]::Escape($Cmd) + $pattern = ']*id=[''"]' + $escapedCmd + '[''"][^>]*>.*?\s*]*>(.*?)

' + $section = [regex]::Match($Html, $pattern, $regexOptions) + if ($section.Success) { + $text = $section.Groups[1].Value + $text = [System.Net.WebUtility]::HtmlDecode($text) + $text = ($text -replace '<[^>]+>', '').Trim() + if (-not [string]::IsNullOrWhiteSpace($text)) { + return $text + } + } + } + + return $null +} + +function Get-SynopsisFromUri { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$Uri, + + [Parameter()] + [string]$Cmd + ) + + $normalizedUri = ConvertTo-LearnDocumentationUri -Uri $Uri + + try { + # Use Invoke-WebRequest to reliably get HTML content; IRM sometimes tries to parse. + $response = Invoke-WebRequest -Uri $normalizedUri -ErrorAction Stop + $html = $response.Content + + $synopsis = Get-SynopsisFromHtml -Html $html -Cmd $Cmd + if (-not [string]::IsNullOrWhiteSpace($synopsis)) { + return @($true, $synopsis) + } + + $title = Get-TitleFromHtml -Html $html + return @($false, $title) + } + catch { + return @($false, $null) + } +} + +function Get-Synopsis { + param( + $Help, + $Cmd, + $DocumentationLink, + $description + ) + + if ($null -eq $help) { + return @($null, $null) + } + + $synopsis = $help.Synopsis.Trim() + + if ($synopsis -like '') { + Write-Verbose "$($cmd.name) - Empty synopsis, trying to get synopsis from description." + $description = $help.description.Text + if ([string]::IsNullOrEmpty($description)) { + Write-Verbose "$($cmd.name) - Empty description." + } + else { + $synopsis = $description.Trim().Split('.')[0].Trim() + } + } + + if ($synopsis -match "^$($cmd.Name) .*[-\[\]<>]" -or $synopsis -like '') { + # If synopsis starts with the name of the verb, it's not a synopsis. + $synopsis = $null + + if ([string]::IsNullOrEmpty($DocumentationLink) -or $DocumentationLink -in $script:badUrls) { + } + else { + Write-Verbose "$($cmd.name) - Trying to get missing synopsis from Uri" + $success, $synopsis = Get-SynopsisFromUri -Uri $DocumentationLink -Cmd $cmd.Name -verbose:$false + + if ($null -eq $synopsis -or -not $success) { + if ($synopsis -notmatch "^$($cmd.Name) .*[-\[\]<>]") { + Write-Warning "!!$($cmd.name) - Bad online help uri, '$DocumentationLink' is about '$synopsis'" + $script:badUrls += $DocumentationLink + $DocumentationLink = $null + $synopsis = $null + } + } + } + } + + if ($null -ne $synopsis -and $synopsis -match "^$($cmd.Name) .*[-\[\]<>]") { + $synopsis = $null + } + + return @($synopsis, $DocumentationLink) +} diff --git a/explainpowershell.helpcollector/New-SasToken.ps1 b/explainpowershell.helpcollector/New-SasToken.ps1 index 106661f..b02ef0d 100644 --- a/explainpowershell.helpcollector/New-SasToken.ps1 +++ b/explainpowershell.helpcollector/New-SasToken.ps1 @@ -9,7 +9,7 @@ function New-SasToken { $sasSplat = @{ Service = 'Table' ResourceType = 'Service', 'Container', 'Object' - Permission = 'racwdlup' # https://docs.microsoft.com/en-us/powershell/module/az.storage/new-azstorageaccountsastoken + Permission = 'racwdlup' # https://learn.microsoft.com/en-us/powershell/module/az.storage/new-azstorageaccountsastoken#-permission StartTime = (Get-Date) ExpiryTime = (Get-Date).AddMinutes(30) Context = $context diff --git a/explainpowershell.helpcollector/aboutcollector.ps1 b/explainpowershell.helpcollector/aboutcollector.ps1 index 7f86fe4..ba32374 100644 --- a/explainpowershell.helpcollector/aboutcollector.ps1 +++ b/explainpowershell.helpcollector/aboutcollector.ps1 @@ -7,7 +7,7 @@ $aboutArticles = Get-Help About_* $abouts = $aboutArticles | Where-Object {-not $_.synopsis} foreach ($about in $abouts) { - $baseUrl = 'https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/' + $baseUrl = 'https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/' [BasicHtmlWebResponseObject]$result = $null try { $result = Invoke-WebRequest -Uri ($baseUrl + $about.name) -ErrorAction SilentlyContinue diff --git a/explainpowershell.helpcollector/helpcollector.ps1 b/explainpowershell.helpcollector/helpcollector.ps1 index 2ab7710..4594a55 100644 --- a/explainpowershell.helpcollector/helpcollector.ps1 +++ b/explainpowershell.helpcollector/helpcollector.ps1 @@ -4,81 +4,7 @@ param( $ModulesToProcess ) -#region functions -function Get-SynopsisFromUri { - [CmdletBinding()] - param( - $uri, - $cmd - ) - - try { - $html = (Invoke-RestMethod $uri).Trim().Split("`n").split('(.*)' -Context 1 - if ($null -eq $temp) { - $temp = $html | Select-String "h2 id=`"$cmd" -Context 1 - } - return @($true, $temp.Context.PostContext.Trim() -replace '

|

', '') - } - catch { - return @($false, $title) - } -} - -function Get-Synopsis { - param( - $Help, - $Cmd, - $DocumentationLink, - $description - ) - - if ($null -eq $help) { - return @($null, $null) - } - - $synopsis = $help.Synopsis.Trim() - - if ($synopsis -like '') { - Write-Verbose "$($cmd.name) - Empty synopsis, trying to get synopsis from description." - $description = $help.description.Text - if ([string]::IsNullOrEmpty($description)) { - Write-Verbose "$($cmd.name) - Empty description." - } - else { - $synopsis = $description.Trim().Split('.')[0].Trim() - } - } - - if ($synopsis -match "^$($cmd.Name) .*[-\[\]<>]" -or $synopsis -like '') { - # If synopsis starts with the name of the verb, it's not a synopsis. - $synopsis = $null - - if ([string]::IsNullOrEmpty($DocumentationLink) -or $DocumentationLink -in $script:badUrls) { - } - else { - Write-Verbose "$($cmd.name) - Trying to get missing synopsis from Uri" - $succes, $synopsis = Get-SynopsisFromUri $DocumentationLink -cmd $cmd.Name -verbose:$false - - if ($null -eq $synopsis -or -not $success) { - if ($synopsis -notmatch "^$($cmd.Name) .*[-\[\]<>]") { - Write-Warning "!!$($cmd.name) - Bad online help uri, '$DocumentationLink' is about '$synopsis'" - $script:badUrls += $DocumentationLink - $DocumentationLink = $null - $synopsis = $null - } - } - } - } - - if ($null -ne $synopsis -and $synopsis -match "^$($cmd.Name) .*[-\[\]<>]") { - $synopsis = $null - } - - return @($synopsis, $DocumentationLink) -} -#endregion functions +. $PSScriptRoot/HelpCollector.Functions.ps1 $ModulesToProcess = $ModulesToProcess | Sort-Object -Unique -Property Name diff --git a/explainpowershell.helpcollector/tools/DeCompress.cs b/explainpowershell.helpcollector/tools/DeCompress.cs index 8286ef0..39af806 100644 --- a/explainpowershell.helpcollector/tools/DeCompress.cs +++ b/explainpowershell.helpcollector/tools/DeCompress.cs @@ -40,7 +40,7 @@ public static string Decompress(string compressedText) memoryStream.Position = 0; using (var gZipStream = new GZipStream(memoryStream, CompressionMode.Decompress)) { - gZipStream.Read(buffer, 0, buffer.Length); + gZipStream.ReadExactly(buffer); } return Encoding.UTF8.GetString(buffer); diff --git a/explainpowershell.metadata/defaultModules.json b/explainpowershell.metadata/defaultModules.json index 2f15f30..30756c6 100644 --- a/explainpowershell.metadata/defaultModules.json +++ b/explainpowershell.metadata/defaultModules.json @@ -1,31 +1,31 @@ [ { "Name": "Microsoft.PowerShell.Archive", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.archive" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.archive" }, { "Name": "Microsoft.PowerShell.Host", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.host" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.host" }, { "Name": "Microsoft.PowerShell.Management", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.management" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management" }, { "Name": "Microsoft.PowerShell.Security", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.security" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security" }, { "Name": "Microsoft.PowerShell.Utility", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.utility" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.utility" }, { "Name": "Microsoft.PowerShell.Core", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core" }, { "Name": "PSReadLine", - "ProjectUri": "https://docs.microsoft.com/en-us/powershell/module/psreadline" + "ProjectUri": "https://learn.microsoft.com/en-us/powershell/module/psreadline" }, { "Name": "myTestModule", diff --git a/explainpowershell.models/AnalysisResult.cs b/explainpowershell.models/AnalysisResult.cs index 0f3407f..60667a6 100644 --- a/explainpowershell.models/AnalysisResult.cs +++ b/explainpowershell.models/AnalysisResult.cs @@ -4,10 +4,10 @@ namespace explainpowershell.models { public class AnalysisResult { - public string ExpandedCode { get; set; } + public string? ExpandedCode { get; set; } public List Explanations { get; set; } = new List(); public List DetectedModules { get; set; } = new List(); - public string ParseErrorMessage { get; set; } - public string AiExplanation { get; set; } + public string? ParseErrorMessage { get; set; } + public string? AiExplanation { get; set; } } } diff --git a/explainpowershell.models/Code.cs b/explainpowershell.models/Code.cs index f7d4b39..ed68c51 100644 --- a/explainpowershell.models/Code.cs +++ b/explainpowershell.models/Code.cs @@ -2,6 +2,6 @@ namespace explainpowershell.models { public class Code { - public string PowershellCode { get; set; } + public string? PowershellCode { get; set; } } } diff --git a/explainpowershell.models/Explanation.cs b/explainpowershell.models/Explanation.cs index 3b60741..c8d0cab 100644 --- a/explainpowershell.models/Explanation.cs +++ b/explainpowershell.models/Explanation.cs @@ -2,12 +2,12 @@ namespace explainpowershell.models { public class Explanation { - public string OriginalExtent { get; set; } - public string CommandName { get; set; } - public string Description { get; set; } - public HelpEntity HelpResult { get; set; } - public string Id { get; set; } - public string ParentId { get; set; } - public string TextToHighlight { get; set; } + public string? OriginalExtent { get; set; } + public string? CommandName { get; set; } + public string? Description { get; set; } + public HelpEntity? HelpResult { get; set; } + public string? Id { get; set; } + public string? ParentId { get; set; } + public string? TextToHighlight { get; set; } } } diff --git a/explainpowershell.models/HelpEntity.cs b/explainpowershell.models/HelpEntity.cs index 2b0663d..52c0c65 100644 --- a/explainpowershell.models/HelpEntity.cs +++ b/explainpowershell.models/HelpEntity.cs @@ -6,25 +6,24 @@ namespace explainpowershell.models { public class HelpEntity : ITableEntity { - public string Aliases { get; set; } - public string CommandName { get; set; } - public string DefaultParameterSet { get; set; } - public string Description { get; set; } - public string DocumentationLink { get; set; } - public string InputTypes { get; set; } - public string ModuleName { get; set; } - public string ModuleProjectUri { get; set; } - public string ModuleVersion { get; set; } - public string Parameters { get; set; } - public string ParameterSetNames { get; set; } - public string RelatedLinks { get; set; } - public string ReturnValues { get; set; } - public string Synopsis { get; set; } - public string Syntax { get; set; } - + public string? Aliases { get; set; } + public string? CommandName { get; set; } + public string? DefaultParameterSet { get; set; } + public string? Description { get; set; } + public string? DocumentationLink { get; set; } + public string? InputTypes { get; set; } + public string? ModuleName { get; set; } + public string? ModuleProjectUri { get; set; } + public string? ModuleVersion { get; set; } + public string? Parameters { get; set; } + public string? ParameterSetNames { get; set; } + public string? RelatedLinks { get; set; } + public string? ReturnValues { get; set; } + public string? Synopsis { get; set; } + public string? Syntax { get; set; } // ITableEntity - public string PartitionKey { get; set; } - public string RowKey { get; set; } + public string? PartitionKey { get; set; } + public string? RowKey { get; set; } public DateTimeOffset? Timestamp { get; set; } public ETag ETag { get; set; } } diff --git a/explainpowershell.models/HelpMetaData.cs b/explainpowershell.models/HelpMetaData.cs index 2147ed3..2a0e693 100644 --- a/explainpowershell.models/HelpMetaData.cs +++ b/explainpowershell.models/HelpMetaData.cs @@ -9,12 +9,12 @@ public class HelpMetaData : ITableEntity public int NumberOfCommands { get; set; } public int NumberOfAboutArticles { get; set; } public int NumberOfModules { get; set; } - public string ModuleNames { get; set; } - public string LastPublished {get; set;} + public string? ModuleNames { get; set; } + public string? LastPublished {get; set;} // ITableEntity - public string PartitionKey { get; set; } - public string RowKey { get; set; } + public required string PartitionKey { get; set; } + public required string RowKey { get; set; } public DateTimeOffset? Timestamp { get; set; } public ETag ETag { get; set; } } diff --git a/explainpowershell.models/Module.cs b/explainpowershell.models/Module.cs index f86ab8f..101b0d7 100644 --- a/explainpowershell.models/Module.cs +++ b/explainpowershell.models/Module.cs @@ -2,6 +2,6 @@ namespace explainpowershell.models { public class Module { - public string ModuleName { get; set; } + public string? ModuleName { get; set; } } } diff --git a/explainpowershell.models/ParameterData.cs b/explainpowershell.models/ParameterData.cs index 55b53f1..f75f46a 100644 --- a/explainpowershell.models/ParameterData.cs +++ b/explainpowershell.models/ParameterData.cs @@ -5,17 +5,17 @@ namespace explainpowershell.models { public class ParameterData { - public string Aliases { get; set; } - public string DefaultValue { get; set; } - public string Description { get; set; } - public string Globbing { get; set; } + public string? Aliases { get; set; } + public string? DefaultValue { get; set; } + public string? Description { get; set; } + public string? Globbing { get; set; } public bool? IsDynamic { get; set; } - public string Name { get; set; } - public string PipelineInput { get; set; } - public string Position { get; set; } - public string Required { get; set; } + public string? Name { get; set; } + public string? PipelineInput { get; set; } + public string? Position { get; set; } + public string? Required { get; set; } public bool? SwitchParameter { get; set; } - public string TypeName { get; set; } + public string? TypeName { get; set; } public JsonElement ParameterSets { get; set; } } } diff --git a/explainpowershell.models/ParameterSetData.cs b/explainpowershell.models/ParameterSetData.cs index 47c30dd..9f9e709 100644 --- a/explainpowershell.models/ParameterSetData.cs +++ b/explainpowershell.models/ParameterSetData.cs @@ -2,14 +2,14 @@ namespace explainpowershell.models { public class ParameterSetData { - public string ParameterSetName { get; set; } + public string? ParameterSetName { get; set; } public bool IsMandatory { get; set; } public int Position { get; set; } public bool ValueFromPipeline { get; set; } public bool ValueFromPipelineByPropertyName { get; set; } public bool ValueFromRemainingArguments { get; set; } - public string HelpMessage { get; set; } - public string HelpMessageBaseName { get; set; } - public string HelpMessageResourceId { get; set; } + public string? HelpMessage { get; set; } + public string? HelpMessageBaseName { get; set; } + public string? HelpMessageResourceId { get; set; } } } diff --git a/explainpowershell.sln b/explainpowershell.sln index 557cd95..38f2a9a 100644 --- a/explainpowershell.sln +++ b/explainpowershell.sln @@ -11,39 +11,97 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.models", EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "explainpowershell.helpcollector", "explainpowershell.helpcollector", "{56F173B4-132E-4ADA-A154-7914BC2ECF6C}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools", "explainpowershell.helpcollector\tools\explainpowershell.helpcollector.tools.csproj", "{6854D7F5-13D2-4363-A699-88F3802FC917}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.helpcollector.tools", "explainpowershell.helpcollector\tools\explainpowershell.helpcollector.tools.csproj", "{6854D7F5-13D2-4363-A699-88F3802FC917}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.analysisservice.tests", "explainpowershell.analysisservice.tests\explainpowershell.analysisservice.tests.csproj", "{37C8F882-8BFA-483B-80B6-CBA18387AEE0}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "explainpowershell.frontend.tests", "explainpowershell.frontend.tests\explainpowershell.frontend.tests.csproj", "{FB7EE901-0650-4A54-BF4A-70407BE1234C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x64.ActiveCfg = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x64.Build.0 = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x86.ActiveCfg = Debug|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Debug|x86.Build.0 = Debug|Any CPU {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|Any CPU.ActiveCfg = Release|Any CPU {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|Any CPU.Build.0 = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x64.ActiveCfg = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x64.Build.0 = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x86.ActiveCfg = Release|Any CPU + {3ECDCF7D-284D-4958-B776-ED49218DAD3B}.Release|x86.Build.0 = Release|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x64.ActiveCfg = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x64.Build.0 = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x86.ActiveCfg = Debug|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Debug|x86.Build.0 = Debug|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|Any CPU.ActiveCfg = Release|Any CPU {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|Any CPU.Build.0 = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x64.ActiveCfg = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x64.Build.0 = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x86.ActiveCfg = Release|Any CPU + {8D32B3F4-A053-4A62-8432-6567CAFFE290}.Release|x86.Build.0 = Release|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x64.ActiveCfg = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x64.Build.0 = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x86.ActiveCfg = Debug|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Debug|x86.Build.0 = Debug|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|Any CPU.ActiveCfg = Release|Any CPU {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|Any CPU.Build.0 = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x64.ActiveCfg = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x64.Build.0 = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x86.ActiveCfg = Release|Any CPU + {CE997188-9CE9-43F5-AFAA-670D28222C23}.Release|x86.Build.0 = Release|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x64.ActiveCfg = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x64.Build.0 = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x86.ActiveCfg = Debug|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Debug|x86.Build.0 = Debug|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|Any CPU.ActiveCfg = Release|Any CPU {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|Any CPU.Build.0 = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x64.ActiveCfg = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x64.Build.0 = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x86.ActiveCfg = Release|Any CPU + {6854D7F5-13D2-4363-A699-88F3802FC917}.Release|x86.Build.0 = Release|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x64.Build.0 = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Debug|x86.Build.0 = Debug|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|Any CPU.ActiveCfg = Release|Any CPU {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|Any CPU.Build.0 = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x64.ActiveCfg = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x64.Build.0 = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x86.ActiveCfg = Release|Any CPU + {37C8F882-8BFA-483B-80B6-CBA18387AEE0}.Release|x86.Build.0 = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x64.Build.0 = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Debug|x86.Build.0 = Debug|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|Any CPU.Build.0 = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x64.ActiveCfg = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x64.Build.0 = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x86.ActiveCfg = Release|Any CPU + {FB7EE901-0650-4A54-BF4A-70407BE1234C}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {6854D7F5-13D2-4363-A699-88F3802FC917} = {56F173B4-132E-4ADA-A154-7914BC2ECF6C} diff --git a/research/Ast.txt b/research/Ast.txt index b50afe4..e4fd4d1 100644 --- a/research/Ast.txt +++ b/research/Ast.txt @@ -211,4 +211,4 @@ Ast StringConstantType [BareWord, DoubleQuoted, DoubleQuotedHereString, SingleQuoted, SingleQuotedHereString] -TokenKind (https://docs.microsoft.com/en-us/dotnet/api/system.management.automation.language.tokenkind) \ No newline at end of file +TokenKind (https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.tokenkind) \ No newline at end of file diff --git a/tools/bump_versions.ps1 b/tools/bump_versions.ps1 index bd6e6ba..43e44f3 100644 --- a/tools/bump_versions.ps1 +++ b/tools/bump_versions.ps1 @@ -1,10 +1,10 @@ -$ErrorActionPreference = 'stop' - [CmdletBinding()] param( [string]$TargetDotnetVersion ) +$ErrorActionPreference = 'stop' + $currentGitBranch = git branch --show-current if ($currentGitBranch -eq 'main') { throw "You are on 'main' branch, create a new branch first" @@ -52,6 +52,7 @@ Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForE $xml.Save($_.FullName) } +Write-Host "Updating Azure Functions settings to runtime ~$($functionsToolsVersion.Major)" $vsCodeSettingsFile = "$PSScriptRoot/../.vscode/settings.json" if (Test-Path $vsCodeSettingsFile) { $settings = Get-Content -Path $vsCodeSettingsFile | ConvertFrom-Json @@ -60,39 +61,18 @@ if (Test-Path $vsCodeSettingsFile) { $settings | ConvertTo-Json -Depth 5 | Out-File -Path $vsCodeSettingsFile -Force } +Write-Host "Updating GitHub Actions workflows" $deployAppWorkflow = "$PSScriptRoot/../.github/workflows/deploy_app.yml" if (Test-Path $deployAppWorkflow) { - $ghDeployAction = Get-Content -Path $deployAppWorkflow | ConvertFrom-Yaml - foreach ($jobName in 'buildFrontend','buildBackend') { - $job = $ghDeployAction.jobs.$jobName - if ($null -eq $job) { continue } - foreach ($step in $job.steps) { - if ($step.with.'dotnet-version') { - $step.with.'dotnet-version' = $dotNetShortVersion - } - if ($step.with.path) { - $step.with.path = $step.with.path -replace 'net\d+\.\d+', "net$dotNetShortVersion" - } - } - } - $ghDeployAction - | ConvertTo-Yaml + $ghFlow = Get-Content -Path $deployAppWorkflow + $ghFlow | ForEach-Object { $_ -replace 'dotnet-version: "\d+.0"', "dotnet-version: `"$($dotNetVersion.Major).0`"" } | Set-Content -Path $deployAppWorkflow -Force } - -$deployInfraWorkflow = "$PSScriptRoot/../.github/workflows/deploy_azure_infra.yml" -if (Test-Path $deployInfraWorkflow) { - $ghDeployInfra = Get-Content -Path $deployInfraWorkflow | ConvertFrom-Yaml - foreach ($step in $ghDeployInfra.jobs.deploy.steps) { - if ($step.run) { - $step.run = $step.run -replace 'FUNCTIONS_EXTENSION_VERSION=~\d+', "FUNCTIONS_EXTENSION_VERSION=~$($functionsToolsVersion.Major)" - } - } - $ghDeployInfra - | ConvertTo-Yaml - | Set-Content -Path $deployInfraWorkflow -Force +else { + Write-Host "No deploy_app.yml workflow found, skipping update." } +Write-Host "Updating NuGet package versions to latest stable" ## Update packages (mirrors Pester checks: dotnet list package --outdated) $outdated = dotnet list "$PSScriptRoot/../explainpowershell.sln" package --outdated $outdated += dotnet list "$PSScriptRoot/../explainpowershell.analysisservice.tests/explainpowershell.analysisservice.tests.csproj" package --outdated @@ -103,7 +83,9 @@ $targetVersions = $outdated | Select-String '^ >' | ForEach-Object { PackageName = $parts[0].Trim('>',' ') LatestVersion = [version]$parts[-1] } -} +} | Sort-Object PackageName -Unique + +Write-Host "Found $($targetVersions.Count) packages to update: $($targetVersions.PackageName -join ', ')" Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForEach-Object { $xml = [xml](Get-Content $_.FullName) @@ -111,6 +93,7 @@ Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForE foreach ($package in @($itemGroup.PackageReference)) { if ($package.Include -in $targetVersions.PackageName) { $latest = ($targetVersions | Where-Object PackageName -EQ $package.Include).LatestVersion + Write-Debug "Updating '$($package.Include)' to version '$latest' in '$($_.FullName)'" if ($latest) { $package.Version = $latest.ToString() } @@ -120,6 +103,7 @@ Get-ChildItem -Path "$PSScriptRoot/.." -Filter *.csproj -Recurse -Depth 2 | ForE $xml.Save($_.FullName) } +Write-Host "Restoring and cleaning solution" Push-Location $PSScriptRoot/.. dotnet restore dotnet clean --verbosity minimal