Build-FunctionDocs.ps1
|
# Build-FunctionDocs.ps1 # Regenerates cloudstack-ps.md from the module's comment-based help. # # pwsh -NoProfile -File ./cloudstack-ps/Build-FunctionDocs.ps1 # rewrite the docs if anything changed # pwsh -NoProfile -File ./cloudstack-ps/Build-FunctionDocs.ps1 -Check # exit 1 if the docs are out of date # # The file is only rewritten when something other than the 'Generated:' timestamp # changes, so running this on every build does not create noise in git. [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$ModulePath = $PSScriptRoot, [Parameter(Mandatory = $false)] [string]$OutputPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'cloudstack-ps.md'), # Optional list of API commands and descriptions, used as the synopsis for # functions that have no comment-based help yet. [Parameter(Mandatory = $false)] [string]$ApiEndpointsPath = (Join-Path (Split-Path $PSScriptRoot -Parent) 'api-endpoints.json'), [switch]$Check ) $ErrorActionPreference = 'Stop' function Get-ApiDescriptions { param([string]$Path) $map = @{} if (-not (Test-Path $Path)) { return $map } # api-endpoints.json is a stream of { "description", "name" } objects rather # than one JSON array, so pull the pairs out individually. $text = [System.IO.File]::ReadAllText($Path) foreach ($match in [regex]::Matches($text, '\{[^{}]*\}')) { try { $entry = $match.Value | ConvertFrom-Json } catch { continue } if ($entry.name -and $entry.description) { $map[[string]$entry.name] = [string]$entry.description } } return $map } function Get-ApiCommands { param([string]$Definition) # Direct calls (-Command 'listVolumes' / -Command listClusters) and the older # hashtable style (command = "addNicToVirtualMachine"). $found = [System.Collections.Generic.List[string]]::new() $patterns = @( '-Command\s+[''"]?([A-Za-z0-9]+)', '\bcommand\s*=\s*[''"]([A-Za-z0-9]+)[''"]' ) foreach ($pattern in $patterns) { foreach ($match in [regex]::Matches($Definition, $pattern)) { $name = $match.Groups[1].Value # queryAsyncJobResult is plumbing for -Wait, not what the function wraps. if ($name -ne 'queryAsyncJobResult' -and -not $found.Contains($name)) { $found.Add($name) } } } return $found } function Format-HelpText { <# Renders a comment-based help section as markdown. Lines at the base indentation are joined into paragraphs; indented lines (with or without a leading '-') become list items, and deeper-indented lines continue the list item above them. #> param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return '' } $blocks = [System.Collections.Generic.List[string]]::new() $paragraph = [System.Collections.Generic.List[string]]::new() $items = [System.Collections.Generic.List[string]]::new() $itemIndent = -1 $flushParagraph = { if ($paragraph.Count -gt 0) { $blocks.Add(($paragraph -join ' ')); $paragraph.Clear() } } $flushItems = { if ($items.Count -gt 0) { $blocks.Add((($items | ForEach-Object { "- $_" }) -join "`n")); $items.Clear() } Set-Variable -Name itemIndent -Value -1 -Scope 1 } foreach ($rawLine in (($Text -replace "`r", '') -split "`n")) { $trimmed = $rawLine.Trim() if ($trimmed -eq '') { & $flushParagraph; & $flushItems; continue } $indent = $rawLine.Length - $rawLine.TrimStart().Length if ($indent -eq 0) { & $flushItems $paragraph.Add($trimmed) continue } & $flushParagraph $isBullet = $trimmed.StartsWith('- ') if (-not $isBullet -and $items.Count -gt 0 -and $indent -gt $itemIndent) { $items[$items.Count - 1] += " $trimmed" continue } $items.Add($(if ($isBullet) { $trimmed.Substring(2).Trim() } else { $trimmed })) # Continuation lines of a '- item' are indented past the '- ' marker. $itemIndent = if ($isBullet) { $indent + 1 } else { $indent } } & $flushParagraph & $flushItems return ($blocks -join "`n`n") } function Get-InlineHelp { <# PowerShell's help parser ignores help written as a single-line comment block (SYNOPSIS/EXAMPLE keywords all on one line between the block comment markers), which several older functions use. Read the keywords out of that form here. #> param([string]$Definition) $comment = [regex]::Match($Definition, '(?s)<#(.*?)#>') if (-not $comment.Success -or $comment.Groups[1].Value -notmatch '\.SYNOPSIS') { return $null } $result = [pscustomobject]@{ Synopsis = $null; Description = $null; Examples = [System.Collections.Generic.List[string]]::new() } $pattern = '\.(SYNOPSIS|DESCRIPTION|EXAMPLE)\s+(.*?)(?=\s+\.(?:SYNOPSIS|DESCRIPTION|EXAMPLE|PARAMETER|NOTES|LINK)\b|\s*$)' foreach ($match in [regex]::Matches($comment.Groups[1].Value, $pattern, 'Singleline')) { $value = $match.Groups[2].Value.Trim() switch ($match.Groups[1].Value) { 'SYNOPSIS' { $result.Synopsis = $value } 'DESCRIPTION' { $result.Description = $value } 'EXAMPLE' { $result.Examples.Add($value) } } } return $result } function Test-ProseLine { # True when an example line reads as the example's description rather than # code. The module's convention is: code lines first, then a sentence # describing them ("Lists every volume in your account."). param([string]$Line) if ($Line -match '^\s') { return $false } # indented: continuation of code $trimmed = $Line.Trim() if ($trimmed -match '^[#$(\[{}"''@|.&!<>]') { return $false } # comment, variable, expression... if ($trimmed -match '^(if|elseif|else|foreach|for|while|do|switch|try|catch|finally|function|param|return)\s*[\({]') { return $false } $firstWord = ($trimmed -split '\s+')[0] if ($firstWord -match '^[A-Za-z]+-[A-Za-z]') { return $false } # Verb-Noun command return ($trimmed -match '^[A-Z][a-z]+\b') } function Split-Example { param([string]$Text) $code = [System.Collections.Generic.List[string]]::new() $description = [System.Collections.Generic.List[string]]::new() foreach ($line in (($Text -replace "`r", '').TrimEnd() -split "`n")) { if ($description.Count -gt 0) { $description.Add($line.Trim()); continue } $hasCode = @($code | Where-Object { $_.Trim() }).Count -gt 0 if ($hasCode -and $line.Trim() -and (Test-ProseLine $line)) { $description.Add($line.Trim()); continue } $code.Add($line.TrimEnd()) } while ($code.Count -gt 0 -and -not $code[$code.Count - 1].Trim()) { $code.RemoveAt($code.Count - 1) } while ($code.Count -gt 0 -and -not $code[0].Trim()) { $code.RemoveAt(0) } [pscustomobject]@{ Code = ($code -join "`n") Description = (($description | Where-Object { $_ }) -join ' ') } } function Format-Cell { param([string]$Text) if ([string]::IsNullOrEmpty($Text)) { return '-' } return $Text.Replace('|', '\|') } # --- Load the module the same way a user would --------------------------------- $manifestPath = Join-Path $ModulePath 'cloudstack-ps.psd1' $module = Import-Module $manifestPath -Force -PassThru -WarningAction SilentlyContinue try { $apiDescriptions = Get-ApiDescriptions -Path $ApiEndpointsPath $commonParameters = [System.Management.Automation.Cmdlet]::CommonParameters + [System.Management.Automation.Cmdlet]::OptionalCommonParameters $aliasesByCommand = @{} foreach ($alias in $module.ExportedAliases.Values) { $target = $alias.ResolvedCommandName if (-not $aliasesByCommand.ContainsKey($target)) { $aliasesByCommand[$target] = [System.Collections.Generic.List[string]]::new() } $aliasesByCommand[$target].Add($alias.Name) } $functions = @($module.ExportedFunctions.Values | Sort-Object Name) $sections = foreach ($command in $functions) { $functionAst = $command.ScriptBlock.Ast $help = $functionAst.GetHelpContent() if (-not $help) { $help = Get-InlineHelp -Definition $command.Definition } $apiCommands = @(Get-ApiCommands -Definition $command.Definition) $lines = [System.Collections.Generic.List[string]]::new() $lines.Add("## $($command.Name)") $lines.Add('') $lines.Add("**Source File:** ``$(Split-Path $command.ScriptBlock.File -Leaf)``") $synopsis = if ($help -and $help.Synopsis) { ($help.Synopsis -replace '\s+', ' ').Trim() } else { $null } if (-not $synopsis -and $apiCommands.Count -gt 0 -and $apiDescriptions[$apiCommands[0]]) { $synopsis = $apiDescriptions[$apiCommands[0]].Trim() if ($synopsis -notmatch '[.!?]$') { $synopsis += '.' } } if (-not $synopsis -and $apiCommands.Count -gt 0) { $synopsis = "Calls the CloudStack $($apiCommands[0]) API." } if (-not $synopsis) { $synopsis = '_No synopsis yet._' } $lines.Add("**Synopsis:** $synopsis") if ($aliasesByCommand.ContainsKey($command.Name)) { $names = ($aliasesByCommand[$command.Name] | Sort-Object | ForEach-Object { "``$_``" }) -join ', ' $lines.Add("**Deprecated aliases:** $names") } $lines.Add('') if ($help -and $help.Description) { $lines.Add("**Description:** $(Format-HelpText $help.Description)") $lines.Add('') } if ($apiCommands.Count -gt 0) { $apiText = ($apiCommands | ForEach-Object { '`' + $_ + '`' }) -join ', ' $lines.Add("**API Command:** $apiText") $lines.Add('') } $binding = $command.ScriptBlock.Attributes | Where-Object { $_ -is [System.Management.Automation.CmdletBindingAttribute] } | Select-Object -First 1 if ($binding) { $bindingLine = '**CmdletBinding:** Yes' if ($binding.SupportsShouldProcess) { $bindingLine += " | **SupportsShouldProcess:** Yes | **ConfirmImpact:** $($binding.ConfirmImpact)" } } else { $bindingLine = '**CmdletBinding:** No' } $lines.Add($bindingLine) $lines.Add('') # Default values only exist in the source, so read them from the AST. $defaults = @{} $parameterAsts = if ($functionAst.Body.ParamBlock) { $functionAst.Body.ParamBlock.Parameters } else { $functionAst.Parameters } foreach ($parameterAst in @($parameterAsts)) { if ($parameterAst -and $parameterAst.DefaultValue) { $defaults[$parameterAst.Name.VariablePath.UserPath] = $parameterAst.DefaultValue.Extent.Text } } $parameters = @($command.Parameters.Values | Where-Object { $commonParameters -notcontains $_.Name } | Sort-Object Name) if ($parameters.Count -gt 0) { $lines.Add('### Parameters') $lines.Add('') $lines.Add('| Parameter | Type | Mandatory | Pipeline | Aliases | ValidateSet | Default |') $lines.Add('|-----------|------|-----------|----------|---------|-------------|---------|') foreach ($parameter in $parameters) { $parameterAttributes = @($parameter.Attributes | Where-Object { $_ -is [System.Management.Automation.ParameterAttribute] }) $mandatory = [bool]($parameterAttributes | Where-Object Mandatory) $pipeline = @() if ($parameterAttributes | Where-Object ValueFromPipeline) { $pipeline += 'ByValue' } if ($parameterAttributes | Where-Object ValueFromPipelineByPropertyName) { $pipeline += 'ByPropertyName' } $validateSet = $parameter.Attributes | Where-Object { $_ -is [System.Management.Automation.ValidateSetAttribute] } | Select-Object -First 1 $setText = if ($validateSet) { ($validateSet.ValidValues | ForEach-Object { "``$_``" }) -join ', ' } else { '' } $aliasText = ($parameter.Aliases | ForEach-Object { "``$_``" }) -join ', ' $defaultText = if ($defaults.ContainsKey($parameter.Name)) { "``$($defaults[$parameter.Name])``" } else { '' } $lines.Add(('| **{0}** | `{1}` | {2} | {3} | {4} | {5} | {6} |' -f $parameter.Name, $parameter.ParameterType.Name, $mandatory, (Format-Cell ($pipeline -join ', ')), (Format-Cell $aliasText), (Format-Cell $setText), (Format-Cell $defaultText))) } $lines.Add('') } $examples = if ($help) { @($help.Examples | Where-Object { $_ -and $_.Trim() }) } else { @() } if ($examples.Count -gt 0) { $lines.Add('### Examples') $lines.Add('') $number = 0 foreach ($example in $examples) { $number++ $parts = Split-Example $example $title = if ($parts.Description) { "**Example $number - $($parts.Description)**" } else { "**Example $number**" } $lines.Add($title) $lines.Add('```powershell') $lines.Add($parts.Code) $lines.Add('```') $lines.Add('') } } $lines.Add('---') $lines.Add('') $lines -join "`n" } $body = @( '' "Total Functions: $($functions.Count)" '' ($sections -join "`n") ) -join "`n" $title = '# CloudStack PowerShell Module Function Documentation' } finally { Remove-Module $module -Force -ErrorAction SilentlyContinue } # --- Write only when the content (ignoring the timestamp) changed ------------ $stripTimestamp = { param($text) ($text -replace "`r", '') -replace '(?m)^Generated: .*\n', '' } $existing = if (Test-Path $OutputPath) { [System.IO.File]::ReadAllText($OutputPath) } else { '' } $newContent = "$title`nGenerated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')`n$body" if ((& $stripTimestamp $existing) -ceq (& $stripTimestamp $newContent)) { Write-Host "Docs are up to date: $OutputPath ($($functions.Count) functions)" -ForegroundColor Green return } if ($Check) { Write-Host "Docs are out of date: $OutputPath. Run cloudstack-ps/Build-FunctionDocs.ps1 to regenerate them." -ForegroundColor Red exit 1 } # UTF-8 without a BOM and LF line endings, matching the existing file. [System.IO.File]::WriteAllText($OutputPath, $newContent, (New-Object System.Text.UTF8Encoding($false))) Write-Host "Wrote $OutputPath ($($functions.Count) functions)" -ForegroundColor Green |