diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fa7b22..7c8b60d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,8 @@ jobs: run: | Get-PackageProvider -Name NuGet -ForceBootstrap | Out-Null Install-Module -Name Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force -SkipPublisherCheck -AllowClobber - Install-Module -Name PSScriptAnalyzer, DependsOn -Scope CurrentUser -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name PSScriptAnalyzer -RequiredVersion 1.25.0 -Scope CurrentUser -Force -SkipPublisherCheck -AllowClobber + Install-Module -Name DependsOn -Scope CurrentUser -Force -SkipPublisherCheck -AllowClobber - name: Import module from source shell: pwsh @@ -59,6 +60,7 @@ jobs: shell: pwsh run: | Import-Module Pester -RequiredVersion 5.7.1 -Force + Import-Module PSScriptAnalyzer -RequiredVersion 1.25.0 -Force Import-Module ./PSGraph/PSGraph.psd1 -Force $config = New-PesterConfiguration diff --git a/PSGraph/PSGraph.psd1 b/PSGraph/PSGraph.psd1 index cae83b8..3949641 100644 --- a/PSGraph/PSGraph.psd1 +++ b/PSGraph/PSGraph.psd1 @@ -69,7 +69,7 @@ # NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. - FunctionsToExport = @('Edge','Entity','Export-PSGraph','Graph','Inline','Install-GraphViz','Node','Rank','Record','Row','Set-NodeFormatScript','Show-PSGraph','SubGraph') + FunctionsToExport = @('Edge','Entity','Export-PSGraph','Graph','Inline','Install-GraphViz','New-EdgeAttributeSet','New-NodeAttributeSet','Node','Rank','Record','Row','Set-NodeFormatScript','Show-PSGraph','SubGraph') # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = @() @@ -78,7 +78,7 @@ VariablesToExport = '*' # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. - AliasesToExport = @('digraph') + AliasesToExport = @('digraph', 'NodeAttributes', 'EdgeAttributes') # DSC resources to export from this module # DscResourcesToExport = @() diff --git a/PSGraph/PSGraph.psm1 b/PSGraph/PSGraph.psm1 index 1b1106f..01c336e 100644 --- a/PSGraph/PSGraph.psm1 +++ b/PSGraph/PSGraph.psm1 @@ -61,4 +61,5 @@ foreach ($folder in $folders) Write-Verbose -Message 'Exporting Public functions...' $functions = Get-ChildItem -Path "$PSScriptRoot\Public" -Filter '*.ps1' -Recurse -Export-ModuleMember -Function $functions.BaseName +# Keep in sync with PSGraph.psd1's AliasesToExport +Export-ModuleMember -Function $functions.BaseName -Alias 'DiGraph', 'NodeAttributes', 'EdgeAttributes' diff --git a/PSGraph/Private/Format-Value.ps1 b/PSGraph/Private/Format-Value.ps1 index fd8d5eb..4ff84ac 100644 --- a/PSGraph/Private/Format-Value.ps1 +++ b/PSGraph/Private/Format-Value.ps1 @@ -23,12 +23,24 @@ function Format-Value if ($Edge -and # is not surounded by explicit quotes $value -notmatch '^".*"$' -and - # has record notation with a word as a target - $value -match '^(?.+):(?(\w+))$' + # has record notation with a port/row target - allow hyphens so GUID-style + # row IDs (see issue #65) are recognized as a port, not part of the node name + $value -match '^(?.+):(?[\w-]+)$' ) { + # Capture both groups before any further regex ops below, since -notmatch + # re-populates (and would otherwise clobber) $matches + $recordNode = $matches.node + $recordPort = $matches.Record + + if ($recordPort -notmatch '^[A-Za-z_]\w*$') + { + # Not a bare GraphViz identifier (e.g. a GUID, or starts with a digit) - quote it + $recordPort = '"{0}"' -f $recordPort + } + # Recursive call to this function to format just the node - "{0}:{1}" -f (Format-Value $matches.node -Node), $matches.record + "{0}:{1}" -f (Format-Value $recordNode -Node), $recordPort } else { diff --git a/PSGraph/Private/Update-DefaultArgument.ps1 b/PSGraph/Private/Update-DefaultArgument.ps1 index ada7e40..ac04b24 100644 --- a/PSGraph/Private/Update-DefaultArgument.ps1 +++ b/PSGraph/Private/Update-DefaultArgument.ps1 @@ -10,7 +10,9 @@ function Update-DefaultArgument $InputObject['LayoutEngine'] = Get-LayoutEngine -Name $InputObject['LayoutEngine'] } - if ( -Not $InputObject.ContainsKey( 'DestinationPath' ) ) + # PassThru intentionally omits DestinationPath so graphviz writes to stdout; + # don't let AutoName's '-O' flag force it to an auto-named file instead. + if ( -Not $InputObject.ContainsKey( 'DestinationPath' ) -and -Not $InputObject.ContainsKey( 'PassThru' ) ) { $InputObject["AutoName"] = $true; } diff --git a/PSGraph/Public/Export-PSGraph.ps1 b/PSGraph/Public/Export-PSGraph.ps1 index 1642cc6..791f5e5 100644 --- a/PSGraph/Public/Export-PSGraph.ps1 +++ b/PSGraph/Public/Export-PSGraph.ps1 @@ -15,6 +15,11 @@ function Export-PSGraph Path or paths to the dot graphviz executable. Some sensible defaults are used if nothing is passed. .PARAMETER ShowGraph Launches the graph when done + .PARAMETER PassThru + Returns the rendered graph as text instead of writing it to a file. Useful for + piping SVG/DOT output into a notebook workflow (e.g. Jupyter/.NET Interactive). + Only supported when Source is inline DOT text (not a file path), and cannot be + combined with -DestinationPath or -ShowGraph. .Example Export-PSGraph -Source graph.dot -OutputFormat png @@ -24,6 +29,11 @@ function Export-PSGraph edge (5..2) } | Export-PSGraph -Destination $env:temp\test.png + .Example + graph g { + edge hello world + } | Export-PSGraph -OutputFormat svg -PassThru + .Notes The source can either be files or piped graph data. @@ -74,6 +84,7 @@ function Export-PSGraph [string[]] $GraphVizPath = ( 'C:\Program Files\NuGet\Packages\Graphviz*\dot.exe', + "$env:USERPROFILE\AppData\Local\PackageManagement\NuGet\Packages\Graphviz*\dot.exe", # Install-GraphViz -Scope CurrentUser location 'C:\program files*\GraphViz*\bin\dot.exe', '/usr/local/bin/dot', '/usr/bin/dot' @@ -81,13 +92,29 @@ function Export-PSGraph # launches the graph when done [switch] - $ShowGraph + $ShowGraph, + + # returns the rendered graph as text instead of writing it to a file + [switch] + $PassThru ) begin { try { + if ( $PassThru ) + { + if ( $PSBoundParameters.ContainsKey('DestinationPath') -and -Not [string]::IsNullOrEmpty($DestinationPath) ) + { + throw '-PassThru cannot be combined with -DestinationPath; PassThru returns the rendered graph instead of writing a file.' + } + if ( $ShowGraph ) + { + throw '-PassThru cannot be combined with -ShowGraph; there is no destination file to show when the graph is returned as text.' + } + } + $graphViz = $null # Unless the caller explicitly pinned a path, prefer a cross-platform @@ -107,7 +134,7 @@ function Export-PSGraph if ( $null -eq $graphViz ) { $GraphvizPathString = $GraphVizPath -Join " or " - throw "Could not find GraphViz installed on this system. Please run 'Install-GraphViz' to install the needed binaries and libraries. This module looked for a 'dot' executable on PATH and in the following paths: $($GraphvizPathString). Optionally pass a path to your dot.exe file with the GraphVizPath parameter" + throw "Could not find GraphViz installed on this system. Please run 'Install-GraphViz' (or 'Install-GraphViz -Scope CurrentUser' if you don't have admin rights) to install the needed binaries and libraries. This module looked for a 'dot' executable on PATH and in the following paths: $($GraphvizPathString). Optionally pass a path to your dot.exe file with the GraphVizPath parameter" } $useStandardInput = $false @@ -149,6 +176,11 @@ function Export-PSGraph if ( $null -ne $fileList -and $Source.Count -gt 0 ) { + if ( $PassThru ) + { + throw '-PassThru is only supported when Source is inline DOT text, not a file path.' + } + foreach ( $file in $fileList ) { Write-Verbose "Generating graph from '$($file.path)'" @@ -182,7 +214,7 @@ function Export-PSGraph if ( $useStandardInput ) { Write-Verbose 'Processing standard input' - if ( -Not $PSBoundParameters.ContainsKey( 'DestinationPath' ) ) + if ( -Not $PSBoundParameters.ContainsKey( 'DestinationPath' ) -and -Not $PassThru ) { Write-Verbose ' Creating temporary path to save graph' @@ -200,12 +232,17 @@ function Export-PSGraph $arguments = Get-GraphVizArgument $PSBoundParameters Write-Verbose " Arguments: $($arguments -join ' ')" - $null = $standardInput.ToString() | & $graphViz @($arguments) + $result = $standardInput.ToString() | & $graphViz @($arguments) if ($LastExitCode) { Write-Error -ErrorAction Stop -Exception ([System.Management.Automation.ParseException]::New()) } + if ( $PassThru ) + { + return $result + } + if ( $ShowGraph ) { # Launches image with default viewer as decided by explorer diff --git a/PSGraph/Public/Install-GraphViz.ps1 b/PSGraph/Public/Install-GraphViz.ps1 index ccf8e24..554a795 100644 --- a/PSGraph/Public/Install-GraphViz.ps1 +++ b/PSGraph/Public/Install-GraphViz.ps1 @@ -3,17 +3,26 @@ function Install-GraphViz <# .Description Installs GraphViz package using online provider + .PARAMETER Scope + Use -Scope CurrentUser to install as a non-admin user to a per-user + location instead of Program Files. Unused on macOS. .Example Install-GraphViz + .Example + Install-GraphViz -Scope CurrentUser #> [cmdletbinding( SupportsShouldProcess = $true, ConfirmImpact = "High" )] - param() + param( + [ValidateSet('AllUsers', 'CurrentUser')] + [string] + $Scope = 'AllUsers' + ) process { try { - if ( $IsOSX ) + if ( $IsMacOS ) { if ( $PSCmdlet.ShouldProcess( 'Install graphviz' ) ) { @@ -24,12 +33,29 @@ function Install-GraphViz { if ( $PSCmdlet.ShouldProcess('Register Chocolatey provider and install graphviz' ) ) { - if ( -Not ( Get-PackageProvider | Where-Object ProviderName -eq 'Chocolatey' ) ) + if ( -Not ( Get-PackageSource | Where-Object ProviderName -eq 'Chocolatey' ) ) { - Register-PackageSource -Name Chocolatey -ProviderName Chocolatey -Location http://chocolatey.org/api/v2/ + try + { + Register-PackageSource -Name Chocolatey -ProviderName Chocolatey -Location http://chocolatey.org/api/v2/ -ErrorAction Stop + } + catch + { + # Registering Chocolatey typically requires admin rights. Fall back to the + # (older, but still functional) GraphViz package on nuget.org instead of failing outright. + $nugetSource = Get-PackageSource | Where-Object { $_.Location -like 'https://api.nuget.org/v*' } + if ( -Not $nugetSource ) + { + Write-Warning 'No nuget.org package source found to fall back on. Cannot install GraphViz.' + throw + } + + Write-Warning 'Could not register a Chocolatey package provider (this typically requires admin rights). Falling back to the older GraphViz package on nuget.org.' + Write-Warning 'Install Chocolatey and re-run this command to get the latest GraphViz.' + } } - Find-Package graphviz | Install-Package -Verbose -ForceBootstrap + Find-Package graphviz | Install-Package -Verbose -ForceBootstrap -Scope $Scope } } } diff --git a/PSGraph/Public/New-EdgeAttributeSet.ps1 b/PSGraph/Public/New-EdgeAttributeSet.ps1 new file mode 100644 index 0000000..4bf4073 --- /dev/null +++ b/PSGraph/Public/New-EdgeAttributeSet.ps1 @@ -0,0 +1,135 @@ +function New-EdgeAttributeSet +{ + <# + .SYNOPSIS + Builds a GraphViz attribute hashtable for the Edge command. + + .DESCRIPTION + Edge takes a hashtable of attributes, but GraphViz attribute names and values are + case-sensitive and easy to get wrong ('blue' works, 'Blue' does not). This command + exposes the common edge attributes as PowerShell parameters - with tab completion for + arrowhead/color/font values - and normalizes casing for the ones GraphViz requires lowercase. + + .EXAMPLE + $attrs = New-EdgeAttributeSet -Direction both -ArrowHead crow -ArrowTail lcrow -Color Blue -Style dashed -Label test + edge one two $attrs + + This defines a two-way dashed edge, in blue, with a "crow" head and left-half-crow tail. + + .NOTES + Ported from upstream PR #105 (jhoneill). The source PR called .ToLower() on every + attribute value including numeric/boolean ones, which throws - this version only + lowercases the string-valued attributes GraphViz actually requires lowercase. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + [Alias('EdgeAttributes')] + [OutputType([hashtable])] + param( + # Style of arrowhead on the head node of an edge. Only shown when Direction is 'forward' or 'both'. + [string] + $ArrowHead, + + # Multiplicative scale factor for arrowheads + [double] + $ArrowSize, + + # Style of arrowhead on the tail node of an edge. Only shown when Direction is 'back' or 'both'. + [string] + $ArrowTail, + + # Basic drawing color for graphics, not text (which requires FontColor to be set) + [string] + $Color, + + # If false, the edge is not used when ranking nodes + [bool] + $Constraint, + + # Which ends of the edge should be decorated with an arrowhead + [ValidateSet('forward', 'back', 'both', 'none')] + [string] + $Direction, + + # Color used for text + [string] + $FontColor, + + # Font used for text + [string] + $FontName, + + # Font size, in points, used for text + [double] + $FontSize, + + # Text label placed near the head of the edge + [string] + $HeadLabel, + + # Text label attached to the edge + [string] + $Label, + + # Color used for HeadLabel/TailLabel; defaults to the edge's FontColor if unset + [string] + $LabelFontColor, + + # Font used for HeadLabel/TailLabel; defaults to the edge's FontName if unset + [string] + $LabelFontName, + + # Font size, in points, used for HeadLabel/TailLabel; defaults to the edge's FontSize if unset + [double] + $LabelFontSize, + + # Preferred edge length, in inches + [double] + $Length, + + # Width of the pen, in points, used to draw lines and curves + [double] + $PenWidth, + + # Style for the edge, e.g. dashed, solid + [ValidateSet('dashed', 'dotted', 'solid', 'invis', 'bold', 'tapered')] + [string] + $Style, + + # Text label placed near the tail of the edge + [string] + $TailLabel + ) + + $values = @{} + + # Attributes where the GraphViz key is shortened from the parameter name + if ($PSBoundParameters.ContainsKey('Direction')) + { + $values['dir'] = $Direction.ToLower() + } + if ($PSBoundParameters.ContainsKey('Length')) + { + $values['len'] = $Length + } + + # GraphViz requires these lowercase; user input may not be + foreach ($param in @('ArrowHead', 'ArrowTail', 'Color', 'FontColor', 'LabelFontColor', 'Style')) + { + if ($PSBoundParameters.ContainsKey($param)) + { + $values[$param.ToLower()] = $PSBoundParameters[$param].ToLower() + } + } + + # Passed through unchanged - numeric, boolean, or free-form text where case is meaningful + foreach ($param in @('ArrowSize', 'Constraint', 'FontName', 'FontSize', 'HeadLabel', 'Label', 'LabelFontName', 'LabelFontSize', 'PenWidth', 'TailLabel')) + { + if ($PSBoundParameters.ContainsKey($param)) + { + $values[$param.ToLower()] = $PSBoundParameters[$param] + } + } + + $values +} diff --git a/PSGraph/Public/New-NodeAttributeSet.ps1 b/PSGraph/Public/New-NodeAttributeSet.ps1 new file mode 100644 index 0000000..4845963 --- /dev/null +++ b/PSGraph/Public/New-NodeAttributeSet.ps1 @@ -0,0 +1,204 @@ +function New-NodeAttributeSet +{ + <# + .SYNOPSIS + Builds a GraphViz attribute hashtable for the Node command. + + .DESCRIPTION + Node takes a hashtable of attributes, but GraphViz attribute names and values are + case-sensitive and easy to get wrong ('blue' works, 'Blue' does not). This command + exposes the common node attributes as PowerShell parameters - with tab completion for + shape/color/font values - and normalizes casing for the ones GraphViz requires lowercase. + + .EXAMPLE + $attrs = New-NodeAttributeSet -Shape box -Color Blue -FontName 'Calibri' -Label 'test' + node MyNode $attrs + + .NOTES + Ported from upstream PR #105 (jhoneill). The source PR called .ToLower() on every + attribute value including numeric/boolean ones, which throws - this version only + lowercases the string-valued attributes GraphViz actually requires lowercase. + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] + [CmdletBinding()] + [Alias('NodeAttributes')] + [OutputType([hashtable])] + param( + # Basic drawing color for graphics, not text (which requires FontColor to be set) + [string] + $Color, + + # Distortion factor for shape=polygon. Positive values enlarge the top; negative the bottom. + [double] + $Distortion, + + # Background color used to fill the node's shape + [string] + $FillColor, + + # If true, node size is fixed to Width/Height and not expanded to fit the label + [ValidateSet('false', 'shape', 'true')] + [string] + $FixedSize, + + # Color used for text + [string] + $FontColor, + + # Font used for text + [string] + $FontName, + + # Font size, in points, used for text + [double] + $FontSize, + + # Height of node, in inches - the initial, minimum height + [double] + $Height, + + # Path to an image file to display inside the node (JPEG/PNG/GIF/BMP/SVG/PostScript) + [string] + $Image, + + # Text label attached to the node + [string] + $Label, + + # Width of the pen, in points, used to draw lines and curves + [double] + $PenWidth, + + # Forces a polygon shape to be regular (vertices lie on a circle centered on the node) + [switch] + $Regular, + + # A string specifying the shape of a node + [ValidateSet('box', 'polygon', 'ellipse', 'oval', 'circle', 'point', 'egg', 'triangle', 'plaintext', 'plain', 'diamond', + 'trapezium', 'parallelogram', 'house', 'pentagon', 'hexagon', 'septagon', 'octagon', 'doublecircle', + 'doubleoctagon', 'tripleoctagon', 'invtriangle', 'invtrapezium', 'invhouse', 'Mdiamond', 'Msquare', + 'Mcircle', 'rect', 'rectangle', 'square', 'star', 'none', 'underline', 'cylinder', 'note', 'tab', + 'folder', 'box3d', 'component', 'promoter', 'cds', 'terminator', 'utr', 'primersite', 'restrictionsite', + 'fivepoverhang', 'threepoverhang', 'noverhang', 'assembly', 'signature', 'insulator', 'ribosite', + 'rnastab', 'proteasesite', 'proteinstab', 'rpromoter', 'rarrow', 'larrow', 'lpromoter')] + [string] + $Shape, + + # Number of sides if Shape is 'polygon' + [int16] + $Sides, + + # Skew factor for shape=polygon. Positive skews the top right; negative skews it left. + [double] + $Skew, + + # Style for the node, e.g. filled, dashed, rounded + [ValidateSet('dashed', 'dotted', 'solid', 'invis', 'bold', 'filled', 'striped', 'wedged', 'diagonals', 'rounded')] + [string] + $Style, + + # Width of node, in inches - the initial, minimum width + [double] + $Width + ) + + $values = @{} + + # GraphViz requires these lowercase; user input may not be + foreach ($param in @('Color', 'FillColor', 'FixedSize', 'FontColor', 'Shape', 'Style')) + { + if ($PSBoundParameters.ContainsKey($param)) + { + $values[$param.ToLower()] = $PSBoundParameters[$param].ToLower() + } + } + + # Passed through unchanged - numeric, or free-form text where case is meaningful + foreach ($param in @('Distortion', 'FontName', 'FontSize', 'Height', 'Image', 'Label', 'PenWidth', 'Sides', 'Skew', 'Width')) + { + if ($PSBoundParameters.ContainsKey($param)) + { + $values[$param.ToLower()] = $PSBoundParameters[$param] + } + } + + if ($Regular) + { + $values['regular'] = $true + } + + $values +} + +function Get-PSGraphColorCompletion +{ + # commandName/parameterName/commandAst/fakeBoundParameter are required by + # Register-ArgumentCompleter's scriptblock signature but unused here. + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) + try + { + [System.Drawing.KnownColor].GetFields() | + Where-Object { $_.IsStatic -and -not $_.IsSpecialName -and $_.Name -like "$wordToComplete*" } | + Sort-Object Name | + ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_.Name.ToLower(), $_.Name.ToLower(), 'ParameterValue', $_.Name.ToLower()) + } + } + catch + { + # System.Drawing isn't guaranteed to be available/functional on every platform - degrade to no suggestions + Write-Debug "Get-PSGraphColorCompletion: System.Drawing unavailable - $PSItem" + } +} + +function Get-PSGraphFontCompletion +{ + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) + if ($null -eq $script:PSGraphFontFamilies) + { + try + { + $script:PSGraphFontFamilies = (New-Object System.Drawing.Text.InstalledFontCollection).Families.Name + } + catch + { + # Not available on this platform (e.g. non-Windows without GDI+) - degrade to no suggestions + Write-Debug "Get-PSGraphFontCompletion: System.Drawing.Text unavailable - $PSItem" + $script:PSGraphFontFamilies = @() + } + } + $script:PSGraphFontFamilies.Where({ $_ -like "$wordToComplete*" }) | ForEach-Object { + [System.Management.Automation.CompletionResult]::new("'$_'", $_, 'ParameterValue', $_) + } +} + +function Get-PSGraphArrowCompletion +{ + [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSReviewUnusedParameter", "")] + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameter) + $baseArrows = @('box', 'crow', 'curve', 'diamond', 'dot', 'inv', 'none', 'normal', 'tee', 'vee') + $modifierPrefixMatch = [regex]::Match($wordToComplete, '^[olr]{1,2}') + $prefix = if ($modifierPrefixMatch.Success) { $modifierPrefixMatch.Value } else { '' } + + $baseArrows | Where-Object { "$prefix$_" -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new("$prefix$_", "$prefix$_", 'ParameterValue', "$prefix$_") + } +} + +if (Get-Command -Name Register-ArgumentCompleter -ErrorAction SilentlyContinue) +{ + Register-ArgumentCompleter -CommandName New-NodeAttributeSet -ParameterName Color -ScriptBlock ${function:Get-PSGraphColorCompletion} + Register-ArgumentCompleter -CommandName New-NodeAttributeSet -ParameterName FillColor -ScriptBlock ${function:Get-PSGraphColorCompletion} + Register-ArgumentCompleter -CommandName New-NodeAttributeSet -ParameterName FontColor -ScriptBlock ${function:Get-PSGraphColorCompletion} + Register-ArgumentCompleter -CommandName New-NodeAttributeSet -ParameterName FontName -ScriptBlock ${function:Get-PSGraphFontCompletion} + + Register-ArgumentCompleter -CommandName New-EdgeAttributeSet -ParameterName ArrowHead -ScriptBlock ${function:Get-PSGraphArrowCompletion} + Register-ArgumentCompleter -CommandName New-EdgeAttributeSet -ParameterName ArrowTail -ScriptBlock ${function:Get-PSGraphArrowCompletion} + Register-ArgumentCompleter -CommandName New-EdgeAttributeSet -ParameterName Color -ScriptBlock ${function:Get-PSGraphColorCompletion} + Register-ArgumentCompleter -CommandName New-EdgeAttributeSet -ParameterName FontColor -ScriptBlock ${function:Get-PSGraphColorCompletion} + Register-ArgumentCompleter -CommandName New-EdgeAttributeSet -ParameterName LabelFontColor -ScriptBlock ${function:Get-PSGraphColorCompletion} + Register-ArgumentCompleter -CommandName New-EdgeAttributeSet -ParameterName FontName -ScriptBlock ${function:Get-PSGraphFontCompletion} + Register-ArgumentCompleter -CommandName New-EdgeAttributeSet -ParameterName LabelFontName -ScriptBlock ${function:Get-PSGraphFontCompletion} +} diff --git a/PSGraph/Public/Rank.ps1 b/PSGraph/Public/Rank.ps1 index 8865401..7ecf261 100644 --- a/PSGraph/Public/Rank.ps1 +++ b/PSGraph/Public/Rank.ps1 @@ -21,6 +21,12 @@ function Rank edge $odd -to $even } + .Example + graph g { + rank 1,2,3 -RankType min + edge (1..3) + } + .Notes Accepts an array of items or a list of strings. #> @@ -48,7 +54,12 @@ function Rank # Script to run on each node [alias('Script')] [scriptblock] - $NodeScript = {$_} + $NodeScript = {$_}, + + # GraphViz rank type used to constrain the relative layout of this node set + [ValidateSet('same', 'min', 'source', 'max', 'sink')] + [string] + $RankType = 'same' ) begin @@ -97,6 +108,6 @@ function Rank end { - '{0}{{ rank=same; {1}; }}' -f (Get-Indent), ($values -join '; ') + '{0}{{ rank={1}; {2}; }}' -f (Get-Indent), $RankType, ($values -join '; ') } } diff --git a/PSGraph/Public/Row.ps1 b/PSGraph/Public/Row.ps1 index 56f51f2..da550eb 100644 --- a/PSGraph/Public/Row.ps1 +++ b/PSGraph/Public/Row.ps1 @@ -18,6 +18,10 @@ function Row This will encode unintentional HTML. Characters like <>& would break html parsing if they are contained in the source data. + .PARAMETER Attributes + Additional HTML attributes applied to this row's cell, e.g. @{BORDER = 0} to hide the row's + border line, or @{BGCOLOR = 'lightgrey'} to shade it. + .EXAMPLE graph { @@ -64,7 +68,11 @@ function Row $Name, [switch] - $HtmlEncode + $HtmlEncode, + + # Additional HTML attributes for this row's cell, e.g. @{BORDER = 0} to hide the row's border + [hashtable] + $Attributes = @{} ) process { @@ -90,7 +98,10 @@ function Row { $Label = ([System.Net.WebUtility]::HtmlEncode($Label)) } - '{1}' -f $Name, $Label + + $extraAttributes = ($Attributes.GetEnumerator() | ForEach-Object { ' {0}="{1}"' -f $_.Key.ToString().ToUpper(), $_.Value }) -join '' + + '{2}' -f $Name, $extraAttributes, $Label } } } \ No newline at end of file diff --git a/Tests/Export-PSGraph.Tests.ps1 b/Tests/Export-PSGraph.Tests.ps1 index 7d11e77..7a4a665 100644 --- a/Tests/Export-PSGraph.Tests.ps1 +++ b/Tests/Export-PSGraph.Tests.ps1 @@ -96,4 +96,49 @@ Describe "$ModuleName Export-PSGraph" -Tag graphviz { { Export-PSGraph -Source $dot -DestinationPath (Join-Path $testdrive "badpath.png") -GraphVizPath 'C:\does\not\exist\dot.exe' -ErrorAction Stop } | Should -Throw } } + + Context "PR #105 -PassThru stdout/SVG export" { + + It "Returns the rendered graph as text instead of writing a file" { + $tempCountBefore = (Get-ChildItem ([System.IO.Path]::GetTempPath()) -Filter '*.svg' -ErrorAction SilentlyContinue).Count + + $result = $dot | Export-PSGraph -OutputFormat svg -PassThru + + $result | Should -Not -BeNullOrEmpty + ($result -join "`n") | Should -Match ''" -TestCases @( + @{ RankType = 'same' } + @{ RankType = 'min' } + @{ RankType = 'source' } + @{ RankType = 'max' } + @{ RankType = 'sink' } + ) { + param($RankType) + + $result = rank lhs rhs -RankType $RankType + $result | Should -Match "\{ rank=$RankType; `"lhs`"; `"rhs`"; \}" + } + + It "Rejects an invalid -RankType value" { + { rank lhs rhs -RankType 'bogus' } | Should -Throw + } + } + } diff --git a/Tests/Record.Tests.ps1 b/Tests/Record.Tests.ps1 index ce7094a..56140fe 100644 --- a/Tests/Record.Tests.ps1 +++ b/Tests/Record.Tests.ps1 @@ -80,7 +80,25 @@ Describe "Function Record" -Tag Build { $result | Should -match test $result | Should -match 'PORT="first"' $result | Should -match 'PORT="Second"' - $result | Should -match 'first' + $result | Should -match 'first' $result | Should -match 'Second' } + + Context "#65 GUID-style row ports round-trip through Edge" { + + It 'quotes a GUID row port when targeted by Edge' { + $rowId = '3fa85f64-5717-4562-b3fc-2c963f66afa6' + + $dot = graph g { + Record Table1 { + Row -Name $rowId -Label 'Row with a GUID id' + } + Node Table2 + Edge "Table1:$rowId" -To Table2 + } + + ($dot -join "`n") | Should -Match "PORT=`"$rowId`"" + ($dot -join "`n") | Should -Match "`"Table1`":`"$rowId`"->`"Table2`"" + } + } } diff --git a/Tests/Row.Tests.ps1 b/Tests/Row.Tests.ps1 index 27b537c..d987ddc 100644 --- a/Tests/Row.Tests.ps1 +++ b/Tests/Row.Tests.ps1 @@ -31,4 +31,22 @@ Describe "Function Row" { Row Label -ID Test | Should -Match 'PORT="Test"' Row Label -Name Test | Should -Match 'PORT="Test"' } + + Context "#64 Row attributes" { + + It 'applies additional attributes to the row cell' { + Row Test -Attributes @{BORDER = 0 } | Should -Match 'BORDER="0"' + } + + It 'still emits the standard PORT/ALIGN attributes alongside extra attributes' { + $result = Row Test -Attributes @{BGCOLOR = 'lightgrey' } + $result | Should -Match 'PORT="Test"' + $result | Should -Match 'ALIGN="LEFT"' + $result | Should -Match 'BGCOLOR="lightgrey"' + } + + It 'does not add any extra attributes when none are specified' { + Row Test | Should -Be 'Test' + } + } }